From 74362d440ba88eab95d72a1221780a8152e3a337 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 06:18:36 +0800 Subject: [PATCH 01/21] working proof --- app/build.gradle.kts | 5 +- build.gradle.kts | 2 + core/build.gradle.kts | 2 + desktopApp/build.gradle.kts | 21 +++++++++ .../xyz/sevive/arcaeaoffline/desktop/Main.kt | 47 +++++++++++++++++++ gradle/libs.versions.toml | 13 ++++- settings.gradle.kts | 2 + shared/build.gradle.kts | 14 ++++++ .../xyz/sevive/arcaeaoffline/core/Progress.kt | 0 .../arcaeaoffline/core/calculators/Common.kt | 0 .../core/constants/ArcaeaLanguage.kt | 0 .../constants/ArcaeaPlayResultClearType.kt | 0 .../constants/ArcaeaPlayResultModifier.kt | 0 .../core/constants/ArcaeaRatingClass.kt | 0 14 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 desktopApp/build.gradle.kts create mode 100644 desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt create mode 100644 shared/build.gradle.kts rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt (100%) rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt (100%) rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaLanguage.kt (100%) rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt (100%) rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultModifier.kt (100%) rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaRatingClass.kt (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c9ba255..cc376368 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -148,6 +148,9 @@ android { } dependencies { + implementation(project(":shared")) + implementation(project(":core")) + // android & androidx coreLibraryDesugaring(libs.desugarJdkLibs) @@ -235,6 +238,4 @@ dependencies { androidTestImplementation(androidx.compose.ui.test.junit4) debugImplementation(androidx.compose.ui.test.manifest) - - implementation(project(":core")) } diff --git a/build.gradle.kts b/build.gradle.kts index a0944c1e..77e22fca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,9 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.koin.compiler) apply false + alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.jetbrains.compose) apply false alias(androidx.plugins.room) apply false diff --git a/core/build.gradle.kts b/core/build.gradle.kts index d037942c..491921f4 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -53,6 +53,8 @@ android { } dependencies { + implementation(project(":shared")) + implementation(libs.kotlinx.io) implementation(libs.kotlinx.serialization) implementation(libs.ktoml.core) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 00000000..60729ac0 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.compose.compiler) +} + +dependencies { + implementation(project(":shared")) + implementation(compose.desktop.currentOs) + + implementation(libs.compose.runtime) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) +} + +compose.desktop { + application { + mainClass = "xyz.sevive.arcaeaoffline.desktop.MainKt" + } +} diff --git a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt new file mode 100644 index 00000000..a67d5a22 --- /dev/null +++ b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt @@ -0,0 +1,47 @@ +package xyz.sevive.arcaeaoffline.desktop + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating + +fun main() = + application { + Window(onCloseRequest = ::exitApplication, title = "Arcaea Offline - Play Rating Calculator") { + CalculatorScreen() + } + } + +@Composable +fun CalculatorScreen() { + var scoreText by remember { mutableStateOf("") } + var constantText by remember { mutableStateOf("") } + + val score = scoreText.toIntOrNull() ?: 0 + val constant = (constantText.toDoubleOrNull() ?: 0.0) + val result = calculatePlayRating(score, (constant * 10).toInt()) + + MaterialTheme { + Column(Modifier.padding(16.dp).width(400.dp)) { + OutlinedTextField(scoreText, { scoreText = it }, label = { Text("Score") }) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(constantText, { constantText = it }, label = { Text("Constant (e.g. 10.7)") }) + Spacer(Modifier.height(16.dp)) + Text("Play Rating: ${"%.4f".format(result)}") + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b4e64dcb..101db802 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,9 @@ ksp = "2.3.9" koin = "4.2.1" koin-plugin = "1.0.1" +compose-multiplatform = "1.11.1" +compose-material3 = "1.9.0" + opencv = "4.13.0" apache-commons-compress = "1.28.0" @@ -45,6 +48,11 @@ koin-android = { module = "io.insert-koin:koin-android" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" } koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } + opencv = { module = "org.opencv:opencv", version.ref = "opencv" } apache-commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "apache-commons-compress" } @@ -78,10 +86,11 @@ android-application = { id = "com.android.application", version.ref = "androidGr kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version = "2.0.1" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 947c67c1..d29b9587 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,3 +33,5 @@ dependencyResolutionManagement { rootProject.name = "Arcaea Offline" include("app") include(":core") +include(":shared") +include(":desktopApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 00000000..102221a8 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { +// alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.multiplatform) +// kotlin("multiplatform") +} + +kotlin { +// androidTarget() + jvm() + + sourceSets { + jvmMain.dependencies { } + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt similarity index 100% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/Progress.kt diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt similarity index 100% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/calculators/Common.kt diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaLanguage.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaLanguage.kt similarity index 100% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaLanguage.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaLanguage.kt diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt similarity index 100% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultClearType.kt diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultModifier.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultModifier.kt similarity index 100% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultModifier.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaPlayResultModifier.kt diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaRatingClass.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaRatingClass.kt similarity index 100% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaRatingClass.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/constants/ArcaeaRatingClass.kt From 44e1bdae9ff787ec5f1943cd29086195a5274f87 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 06:42:06 +0800 Subject: [PATCH 02/21] move commonTest --- .../sevive/arcaeaoffline/core/ExampleUnitTest.kt | 16 ---------------- shared/build.gradle.kts | 4 ++++ .../core/calculators/CommonCalculatorsTest.kt | 14 +++++++------- 3 files changed, 11 insertions(+), 23 deletions(-) delete mode 100644 core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ExampleUnitTest.kt rename {core/src/test => shared/src/commonTest}/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt (78%) diff --git a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ExampleUnitTest.kt b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ExampleUnitTest.kt deleted file mode 100644 index 078636d8..00000000 --- a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package xyz.sevive.arcaeaoffline.core - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 102221a8..a8049548 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -10,5 +10,9 @@ kotlin { sourceSets { jvmMain.dependencies { } + + commonTest.dependencies { + implementation(kotlin("test")) + } } } diff --git a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt similarity index 78% rename from core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt rename to shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt index 6b5645a0..493131da 100644 --- a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/core/calculators/CommonCalculatorsTest.kt @@ -1,9 +1,9 @@ package xyz.sevive.arcaeaoffline.core.calculators -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull class CommonCalculatorsTest { @Test @@ -45,15 +45,15 @@ class CommonCalculatorsTest { fun testInvertPlayRating() { val result1 = calculateInvertScoreRange(targetPlayRating = 12.465, constant = 107) assertNotNull(result1) - assert(9_952_935 in result1!!) { "9_952_935 [10.7] > 12.4647" } + assert(9_952_935 in result1) { "9_952_935 [10.7] > 12.4647" } val result2 = calculateInvertScoreRange(targetPlayRating = 12.345, constant = 111) assertNotNull(result2) - assert(9_849_089 in result2!!) { "9_849_089 [11.1] > 12.3454" } + assert(9_849_089 in result2) { "9_849_089 [11.1] > 12.3454" } val result3 = calculateInvertScoreRange(targetPlayRating = 14.0, constant = 120) assertNotNull(result3) - assert(10_002_221 in result3!!) { "10_002_221 [12.0] > 14.0" } + assert(10_002_221 in result3) { "10_002_221 [12.0] > 14.0" } assertNull(calculateInvertScoreRange(targetPlayRating = 14.0, constant = 80)) } From d195aa0b36d118f49ef1f2e2eb48d4ae535a5769 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 10:57:59 +0800 Subject: [PATCH 03/21] change window title --- .../src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt index a67d5a22..a0ef74b4 100644 --- a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt @@ -21,7 +21,7 @@ import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating fun main() = application { - Window(onCloseRequest = ::exitApplication, title = "Arcaea Offline - Play Rating Calculator") { + Window(onCloseRequest = ::exitApplication, title = "Arcaea Offline (Proof-of-Concept Prototype)") { CalculatorScreen() } } From dabca9f4967e5028983739e073db2fa3a9919d8a Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 12:59:53 +0800 Subject: [PATCH 04/21] confirm shared ui and string resources work --- .../ui/components/ArcaeaPlayResultEditor.kt | 2 + .../ui/components/PlayRatingCalculator.kt | 2 + .../UtilitiesChartRecommendScreen.kt | 4 +- desktopApp/build.gradle.kts | 1 + .../xyz/sevive/arcaeaoffline/desktop/Main.kt | 47 ++- gradle/libs.versions.toml | 5 + shared/build.gradle.kts | 24 +- .../values-zh-rCN/strings.xml | 333 ++++++++++++++++ .../composeResources/values/strings.xml | 363 ++++++++++++++++++ .../ui/components/DecimalStepperTextField.kt | 21 +- .../arcaea}/ArcaeaScoreTextField.kt | 9 +- 11 files changed, 779 insertions(+), 32 deletions(-) create mode 100644 shared/src/commonMain/composeResources/values-zh-rCN/strings.xml create mode 100644 shared/src/commonMain/composeResources/values/strings.xml rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt (97%) rename {app/src/main/java/xyz/sevive/arcaeaoffline/ui/components => shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/arcaea}/ArcaeaScoreTextField.kt (95%) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt index 4ce25ed5..053b46f2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaPlayResultEditor.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.res.stringResource import kotlinx.serialization.json.Json import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField +import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt index ce71d04e..b304c2d7 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayRatingCalculator.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField +import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @Composable diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt index a5f04b65..9b475c59 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt @@ -60,9 +60,9 @@ import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface import xyz.sevive.arcaeaoffline.ui.components.DecimalStepperTextField import xyz.sevive.arcaeaoffline.ui.components.ListGroupHeader -import xyz.sevive.arcaeaoffline.ui.components.OutlinedArcaeaScoreTextField +import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.PlayRatingCalculator -import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.rememberDecimalStepperTextFieldState import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.UtilitiesSubScreen diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 60729ac0..7aaff766 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.compose.foundation) + implementation(libs.compose.components.resources) } compose.desktop { diff --git a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt index a0ef74b4..501d5a12 100644 --- a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt @@ -1,23 +1,29 @@ package xyz.sevive.arcaeaoffline.desktop +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import org.jetbrains.compose.resources.stringResource import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating +import xyz.sevive.arcaeaoffline.resources.Res +import xyz.sevive.arcaeaoffline.resources.arcaea_constant +import xyz.sevive.arcaeaoffline.resources.arcaea_play_rating +import xyz.sevive.arcaeaoffline.ui.components.DecimalStepperTextField +import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField +import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaConstantStepperTextFieldState fun main() = application { @@ -28,20 +34,31 @@ fun main() = @Composable fun CalculatorScreen() { - var scoreText by remember { mutableStateOf("") } - var constantText by remember { mutableStateOf("") } + val scoreTextFieldState = rememberArcaeaScoreTextFieldState(initialValue = 0) + val constantTextFieldState = rememberArcaeaConstantStepperTextFieldState(initialValue = 0.0) - val score = scoreText.toIntOrNull() ?: 0 - val constant = (constantText.toDoubleOrNull() ?: 0.0) - val result = calculatePlayRating(score, (constant * 10).toInt()) + val score by remember { derivedStateOf { scoreTextFieldState.intValue ?: 0 } } + val constant by remember { derivedStateOf { ((constantTextFieldState.doubleValue ?: 0.0) * 10).toInt() } } + val result = calculatePlayRating(score, constant) MaterialTheme { - Column(Modifier.padding(16.dp).width(400.dp)) { - OutlinedTextField(scoreText, { scoreText = it }, label = { Text("Score") }) - Spacer(Modifier.height(8.dp)) - OutlinedTextField(constantText, { constantText = it }, label = { Text("Constant (e.g. 10.7)") }) - Spacer(Modifier.height(16.dp)) - Text("Play Rating: ${"%.4f".format(result)}") + Column( + Modifier.padding(16.dp).width(400.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedArcaeaScoreTextField(scoreTextFieldState) + + DecimalStepperTextField( + constantTextFieldState, + label = { Text(stringResource(Res.string.arcaea_constant)) }, + ) + + OutlinedTextField( + value = "%.4f".format(result), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.arcaea_play_rating)) }, + ) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 101db802..a4875df9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ koin-plugin = "1.0.1" compose-multiplatform = "1.11.1" compose-material3 = "1.9.0" +compose-material-icons-extended = "1.7.3" opencv = "4.13.0" apache-commons-compress = "1.28.0" @@ -51,7 +52,11 @@ koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } +compose-ui-graphics = { module = "org.jetbrains.compose.ui:ui-graphics", version.ref = "compose-multiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } +compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } +compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-material-icons-extended" } opencv = { module = "org.opencv:opencv", version.ref = "opencv" } apache-commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "apache-commons-compress" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a8049548..fea9f2e2 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,7 +1,16 @@ plugins { // alias(libs.plugins.android.application) alias(libs.plugins.kotlin.multiplatform) -// kotlin("multiplatform") + alias(libs.plugins.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +compose { + resources { + publicResClass = true + packageOfResClass = "xyz.sevive.arcaeaoffline.resources" + generateResClass = always + } } kotlin { @@ -9,6 +18,19 @@ kotlin { jvm() sourceSets { + commonMain.dependencies { + implementation(libs.compose.runtime) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.foundation) + implementation(libs.compose.material.icons.extended) + implementation(libs.compose.components.resources) + implementation(libs.compose.ui.tooling) + + implementation(libs.bignum) + } + jvmMain.dependencies { } commonTest.dependencies { diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml new file mode 100644 index 00000000..6bfb2852 --- /dev/null +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -0,0 +1,333 @@ + + Arcaea Offline + + + 返回 + 编辑此项 + 删除此项 + + + OK + 完成 + 保存 + 编辑 + 删除 + 重置 + 请稍候…… + 导入 + 停止 + 无结果 + 更新于 %s + 未知错误 + + 升序 + 升序 + 降序 + 降序 + + 未安装 Arcaea + 从 Arcaea 导入 + + + Arcaea 资源不可用 + + + // 无日期 // + // 无 Clear Type // + // 无 Modifier // + // 无备注 // + + + 应用崩溃。 + 发生错误。 + 应用安装 ID + 报告 ID + 崩溃时间 + 要发送崩溃报告吗? + 备注(可选) + 你在应用崩溃时尝试执行什么操作? + 联系方式(可选) + 当我们需要更多有关信息时,该如何联系你? + 发送 + 暂存 + 忽略 + 已尝试发送崩溃报告 + 崩溃报告已发送 + 崩溃报告发送失败 + 崩溃报告已保存 + 崩溃报告保存失败 + 崩溃报告已忽略 + + + 未授予权限 + + “%s”需要以下权限: + + + 保存图片 + + + KNearest 模型 + 图像哈希数据库 + CRNN OCR 模型 + + + 谱面 + 游玩记录 + 分数 + MAX RECALL + 通关状态 + Modifier + 备注 + 潜力值 + 定数 + 单曲潜力值 + + + 等待 OCR 结果…… + 保存图片 + 暂存 + 成绩已保存 + 返回上一应用 + 留在 %s + + + 概览 + 数据库 + 小工具 + OCR + 设置 + + + 版本 %d + + %d 个曲包 + + + %d 首歌曲 + + + %d 个难度 + + + %d 个谱面信息 + + + %d 个游玩记录 + + + 数据库状态 + 初始化 + 数据库未初始化 + + + 管理 + + 导入 + 曲包 + 曲包本地化对象 + 歌曲 + 歌曲本地化对象 + 难度 + 难度本地化对象 + 谱面信息 + 游玩记录 + packlist + songlist + 从 Arcaea 安装包导入 + 正在读取安装包,耗时可能较长…… + 从 Arcaea 导入 + 谱面信息数据库 + + 导出 + 游玩记录 + + + 添加游玩记录 + 选择谱面…… + 编辑游玩记录…… + 点此选择谱面 + 请先选择谱面 + + + 游玩记录列表 + + 删除 %d 个游玩记录? + + 游玩记录 %s 已更新 + + + B30 列表 + + + R30 列表 + 重建 R30 列表 + 将清除目前 R30 列表内的所有记录,然后再从头计算 R30。确定吗? + + + 去重器 + 自动选择(完全相同) + 对每组完全相同的游玩记录,仅保留其中 ID 最小的项 + 自动选择(属性优先) + 对每一重复组,仅保留属性最多的一项 + 自动选择(R30 优先) + 对每一重复组,仅保留有日期的一项 + 自动合并所有重复组 + 自动合并在一般情况下没有问题,但也有可能合并出不太对劲的结果,并且在处理过程中不能手动介入或更改。 + + + + 已导入 %d 个曲包 + + + 已导入 %d 个曲包本地化对象 + + + 已导入 %d 首歌曲 + + + 已导入 %d 个歌曲本地化对象 + + + 已导入 %d 个难度 + + + 已导入 %d 个难度本地化对象 + + + 已导入 %d 个谱面信息 + + + 已导入 %d 个游玩记录 + + + 已导出 %d 个游玩记录 + + + + 快速搜索…… + 选择曲包 + 选择歌曲 + + 主要字段 + 其他字段 + 置空 + 备注 + + 日期时间选择 + 日期 + 时间 + + + 单曲潜力值计算 + 推荐谱面 + 分数下界 + 分数上界 + 期望单曲潜力值 + 参数 + 结果 + + + OCR 队列 + + 多选图像 + 导入文件夹 + 添加图片 + 检查是否为图片 + 检查是否为 Arcaea 截图 + 队列 + 并行数量 + 待命 + 正在处理 + 完成 + 完成但有警告 + 出错 + + + %s 个文件已添加至暂存区 + + 正在扫描 "%s" + 暂存处理错误:%s + 暂存区已清空 + 暂存 + + 验证 + 空闲 + 开始 + 清空 + + + OCR 依赖项 + + + 发送崩溃报告 + 通用 + 关于 + 应用 ID + 版本 + 版本代号 + 许可证 + 开源组件 + + 对分享图片 OCR + + + + %d 条警告 + + + 分数为零 + 分数为 0。 + + 分数不在范围内 + 所给分数不在由 PURE、FAR 计算而得的分数范围内 + + Note 溢出 + PURE、FAR、LOST 的和比谱面总物件数要大 + + MAX RECALL 溢出 + 最大连击数比谱面总物件数要大 + + FR/PM MAX RECALL 有误 + 以 FULL RECALL / PURE MEMORY 结算的游玩结果,其最大连击数应与谱面总物件数相等 + + Note 有误 + 非 HARD LOST 结算的游玩结果,其 PURE、FAR、LOST 的和应与谱面总物件数相等 + + 真全想起来了? + FULL RECALL 时,LOST 应为 0 + + 真纯净回忆? + PURE MEMORY 时,FAR 和 LOST 应为 0 + + Modifier 与 Clear Type 不匹配 + 以 HARD CLEAR 结算的游玩成绩,其 Modifier 应为 HARD,EASY CLEAR 同理 + + + + 不稳定版本 + 应用随时可能崩溃,并且数据可能丢失甚至无法恢复。\n请注意备份! + + 这是 Arcaea Offline 的**不稳定**构建版本。\n + \n + #### 应用随时可能崩溃\n + \n + 部分功能未经充分测试,可能导致应用崩溃。\n + \n + 也请在应用请求时发送崩溃报告,帮助我们确定崩溃原因。\n + \n + *如若担心隐私泄露等问题,也可不发送崩溃报告。* + *我们尊重您的选择。*\n + \n + #### 数据存在丢失风险\n + \n + 部分数据操作未经充分测试,可能导致数据丢失乃至无法恢复。\n + \n + 因此,请确保时常备份您的数据。 + + + 对于使用不稳定版本造成的数据丢失等问题,我们将**不会提供技术支持**。请确认您已知悉相关风险。 + + 我已了解这些风险,继续。 + 退出 + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..928fbe5e --- /dev/null +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,363 @@ + + Arcaea Offline + + Copyright © 2023-2024 Arcaea Offline Android client maintainers + GNU General Public License Version 3 + + + Go back + Edit this + Delete this + + [DEV] PlAcEhOlDeR + + + OK + Done + Save + Edit + Delete + Reset + Please wait… + Import + Stop + No results + Updated at %s + Unknown error. + + Ascending + ASC + Descending + DESC + + Arcaea not installed + Import from Arcaea + + + Arcaea resource unavailable + + + // No date // + // No clear type // + // No modifier // + // No comment // + + + App Crashed. + Error Occurred. + App installation ID + Report ID + Crashed at + Send this crash report? + Comment (Optional) + What did you do when the app crashed? + Contact (Optional) + How can we reach you when we need more information? + Send + Save + Ignore + Attempted to send a crash report + Crash report sent + Failed to send crash report + Crash report saved + Failed to save crash report + Crash report ignored + + + Permission Required + + \"%s\" needs these permissions: + \"%s\" needs this permission: + + + Save image + + + KNearest model + Image hashes database + CRNN OCR model + + + Chart + Play Results + Score + PURE + FAR + LOST + MAX RECALL + Clear Type + Modifier + Comment + st3 + Potential + Constant + Play Rating + + + Waiting for OCR… + Save Image + Cache + Score saved + Return to previous app + Stay in %s + + + Overview + Database + Utilities + OCR + Settings + + + Version %d + + %d packs + %d pack + + + %d songs + %d song + + + %d difficulties + %d difficulty + + + %d chart info + %d chart info + + + %d play results + %d play result + + + Database Status + Initialize + Database not initialized + + + Manage + + Import… + Packs + Pack Localized Objects + Songs + Song Localized Objects + Difficulties + Difficulty Localized Objects + Chart Info + Play Results + packlist + songlist + From Arcaea .apk + Reading apk, this might take a while… + From installed Arcaea + Chart Info Database + + Export… + Play Results + + + Add a Play Result + Select a chart… + Then edit your play result… + Click here to select a chart. + Select a chart first. + + + Play Results List + + Are you sure to delete %d play results? + Are you sure to delete %d play result? + + Play result %s updated + + + Best30 List + + + Recent30 List + Rebuild Recent30 List + This will remove all the records in the current Recent30 list, and then re-calculate the list from scratch. Continue? + + + Deduplicator + Auto select (identical) + Only keep the one with smallest ID in an identical play results group + Auto select (properties priority) + Only keep the one with most properties in a duplicate group + Auto select (R30 priority) + Only keep the one has date property in a duplicate group + Auto merge all groups + Auto merge works fine in general situations. However, it may produce undesired results, and you cannot manually edit them during the process. + + + + %d packs imported + %d pack imported + + + %d pack localized objects imported + %d pack localized objects imported + + + %d songs imported + %d song imported + + + %d song localized objects imported + %d song localized objects imported + + + %d difficulties imported + %d difficulty imported + + + %d difficulty localized objects imported + %d difficulty localized objects imported + + + %d chart info imported + %d chart info imported + + + %d play results imported + %d play result imported + + + %d play results exported + %d play result exported + + + + Quick Search… + Select a pack… + Select a song… + + Main fields + Other fields + NULL + Comment + + Date Time Picker + Date + Time + + + Play Rating Calculator + Recommend Charts + Minimum Score + Maximum Score + Target Play Rating + Input + Results + + + OCR Queue + + Add Images + Import Folder + Adding Images + Check if the file is an image + Check if the image is an Arcaea screenshot + Queue + Parallel execution counts + Ready + Processing + Done + Done but with warnings + Error + + + %s file added to staging + %s files added to staging + + Scanning "%s" + Error occurred in staging: %s + Staging cleared + Staging + Empty + Validation + Idle + Start + Clear + + + OCR Dependencies + + + Send crash reports + General + About + Application ID + Version + Version code + License + Libraries + + OCR from Sharing + + + + %d warnings + %d warning + + + Score is zero + Score is zero. + + Score out of range + The score calculation based on the pure/far value does not match the given score. + + Notes overflow + The sum of pure, far and lost is greater than the total notes of the chart. + + MAX RECALL overflow + The max recall is greater than the total notes of the chart. + + FR/PM MAX RECALL mismatch + The max recall of a FULL RECALL / PURE MEMORY play result should be equal to the total notes of the chart. + + Notes mismatch + The sum of pure, far, lost of a non HARD LOST play result should be equal to the total notes of the chart. + + FULL RECALL? + The lost of a FULL RECALL play result should be zero. + + PURE MEMORY? + The far and lost of a PURE MEMORY play result should be zero. + + Modifier & Clear Type mismatch + A HARD CLEAR clear type should have the HARD modifier, and the same for EASY CLEAR. + + + + UNSTABLE VERSION + The app may crash at any time, and your data is under the risk of being lost or even irrecoverable.\nBe sure to backup your data! + + This is an **unstable** version of Arcaea Offline.\n + \n + #### App may crash at any time\n + \n + Some features have not been tested and may result in app crashes.\n + \n + Thus, please send crash reports to help us locate issues when the app prompted to do so.\n + \n + *If you are concerned about privacy issues, you can also choose not to send the reports.* + *We respect your choice.*\n + \n + #### Risk of data loss\n + \n + Some data operations have not been tested, and may result in data loss, even making it + irrecoverable.\n + \n + Thus, please be sure to back up your data. + + + We will **not provide any support** for data loss or any other similar issues + caused by using an unstable version. Thus, please confirm that you are aware of + the risks. + + I am aware of the risks. Continue. + Exit + diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt similarity index 97% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt index 373253f5..6da9b8b3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt @@ -38,10 +38,11 @@ import com.ionspin.kotlin.bignum.decimal.BigDecimal import com.ionspin.kotlin.bignum.decimal.RoundingMode import com.ionspin.kotlin.bignum.decimal.toBigDecimal import kotlinx.coroutines.delay -import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +// import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme + object DecimalStepperTextFieldTestTags { const val DECREASE_BUTTON = "DecimalStepperTextField_DecreaseIconButton" const val TEXT_FIELD = "DecimalStepperTextField_TextField" @@ -338,16 +339,16 @@ private fun DecimalStepperTextFieldPreview() { maxValue = 100.0, ) - ArcaeaOfflineTheme { - Surface { - Column( - Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - DecimalStepperTextField(state) +// ArcaeaOfflineTheme { + Surface { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DecimalStepperTextField(state) - Text("state.value is ${state.value}") - } + Text("state.value is ${state.value}") } } +// } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaScoreTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/arcaea/ArcaeaScoreTextField.kt similarity index 95% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaScoreTextField.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/arcaea/ArcaeaScoreTextField.kt index 0e477817..f64ac0cd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaScoreTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/arcaea/ArcaeaScoreTextField.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.components +package xyz.sevive.arcaeaoffline.ui.components.arcaea import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -11,10 +11,11 @@ import androidx.compose.material3.TextFieldLabelScope import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardType -import xyz.sevive.arcaeaoffline.R +import org.jetbrains.compose.resources.stringResource +import xyz.sevive.arcaeaoffline.resources.Res +import xyz.sevive.arcaeaoffline.resources.arcaea_play_result_score import kotlin.math.abs /** @@ -167,7 +168,7 @@ fun rememberArcaeaScoreTextFieldState( fun OutlinedArcaeaScoreTextField( state: ArcaeaScoreTextFieldState, modifier: Modifier = Modifier, - label: @Composable TextFieldLabelScope.() -> Unit = { Text(stringResource(R.string.arcaea_play_result_score)) }, + label: @Composable TextFieldLabelScope.() -> Unit = { Text(stringResource(Res.string.arcaea_play_result_score)) }, ) { OutlinedTextField( state.textFieldState, From d89ea955ddd704ed91d8b9865f02981f31f5ea1e Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 30 Jun 2026 13:57:18 +0800 Subject: [PATCH 05/21] ArcaeaOfflineTheme support --- build.gradle.kts | 2 + .../xyz/sevive/arcaeaoffline/desktop/Main.kt | 42 ++++++------ gradle/libs.versions.toml | 4 +- shared/build.gradle.kts | 21 +++++- .../arcaeaoffline/ui/theme/Theme.android.kt | 13 ++-- .../ui/components/DecimalStepperTextField.kt | 21 +++--- .../arcaeaoffline/ui/theme/ColorSchemes.kt | 1 + .../arcaeaoffline/ui/theme/ColorUtils.kt | 0 .../sevive/arcaeaoffline/ui/theme/Colors.kt | 0 .../arcaeaoffline/ui/theme/CustomColors.kt | 12 ++++ .../arcaeaoffline/ui/theme/Extensions.kt | 0 .../sevive/arcaeaoffline/ui/theme/Theme.kt | 16 +++++ .../arcaeaoffline/ui/theme/Typography.kt | 0 .../arcaeaoffline/ui/theme/Theme.jvm.kt | 67 +++++++++++++++++++ 14 files changed, 158 insertions(+), 41 deletions(-) rename app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt => shared/src/androidMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.android.kt (77%) rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt (99%) rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/theme/ColorUtils.kt (100%) rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/theme/Colors.kt (100%) rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt (95%) rename {app/src/main/java => shared/src/commonMain/kotlin}/xyz/sevive/arcaeaoffline/ui/theme/Extensions.kt (100%) create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt rename app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Type.kt => shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Typography.kt (100%) create mode 100644 shared/src/jvmMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.jvm.kt diff --git a/build.gradle.kts b/build.gradle.kts index 77e22fca..e0ac8786 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.secrets.gradle.plugin) apply false alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false + alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.jvm) diff --git a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt index 501d5a12..27480fdf 100644 --- a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt @@ -2,10 +2,11 @@ package xyz.sevive.arcaeaoffline.desktop import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -24,11 +25,16 @@ import xyz.sevive.arcaeaoffline.ui.components.DecimalStepperTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaConstantStepperTextFieldState +import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme fun main() = application { Window(onCloseRequest = ::exitApplication, title = "Arcaea Offline (Proof-of-Concept Prototype)") { - CalculatorScreen() + ArcaeaOfflineTheme { + Surface(Modifier.fillMaxSize()) { + CalculatorScreen() + } + } } } @@ -41,24 +47,22 @@ fun CalculatorScreen() { val constant by remember { derivedStateOf { ((constantTextFieldState.doubleValue ?: 0.0) * 10).toInt() } } val result = calculatePlayRating(score, constant) - MaterialTheme { - Column( - Modifier.padding(16.dp).width(400.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedArcaeaScoreTextField(scoreTextFieldState) + Column( + Modifier.padding(16.dp).width(400.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedArcaeaScoreTextField(scoreTextFieldState) - DecimalStepperTextField( - constantTextFieldState, - label = { Text(stringResource(Res.string.arcaea_constant)) }, - ) + DecimalStepperTextField( + constantTextFieldState, + label = { Text(stringResource(Res.string.arcaea_constant)) }, + ) - OutlinedTextField( - value = "%.4f".format(result), - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(Res.string.arcaea_play_rating)) }, - ) - } + OutlinedTextField( + value = "%.4f".format(result), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.arcaea_play_rating)) }, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4875df9..2fc9f41b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,7 +60,7 @@ compose-material-icons-extended = { module = "org.jetbrains.compose.material:mat opencv = { module = "org.opencv:opencv", version.ref = "opencv" } apache-commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "apache-commons-compress" } - +jSystemThemeDetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version = "3.9.1" } io-sentry-sentryAndroid = { module = "io.sentry:sentry-android", version.ref = "io-sentry-sentryAndroid" } onnxruntime-android = { module = "com.microsoft.onnxruntime:onnxruntime-android", version = "1.26.0" } @@ -88,6 +88,8 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin" } + kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index fea9f2e2..166983cd 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,5 +1,7 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { -// alias(libs.plugins.android.application) + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrains.compose) @@ -14,9 +16,20 @@ compose { } kotlin { -// androidTarget() jvm() + android { + namespace = "xyz.sevive.arcaeaoffline.shared" + compileSdk = 37 + minSdk = 24 + + withJava() + + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + sourceSets { commonMain.dependencies { implementation(libs.compose.runtime) @@ -31,7 +44,9 @@ kotlin { implementation(libs.bignum) } - jvmMain.dependencies { } + jvmMain.dependencies { + implementation(libs.jSystemThemeDetector) + } commonTest.dependencies { implementation(kotlin("test")) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt b/shared/src/androidMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.android.kt similarity index 77% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt rename to shared/src/androidMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.android.kt index 64330cd9..eb1664e7 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt +++ b/shared/src/androidMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.android.kt @@ -1,22 +1,21 @@ package xyz.sevive.arcaeaoffline.ui.theme import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext -val LocalExtendedColorScheme = staticCompositionLocalOf { ExtendedColorScheme() } +@Composable +actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme() @Composable -fun ArcaeaOfflineTheme( - darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit, +actual fun ArcaeaOfflineTheme( + darkTheme: Boolean, + dynamicColor: Boolean, + content: @Composable (() -> Unit), ) { val colorScheme = when { diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt index 6da9b8b3..373253f5 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt @@ -38,11 +38,10 @@ import com.ionspin.kotlin.bignum.decimal.BigDecimal import com.ionspin.kotlin.bignum.decimal.RoundingMode import com.ionspin.kotlin.bignum.decimal.toBigDecimal import kotlinx.coroutines.delay +import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -// import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme - object DecimalStepperTextFieldTestTags { const val DECREASE_BUTTON = "DecimalStepperTextField_DecreaseIconButton" const val TEXT_FIELD = "DecimalStepperTextField_TextField" @@ -339,16 +338,16 @@ private fun DecimalStepperTextFieldPreview() { maxValue = 100.0, ) -// ArcaeaOfflineTheme { - Surface { - Column( - Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - DecimalStepperTextField(state) + ArcaeaOfflineTheme { + Surface { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DecimalStepperTextField(state) - Text("state.value is ${state.value}") + Text("state.value is ${state.value}") + } } } -// } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt similarity index 99% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt index cf68619b..a401cd7e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/ColorSchemes.kt @@ -1,6 +1,7 @@ /* * Auto generated file from https://material-foundation.github.io/material-theme-builder/ */ +@file:Suppress("unused") package xyz.sevive.arcaeaoffline.ui.theme diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/ColorUtils.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/ColorUtils.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/ColorUtils.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/ColorUtils.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Colors.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Colors.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Colors.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Colors.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt similarity index 95% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt index 31275e81..6c07e0b3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/CustomColors.kt @@ -4,16 +4,28 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +@Suppress("unused") val ArcaeaPastSvgMain = Color(0xFF5CBAD3) + +@Suppress("unused") val ArcaeaPastSvgBg = Color(0xFF328AA0) +@Suppress("unused") val ArcaeaPresentSvgMain = Color(0xFFB5C76F) + +@Suppress("unused") val ArcaeaPresentSvgBg = Color(0xFF8C9B51) +@Suppress("unused") val ArcaeaFutureSvgMain = Color(0xFF913A79) + +@Suppress("unused") val ArcaeaFutureSvgBg = Color(0xFF772F63) +@Suppress("unused") val ArcaeaBeyondSvgMain = Color(0xFFBF0D25) + +@Suppress("unused") val ArcaeaBeyondSvgBg = Color(0xFFA0303F) /** diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Extensions.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Extensions.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Extensions.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Extensions.kt diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt new file mode 100644 index 00000000..e14a4ead --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.kt @@ -0,0 +1,16 @@ +package xyz.sevive.arcaeaoffline.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalExtendedColorScheme = staticCompositionLocalOf { ExtendedColorScheme() } + +@Composable +expect fun isSystemInDarkTheme(): Boolean + +@Composable +expect fun ArcaeaOfflineTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Type.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Typography.kt similarity index 100% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/theme/Type.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Typography.kt diff --git a/shared/src/jvmMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.jvm.kt b/shared/src/jvmMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.jvm.kt new file mode 100644 index 00000000..fb0bbe4f --- /dev/null +++ b/shared/src/jvmMain/kotlin/xyz/sevive/arcaeaoffline/ui/theme/Theme.jvm.kt @@ -0,0 +1,67 @@ +package xyz.sevive.arcaeaoffline.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.jthemedetecor.OsThemeDetector +import java.util.function.Consumer + +/** + * https://github.com/zacharee/MultiplatformMaterialYou/blob/de1b4f7e563ddc9df53d26235d312009ee0d8aa8/library/src/jvmMain/kotlin/dev/zwander/compose/ThemeInfo.jvm.kt#L22-L49 + * + * MIT License, Copyright (c) 2024 Zachary Wander + */ +@Composable +actual fun isSystemInDarkTheme(): Boolean { + val (osThemeDetector, isSupported) = + remember { + OsThemeDetector.getDetector() to OsThemeDetector.isSupported() + } + + var dark by remember { + mutableStateOf(isSupported && osThemeDetector.isDark) + } + + DisposableEffect(osThemeDetector, isSupported) { + val listener = + Consumer { darkMode: Boolean -> + dark = darkMode + } + + if (isSupported) { + osThemeDetector.registerListener(listener) + } + + onDispose { + if (isSupported) { + osThemeDetector.removeListener(listener) + } + } + } + + return dark +} + +@Composable +actual fun ArcaeaOfflineTheme( + darkTheme: Boolean, + dynamicColor: Boolean, + content: @Composable (() -> Unit), +) { + CompositionLocalProvider( + LocalArcaeaColors provides if (darkTheme) ArcaeaColors.Dark else ArcaeaColors.Light, + LocalArcaeaGradeGradientColors provides if (darkTheme) ArcaeaGradeGradientColors.Dark else ArcaeaGradeGradientColors.Light, + LocalExtendedColorScheme provides if (darkTheme) extendedDark else extendedLight, + ) { + MaterialTheme( + colorScheme = if (darkTheme) darkScheme else lightScheme, + typography = Typography, + content = content, + ) + } +} From ae53dec7fa4e0d4434f544ac7535ec9c95076972 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 03:18:52 +0800 Subject: [PATCH 06/21] add keyboard shortcut for DecimalStepperTextField --- gradle/libs.versions.toml | 3 + shared/build.gradle.kts | 11 +++ .../ui/components/DecimalStepperTextField.kt | 78 ++++++++++++----- .../ui/utils/AutoRepeatController.kt | 85 +++++++++++++++++++ 4 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fc9f41b..5654fc45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ desugarJdkLibs = "2.1.5" kotlin = "2.3.21" kotlinx-serialization = "1.11.0" +kotlinx-atomicfu = "0.33.0" ktoml = "0.7.1" kotlinx-datetime = "0.8.0" ksp = "2.3.9" @@ -56,6 +57,7 @@ compose-ui-graphics = { module = "org.jetbrains.compose.ui:ui-graphics", version compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-multiplatform" } compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-material-icons-extended" } opencv = { module = "org.opencv:opencv", version.ref = "opencv" } @@ -92,6 +94,7 @@ android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform. kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlinx-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 166983cd..bb10b5ea 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -5,6 +5,8 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrains.compose) + + alias(libs.plugins.kotlinx.atomicfu) } compose { @@ -24,6 +26,10 @@ kotlin { minSdk = 24 withJava() + withHostTestBuilder {}.configure {} + withDeviceTestBuilder { + sourceSetTreeName = "test" + } compilerOptions { jvmTarget = JvmTarget.JVM_17 @@ -50,6 +56,11 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) + implementation(libs.compose.ui.test) + } + + jvmTest.dependencies { + implementation(compose.desktop.currentOs) } } } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt index 373253f5..0879d83e 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt @@ -27,6 +27,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction @@ -37,10 +44,9 @@ import androidx.compose.ui.unit.dp import com.ionspin.kotlin.bignum.decimal.BigDecimal import com.ionspin.kotlin.bignum.decimal.RoundingMode import com.ionspin.kotlin.bignum.decimal.toBigDecimal -import kotlinx.coroutines.delay import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds +import xyz.sevive.arcaeaoffline.ui.utils.AutoRepeatController +import xyz.sevive.arcaeaoffline.ui.utils.rememberAutoRepeatController object DecimalStepperTextFieldTestTags { const val DECREASE_BUTTON = "DecimalStepperTextField_DecreaseIconButton" @@ -77,13 +83,11 @@ class DecimalStepperTextFieldState( get() = value?.doubleValue(exactRequired = false) fun stepUp() { - val current = value ?: BigDecimal.ZERO - commitValue(current + step) + value?.let { commitValue(it + step) } } fun stepDown() { - val current = value ?: BigDecimal.ZERO - commitValue(current - step) + value?.let { commitValue(it - step) } } /** @@ -221,31 +225,32 @@ fun rememberArcaeaConstantStepperTextFieldState(initialValue: Double) = @Composable private fun RepeatingIconButton( onClick: () -> Unit, + repeatController: AutoRepeatController, modifier: Modifier = Modifier, enabled: Boolean = true, - initialDelayMillis: Duration = 500.milliseconds, - repeatDelayMillis: Duration = 80.milliseconds, content: @Composable () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - - LaunchedEffect(isPressed, enabled) { - if (isPressed && enabled) { - // wait for a period to confirm the long click indication - delay(initialDelayMillis) - while (true) { - onClick() - delay(repeatDelayMillis) - } + val pressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(pressed, enabled) { + if (!enabled) { + repeatController.release() + return@LaunchedEffect + } + + if (pressed) { + repeatController.press(onClick) + } else { + repeatController.release() } } IconButton( - onClick = onClick, // this handles the first click interaction - modifier = modifier, + onClick = {}, // Controlled by repeatController enabled = enabled, interactionSource = interactionSource, + modifier = modifier.pointerHoverIcon(PointerIcon.Hand), content = content, ) } @@ -271,6 +276,8 @@ fun DecimalStepperTextField( } val isSteppingEnabled = enabled && !readonly + val stepUpRepeater = rememberAutoRepeatController() + val stepDownRepeater = rememberAutoRepeatController() OutlinedTextField( state.textFieldState, @@ -280,7 +287,34 @@ fun DecimalStepperTextField( // Normalize input when focus is lost .onFocusChanged { focusState -> if (focusState.isFocused) return@onFocusChanged + + stepUpRepeater.cancel() + stepDownRepeater.cancel() state.value?.let { state.commitValue(it) } + }.onPreviewKeyEvent { + if (!isSteppingEnabled) return@onPreviewKeyEvent false + + when (it.key) { + Key.DirectionUp -> { + when (it.type) { + KeyEventType.KeyDown -> stepUpRepeater.press(state::stepUp) + KeyEventType.KeyUp -> stepUpRepeater.release() + } + true + } + + Key.DirectionDown -> { + when (it.type) { + KeyEventType.KeyDown -> stepDownRepeater.press(state::stepDown) + KeyEventType.KeyUp -> stepDownRepeater.release() + } + true + } + + else -> { + false + } + } }, inputTransformation = inputTransformation, lineLimits = TextFieldLineLimits.SingleLine, @@ -302,6 +336,7 @@ fun DecimalStepperTextField( { RepeatingIconButton( onClick = { state.stepDown() }, + repeatController = stepDownRepeater, modifier = Modifier.testTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON), ) { Icon(Icons.Default.Remove, contentDescription = "Decrease") @@ -315,6 +350,7 @@ fun DecimalStepperTextField( { RepeatingIconButton( onClick = { state.stepUp() }, + repeatController = stepUpRepeater, modifier = Modifier.testTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON), ) { Icon(Icons.Default.Add, contentDescription = "Increase") diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt new file mode 100644 index 00000000..ad540ad0 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt @@ -0,0 +1,85 @@ +package xyz.sevive.arcaeaoffline.ui.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalAtomicApi::class) +@Stable +class AutoRepeatController internal constructor( + private val scope: CoroutineScope, + private val initialDelay: Duration, + private val repeatDelay: Duration, +) { + private val repeatJob = atomic(null) + + /** + * Call when a press begins. + * + * Executes [action] immediately, then repeats it after [initialDelay]. + */ + fun press(action: () -> Unit) { + // Ignore auto-repeat KeyDown or duplicated presses. + if (repeatJob.value != null) return + + action() + + repeatJob.value = + scope.launch { + delay(initialDelay) + + while (true) { + action() + delay(repeatDelay) + } + } + } + + /** + * Call when the press ends. + */ + fun release() { + repeatJob.value?.cancel() + repeatJob.value = null + } + + /** + * Cancel any active repeat. + */ + fun cancel() = release() +} + +@Composable +fun rememberAutoRepeatController( + initialDelay: Duration = 500.milliseconds, + repeatDelay: Duration = 50.milliseconds, +): AutoRepeatController { + val scope = rememberCoroutineScope() + + val controller = + remember(scope, initialDelay, repeatDelay) { + AutoRepeatController( + scope = scope, + initialDelay = initialDelay, + repeatDelay = repeatDelay, + ) + } + + DisposableEffect(controller) { + onDispose { + controller.cancel() + } + } + + return controller +} From c4a1f7f43300ac10187b584d63f53f860180da7d Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 03:35:43 +0800 Subject: [PATCH 07/21] migrate DecimalStepperTextFieldTest --- .../components/DecimalStepperTextFieldTest.kt | 286 ------------------ .../components/DecimalStepperTextFieldTest.kt | 251 +++++++++++++++ .../ui/utils/AutoRepeatControllerTest.kt | 161 ++++++++++ 3 files changed, 412 insertions(+), 286 deletions(-) delete mode 100644 app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt create mode 100644 shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt create mode 100644 shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt diff --git a/app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt b/app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt deleted file mode 100644 index 9f29bb0e..00000000 --- a/app/src/androidTest/java/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt +++ /dev/null @@ -1,286 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.TextField -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performImeAction -import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.requestFocus -import com.ionspin.kotlin.bignum.decimal.BigDecimal -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test - -class DecimalStepperTextFieldTest { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun testStepperIncrement_preservesDecimalPlaces() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = - rememberDecimalStepperTextFieldState( - initialValue = 1.5, - maxDecimalPlaces = 2, - step = 0.25, - minValue = 0.0, - maxValue = 5.0, - ) - DecimalStepperTextField(state) - } - - assertEquals("1.50", state.textFieldState.text.toString()) - - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).performClick() - assertEquals("1.75", state.textFieldState.text.toString()) - composeTestRule.onNodeWithText("1.75").assertExists() - } - - @Test - fun testStepperMaxBoundary_truncatesCorrectly() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = - rememberDecimalStepperTextFieldState( - initialValue = 4.9, - maxDecimalPlaces = 2, - step = 0.5, - minValue = 0.0, - maxValue = 5.0, - ) - DecimalStepperTextField(state) - } - - // 4.90 + 0.50 = 5.40, BUT it should be truncated to maxValue 5.00 - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).performClick() - assertEquals(BigDecimal.parseString("5.00"), state.value) - assertEquals("5.00", state.textFieldState.text.toString()) - } - - @Test - fun testStepperMinBoundary_truncatesCorrectly() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = - rememberDecimalStepperTextFieldState( - initialValue = 0.1, - maxDecimalPlaces = 2, - step = 0.5, - minValue = 0.0, - maxValue = 5.0, - ) - DecimalStepperTextField(state) - } - - // 0.10 - 0.50 = -0.40, BUT it should be truncated to minValue 0.00 - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON).performClick() - assertEquals(BigDecimal.parseString("0.00"), state.value) - assertEquals("0.00", state.textFieldState.text.toString()) - } - - @Test - fun testInvalidInput_isRejected() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = rememberDecimalStepperTextFieldState(initialValue = 1.0, maxDecimalPlaces = 2) - DecimalStepperTextField(state) - } - - val inputNode = composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - - // Try to input letters - inputNode.performTextReplacement("abc") - assertEquals("1.00", state.textFieldState.text.toString()) - - // Try to input multiple dots - inputNode.performTextReplacement("1.2.3") - assertEquals("1.00", state.textFieldState.text.toString()) - - // Try to input more decimal places than allowed - inputNode.performTextReplacement("1.234") - assertEquals("1.00", state.textFieldState.text.toString()) - } - - @Test - fun testNegativeValueInput_isSupported() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = - rememberDecimalStepperTextFieldState( - initialValue = 0.0, - minValue = -10.0, - maxValue = 10.0, - ) - DecimalStepperTextField(state) - } - - val inputNode = composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - - inputNode.performTextReplacement("-5.5") - assertEquals("-5.5", state.textFieldState.text.toString()) - - // Loss of focus should format it - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).performImeAction() - assertEquals("-5.50", state.textFieldState.text.toString()) - } - - @Test - fun testFocusLost_appendsTrailingZeros() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = - rememberDecimalStepperTextFieldState( - initialValue = 1.5, - maxDecimalPlaces = 3, - ) - - Column { - DecimalStepperTextField(state) - // A clickable item to change focus - TextField( - value = "", - onValueChange = {}, - Modifier.testTag("other_text_field"), - ) - } - } - - // Input value that should be normalized - composeTestRule - .onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - .performTextReplacement("1.5") - - // Changing focus - composeTestRule.onNodeWithTag("other_text_field").requestFocus() - - assertEquals("1.500", state.textFieldState.text.toString()) - } - - @Test - fun testLocaleComma_isReplacedWithDot() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = rememberDecimalStepperTextFieldState(initialValue = 0.0) - DecimalStepperTextField(state) - } - - // Some locale use comma for decimal separator - composeTestRule - .onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - .performTextReplacement("12,34") - - assertEquals("12.34", state.textFieldState.text.toString()) - } - - @Test - fun testDisabledState_blocksInteractions() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = rememberDecimalStepperTextFieldState(5.0, maxDecimalPlaces = 2) - DecimalStepperTextField( - state = state, - enabled = false, - ) - } - - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).assertDoesNotExist() - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON).assertDoesNotExist() - - composeTestRule - .onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - .assert(SemanticsMatcher.expectValue(SemanticsProperties.IsEditable, false)) - } - - @Test - fun testReadonlyState_blocksInteractions() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = rememberDecimalStepperTextFieldState(5.0, maxDecimalPlaces = 2) - DecimalStepperTextField( - state = state, - readonly = true, - ) - } - - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).assertDoesNotExist() - composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON).assertDoesNotExist() - - composeTestRule - .onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - .assert(SemanticsMatcher.expectValue(SemanticsProperties.IsEditable, false)) - } - - @Test - fun testRepeatingIconButton_longPressContinuousStepping() { - val step = 1.0 - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = rememberDecimalStepperTextFieldState(1.0, step = step) - DecimalStepperTextField(state) - } - - // The clock should be manually controlled - composeTestRule.mainClock.autoAdvance = false - - val increaseNode = composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON) - - // Simulate long press indication - increaseNode.performTouchInput { down(center) } - - // The first click should be immediately applied - composeTestRule.mainClock.advanceTimeByFrame() - assertEquals(BigDecimal.parseString("1.00"), state.value) - - // Advance clock to skip initialDelay (500ms), and perform some additions - composeTestRule.mainClock.advanceTimeBy(500 + 525) - - // We cannot really ensure the value since you cannot rely on device clocks... - // So we just assume the number is larger than a reasonable value - val valueAfterLongPress = state.doubleValue ?: 0.0 - assert(valueAfterLongPress >= 6.0) - - // Stop long press - increaseNode.performTouchInput { up() } - - // Then advance the clock - // The value should ideologically be the same, but since the cranky long press implementation, - // it might be slightly off 1~2 step. We just tolerate that since user should be able to adjust - // this by themselves... - composeTestRule.mainClock.advanceTimeBy(500) - val stoppedValue = state.doubleValue ?: 0.0 - assert(valueAfterLongPress <= stoppedValue && stoppedValue <= valueAfterLongPress + (step * 2)) - - // Reset clock - composeTestRule.mainClock.autoAdvance = true - } - - @Test - fun testImeActionDone_triggersFormatting() { - lateinit var state: DecimalStepperTextFieldState - composeTestRule.setContent { - state = rememberDecimalStepperTextFieldState(0.0, maxDecimalPlaces = 3) - DecimalStepperTextField(state) - } - - val inputNode = composeTestRule.onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) - - inputNode.performTextReplacement("7.5") - // The focus isn't lost yet, so the text should stay the same - assertEquals("7.5", state.textFieldState.text.toString()) - - // When the user clicks Done, the focus should be lost, - // and the text should be formatted - inputNode.performImeAction() - assertEquals("7.500", state.textFieldState.text.toString()) - } -} diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt new file mode 100644 index 00000000..0a1e973c --- /dev/null +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt @@ -0,0 +1,251 @@ +package xyz.sevive.arcaeaoffline.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.TextField +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.test.v2.runComposeUiTest +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import org.junit.Assert.assertEquals +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class DecimalStepperTextFieldTest { + @Test + fun `stepping up preserves decimal places`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = + rememberDecimalStepperTextFieldState( + initialValue = 1.5, + maxDecimalPlaces = 2, + step = 0.25, + minValue = 0.0, + maxValue = 5.0, + ) + DecimalStepperTextField(state) + } + + assertEquals("1.50", state.textFieldState.text.toString()) + + onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).performClick() + assertEquals("1.75", state.textFieldState.text.toString()) + onNodeWithText("1.75").assertExists() + } + + @Test + fun `stepping past max boundary clamps to max value`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = + rememberDecimalStepperTextFieldState( + initialValue = 4.9, + maxDecimalPlaces = 2, + step = 0.5, + minValue = 0.0, + maxValue = 5.0, + ) + DecimalStepperTextField(state) + } + + // 4.90 + 0.50 = 5.40, BUT it should be truncated to maxValue 5.00 + onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).performClick() + assertEquals(BigDecimal.parseString("5.00"), state.value) + assertEquals("5.00", state.textFieldState.text.toString()) + } + + @Test + fun `stepping past min boundary clamps to min value`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = + rememberDecimalStepperTextFieldState( + initialValue = 0.1, + maxDecimalPlaces = 2, + step = 0.5, + minValue = 0.0, + maxValue = 5.0, + ) + DecimalStepperTextField(state) + } + + // 0.10 - 0.50 = -0.40, BUT it should be truncated to minValue 0.00 + onNodeWithTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON).performClick() + assertEquals(BigDecimal.parseString("0.00"), state.value) + assertEquals("0.00", state.textFieldState.text.toString()) + } + + @Test + fun `rejects invalid inputs`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = rememberDecimalStepperTextFieldState(initialValue = 1.0, maxDecimalPlaces = 2) + DecimalStepperTextField(state) + } + + val inputNode = onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) + + // Try to input letters + inputNode.performTextReplacement("abc") + assertEquals("1.00", state.textFieldState.text.toString()) + + // Try to input multiple dots + inputNode.performTextReplacement("1.2.3") + assertEquals("1.00", state.textFieldState.text.toString()) + + // Try to input more decimal places than allowed + inputNode.performTextReplacement("1.234") + assertEquals("1.00", state.textFieldState.text.toString()) + } + + @Test + fun `allows negative input`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = + rememberDecimalStepperTextFieldState( + initialValue = 0.0, + minValue = -10.0, + maxValue = 10.0, + ) + DecimalStepperTextField(state) + } + + val inputNode = onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) + + inputNode.performTextReplacement("-5.5") + assertEquals("-5.5", state.textFieldState.text.toString()) + + // Loss of focus should format it + onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).performImeAction() + assertEquals("-5.50", state.textFieldState.text.toString()) + } + + @Test + fun `losing focus appends trailing zeros`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = + rememberDecimalStepperTextFieldState( + initialValue = 1.5, + maxDecimalPlaces = 3, + ) + + Column { + DecimalStepperTextField(state) + // A clickable item to change focus + TextField( + value = "", + onValueChange = {}, + Modifier.testTag("other_text_field"), + ) + } + } + + // Input value that should be normalized + onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).performTextReplacement("1.5") + + // Changing focus + onNodeWithTag("other_text_field").requestFocus() + + assertEquals("1.500", state.textFieldState.text.toString()) + } + + @Test + fun `replaces locale comma with dot`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = rememberDecimalStepperTextFieldState(initialValue = 0.0) + DecimalStepperTextField(state) + } + + // Some locale use comma for decimal separator + onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).performTextReplacement("12,34") + + assertEquals("12.34", state.textFieldState.text.toString()) + } + + @Test + fun `disabled state blocks interactions`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = rememberDecimalStepperTextFieldState(5.0, maxDecimalPlaces = 2) + DecimalStepperTextField( + state = state, + enabled = false, + ) + } + + onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).assertDoesNotExist() + onNodeWithTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON).assertDoesNotExist() + + onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).assert( + SemanticsMatcher.expectValue( + SemanticsProperties.IsEditable, + false, + ), + ) + } + + @Test + fun `readonly state blocks interactions`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = rememberDecimalStepperTextFieldState(5.0, maxDecimalPlaces = 2) + DecimalStepperTextField( + state = state, + readonly = true, + ) + } + + onNodeWithTag(DecimalStepperTextFieldTestTags.INCREASE_BUTTON).assertDoesNotExist() + onNodeWithTag(DecimalStepperTextFieldTestTags.DECREASE_BUTTON).assertDoesNotExist() + + onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).assert( + SemanticsMatcher.expectValue( + SemanticsProperties.IsEditable, + false, + ), + ) + } + + @Test + fun `ime done action triggers formatting`() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = rememberDecimalStepperTextFieldState(0.0, maxDecimalPlaces = 3) + DecimalStepperTextField(state) + } + + val inputNode = onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD) + + inputNode.performTextReplacement("7.5") + // The focus isn't lost yet, so the text should stay the same + assertEquals("7.5", state.textFieldState.text.toString()) + + // When the user clicks Done, the focus should be lost, + // and the text should be formatted + inputNode.performImeAction() + assertEquals("7.500", state.textFieldState.text.toString()) + } +} diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt new file mode 100644 index 00000000..5c382aa7 --- /dev/null +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt @@ -0,0 +1,161 @@ +package xyz.sevive.arcaeaoffline.ui.utils + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class AutoRepeatControllerTest { + private val initialDelay = 500.milliseconds + private val repeatDelay = 50.milliseconds + + private fun TestScope.advanceTimeThenRunCurrent(delayTime: Duration) { + advanceTimeBy(delayTime) + runCurrent() + } + + @Test + fun `press invokes action immediately`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + controller.press { counter++ } + assertEquals(1, counter, "press should invoke action immediately") + } + + @Test + fun `press starts repeating after initial delay`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + controller.press { counter++ } + // Repeat should not start since initialDelay isn't reached + advanceTimeThenRunCurrent(initialDelay - 1.milliseconds) + assertEquals(1, counter) + + // Exactly at the initialDelay, second invocation expected + advanceTimeThenRunCurrent(1.milliseconds) + assertEquals(2, counter) + + // Repeat should start now + advanceTimeThenRunCurrent(repeatDelay) + assertEquals(3, counter) + } + + @Test + fun `repeated press calls are ignored`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + controller.press { counter++ } + // Second press before the initialDelay should be ignored + controller.press { counter++ } + assertEquals(1, counter) + + // Repeat should start after the initialDelay + advanceTimeThenRunCurrent(initialDelay) + assertEquals(2, counter) + + // Additional presses during repeat should still be ignored + controller.press { counter++ } + advanceTimeThenRunCurrent(repeatDelay) + assertEquals(3, counter) + } + + @Test + fun `release stops repeating`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + controller.press { counter++ } + advanceTimeThenRunCurrent(initialDelay) + assertEquals(2, counter) // initial + first repeat + + controller.release() + // Counter should stop increasing after releasing + advanceTimeThenRunCurrent(repeatDelay * 3) + assertEquals(2, counter) + } + + @Test + fun `cancel stops repeating`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + controller.press { counter++ } + advanceTimeThenRunCurrent(initialDelay) + assertEquals(2, counter) + + controller.cancel() + advanceTimeThenRunCurrent(repeatDelay * 3) + assertEquals(2, counter) + } + + @Test + fun `release during initial delay prevents any repeat`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + controller.press { counter++ } + // Release before the initialDelay has elapsed + advanceTimeThenRunCurrent(200.milliseconds) + controller.release() + + // Forward beyond the initialDelay + advanceTimeThenRunCurrent(400.milliseconds) + assertEquals(1, counter, "cancelling during initialDelay should prevent any repeat") + } + + @Test + fun `controller can be reused after release`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + // First press-release cycle + controller.press { counter++ } + advanceTimeThenRunCurrent(initialDelay + repeatDelay) + assertTrue(counter > 1) + controller.release() + val afterFirstRelease = counter + + // Second press-release cycle + controller.press { counter++ } + assertEquals(afterFirstRelease + 1, counter, "second press should invoke action immediately") + advanceTimeThenRunCurrent(initialDelay) + assertEquals(afterFirstRelease + 2, counter) + controller.release() + } + + @Test + fun `multiple rapid press-release cycles work`() = + runTest { + var counter = 0 + val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) + + // Rapid tapping (press followed immediately by release) + repeat(5) { + controller.press { counter++ } + controller.release() + } + // Each press should invoke the action once, without repeats + assertEquals(5, counter) + // Ensure no orphaned coroutines + advanceUntilIdle() + assertEquals(5, counter) + } +} From 384987eccd041a88aa108e6c17fe48f11e6bc129 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 04:39:42 +0800 Subject: [PATCH 08/21] fix DecimalStepperTextField runCatching --- .../arcaeaoffline/ui/components/DecimalStepperTextField.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt index 0879d83e..427b7339 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt @@ -77,7 +77,12 @@ class DecimalStepperTextFieldState( } val value: BigDecimal? - get() = runCatching { textFieldState.text.toString().toBigDecimal() }.getOrNull() + get() = + try { + textFieldState.text.toString().toBigDecimal() + } catch (_: ArithmeticException) { + null + } val doubleValue get() = value?.doubleValue(exactRequired = false) From 0207fa75d546c350a2d5ac0707134c0380daf3df Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 04:57:33 +0800 Subject: [PATCH 09/21] fix android compatibility --- gradle/libs.versions.toml | 2 -- shared/build.gradle.kts | 5 +++-- .../src/androidDeviceTest/AndroidManifest.xml | 15 ++++++++++++++ .../ui/utils/AutoRepeatController.kt | 15 +++++++------- .../components/DecimalStepperTextFieldTest.kt | 20 +++++++++---------- .../ui/utils/AutoRepeatControllerTest.kt | 16 +++++++-------- 6 files changed, 44 insertions(+), 29 deletions(-) create mode 100644 shared/src/androidDeviceTest/AndroidManifest.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5654fc45..f10c89e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ desugarJdkLibs = "2.1.5" kotlin = "2.3.21" kotlinx-serialization = "1.11.0" -kotlinx-atomicfu = "0.33.0" ktoml = "0.7.1" kotlinx-datetime = "0.8.0" ksp = "2.3.9" @@ -94,7 +93,6 @@ android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform. kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -kotlinx-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bb10b5ea..6b48a717 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -5,8 +5,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrains.compose) - - alias(libs.plugins.kotlinx.atomicfu) } compose { @@ -30,6 +28,9 @@ kotlin { withDeviceTestBuilder { sourceSetTreeName = "test" } + androidResources { + enable = true + } compilerOptions { jvmTarget = JvmTarget.JVM_17 diff --git a/shared/src/androidDeviceTest/AndroidManifest.xml b/shared/src/androidDeviceTest/AndroidManifest.xml new file mode 100644 index 00000000..7b0a906f --- /dev/null +++ b/shared/src/androidDeviceTest/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt index ad540ad0..a3d9951c 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatController.kt @@ -5,11 +5,11 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -21,7 +21,7 @@ class AutoRepeatController internal constructor( private val initialDelay: Duration, private val repeatDelay: Duration, ) { - private val repeatJob = atomic(null) + private val repeatJob = AtomicReference(null) /** * Call when a press begins. @@ -30,11 +30,11 @@ class AutoRepeatController internal constructor( */ fun press(action: () -> Unit) { // Ignore auto-repeat KeyDown or duplicated presses. - if (repeatJob.value != null) return + if (repeatJob.load() != null) return action() - repeatJob.value = + repeatJob.store( scope.launch { delay(initialDelay) @@ -42,15 +42,16 @@ class AutoRepeatController internal constructor( action() delay(repeatDelay) } - } + }, + ) } /** * Call when the press ends. */ fun release() { - repeatJob.value?.cancel() - repeatJob.value = null + repeatJob.load()?.cancel() + repeatJob.store(null) } /** diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt index 0a1e973c..86a7e848 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt @@ -22,7 +22,7 @@ import kotlin.test.Test @OptIn(ExperimentalTestApi::class) class DecimalStepperTextFieldTest { @Test - fun `stepping up preserves decimal places`() = + fun stepping_up_preserves_decimal_places() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -45,7 +45,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `stepping past max boundary clamps to max value`() = + fun stepping_past_max_boundary_clamps_to_max_value() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -67,7 +67,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `stepping past min boundary clamps to min value`() = + fun stepping_past_min_boundary_clamps_to_min_value() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -89,7 +89,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `rejects invalid inputs`() = + fun rejects_invalid_inputs() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -113,7 +113,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `allows negative input`() = + fun allows_negative_input() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -137,7 +137,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `losing focus appends trailing zeros`() = + fun losing_focus_appends_trailing_zeros() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -168,7 +168,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `replaces locale comma with dot`() = + fun replaces_locale_comma_with_dot() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -183,7 +183,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `disabled state blocks interactions`() = + fun disabled_state_blocks_interactions() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -206,7 +206,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `readonly state blocks interactions`() = + fun readonly_state_blocks_interactions() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { @@ -229,7 +229,7 @@ class DecimalStepperTextFieldTest { } @Test - fun `ime done action triggers formatting`() = + fun ime_done_action_triggers_formatting() = runComposeUiTest { lateinit var state: DecimalStepperTextFieldState setContent { diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt index 5c382aa7..f07f67fb 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/utils/AutoRepeatControllerTest.kt @@ -23,7 +23,7 @@ class AutoRepeatControllerTest { } @Test - fun `press invokes action immediately`() = + fun press_invokes_action_immediately() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -33,7 +33,7 @@ class AutoRepeatControllerTest { } @Test - fun `press starts repeating after initial delay`() = + fun press_starts_repeating_after_initial_delay() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -53,7 +53,7 @@ class AutoRepeatControllerTest { } @Test - fun `repeated press calls are ignored`() = + fun repeated_press_calls_are_ignored() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -74,7 +74,7 @@ class AutoRepeatControllerTest { } @Test - fun `release stops repeating`() = + fun release_stops_repeating() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -90,7 +90,7 @@ class AutoRepeatControllerTest { } @Test - fun `cancel stops repeating`() = + fun cancel_stops_repeating() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -105,7 +105,7 @@ class AutoRepeatControllerTest { } @Test - fun `release during initial delay prevents any repeat`() = + fun release_during_initial_delay_prevents_any_repeat() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -121,7 +121,7 @@ class AutoRepeatControllerTest { } @Test - fun `controller can be reused after release`() = + fun controller_can_be_reused_after_release() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) @@ -142,7 +142,7 @@ class AutoRepeatControllerTest { } @Test - fun `multiple rapid press-release cycles work`() = + fun `multiple_rapid_press-release_cycles_work`() = runTest { var counter = 0 val controller = AutoRepeatController(backgroundScope, initialDelay, repeatDelay) From 67774f8fcfa33d7ee593516f9e1739a8902fd401 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 05:42:19 +0800 Subject: [PATCH 10/21] fix DecimalStepperTextField empty value when focus lost --- .../ui/components/DecimalStepperTextField.kt | 2 +- .../components/DecimalStepperTextFieldTest.kt | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt index 427b7339..44e31fce 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt @@ -295,7 +295,7 @@ fun DecimalStepperTextField( stepUpRepeater.cancel() stepDownRepeater.cancel() - state.value?.let { state.commitValue(it) } + state.commitValue(state.value ?: state.minValue) }.onPreviewKeyEvent { if (!isSteppingEnabled) return@onPreviewKeyEvent false diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt index 86a7e848..d6d6e80e 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.v2.runComposeUiTest @@ -167,6 +168,36 @@ class DecimalStepperTextFieldTest { assertEquals("1.500", state.textFieldState.text.toString()) } + @Test + fun defaulting_to_minimum_value_when_focus_lost_with_empty_input() = + runComposeUiTest { + lateinit var state: DecimalStepperTextFieldState + setContent { + state = + rememberDecimalStepperTextFieldState( + initialValue = 1.5, + maxDecimalPlaces = 2, + minValue = 1.0, + ) + + Column { + DecimalStepperTextField(state) + // A clickable item to change focus + TextField( + value = "", + onValueChange = {}, + Modifier.testTag("other_text_field"), + ) + } + } + + onNodeWithTag(DecimalStepperTextFieldTestTags.TEXT_FIELD).performTextClearance() + // Changing focus + onNodeWithTag("other_text_field").requestFocus() + + assertEquals("1.00", state.textFieldState.text.toString()) + } + @Test fun replaces_locale_comma_with_dot() = runComposeUiTest { From ba0a73201883e72c122f4aca15a0815b64709066 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 05:50:15 +0800 Subject: [PATCH 11/21] fix inconsistent behavior in DecimalStepperTextField --- .../ui/components/DecimalStepperTextField.kt | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt index 44e31fce..2f18f275 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextField.kt @@ -1,7 +1,8 @@ package xyz.sevive.arcaeaoffline.ui.components +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -22,9 +23,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextFieldLabelScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key @@ -34,6 +35,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction @@ -236,26 +238,44 @@ private fun RepeatingIconButton( content: @Composable () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - - LaunchedEffect(pressed, enabled) { - if (!enabled) { - repeatController.release() - return@LaunchedEffect - } - - if (pressed) { - repeatController.press(onClick) - } else { - repeatController.release() - } - } + val currentOnClick by rememberUpdatedState(onClick) IconButton( - onClick = {}, // Controlled by repeatController + onClick = {}, enabled = enabled, interactionSource = interactionSource, - modifier = modifier.pointerHoverIcon(PointerIcon.Hand), + modifier = + modifier + .pointerHoverIcon(PointerIcon.Hand) + // This complex pointerInput ensures consistent behavior on both desktop and mobile + // --- + // Use raw pointerInput + awaitEachGesture instead of the simpler + // onClick + LaunchedEffect(pressed) pattern because: + // 1. IconButton.onClick fires at different lifecycle points per platform + // (press on Desktop, release on Android), causing double-fire when + // combined with InteractionSource-based press detection. + // 2. LaunchedEffect watching collectIsPressedAsState() misses brief + // press-release cycles on Android due to coroutine scheduling latency, + // and InteractionSource is unreliable on non-Android targets: + // https://github.com/JetBrains/compose-multiplatform/issues/4087 + // 3. JetBrains recommends using pointer events over InteractionSource for + // button gesture handling: + // https://github.com/JetBrains/compose-multiplatform/issues/4801 + // Events are intentionally not consumed so IconButton's internal + // clickable still handles ripple/animation. + // --- + // deepseek-v4-pro with OpenCode, 2026-07-01. + .pointerInput(enabled) { + if (!enabled) return@pointerInput + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + repeatController.press(currentOnClick) + do { + val event = awaitPointerEvent() + } while (event.changes.any { it.pressed }) + repeatController.release() + } + }, content = content, ) } From ed8efee462f077f6a88bbd4149775cc5abb12e96 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 1 Jul 2026 06:45:02 +0800 Subject: [PATCH 12/21] move ArcaeaPartnerData and bundled data file to commonMain --- .../arcaeaoffline/helpers/DeviceOcrHelper.kt | 6 --- .../core/ocr/device/DeviceOcr.kt | 15 +----- .../xyz/sevive/arcaeaoffline/desktop/Main.kt | 13 +++++ shared/build.gradle.kts | 5 ++ .../files}/partnerModifiers.json | 0 .../arcaeaoffline/core/ArcaeaPartnerData.kt | 47 +++++++++---------- 6 files changed, 41 insertions(+), 45 deletions(-) rename {app/src/main/assets => shared/src/commonMain/composeResources/files}/partnerModifiers.json (100%) rename {core/src/main => shared/src/commonMain}/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt (56%) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index a8985152..dcd9ba76 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -22,7 +22,6 @@ import kotlinx.io.buffered import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import org.opencv.ml.KNearest -import xyz.sevive.arcaeaoffline.core.ArcaeaPartnerModifiers import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges @@ -146,16 +145,11 @@ object DeviceOcrHelper { ocrResult: DeviceOcrResult, fallbackDate: Instant? = null, overrideDate: Instant? = null, - customArcaeaPartnerModifiers: ArcaeaPartnerModifiers? = null, ): PlayResult { - val arcaeaPartnerModifiers = - customArcaeaPartnerModifiers ?: ArcaeaPartnerModifiers(context.assets) - val date = readImageDateFromExif(imageUri, overrideDate) ?: fallbackDate val imgFilename = context.getFilename(imageUri) return ocrResult.toPlayResult( - arcaeaPartnerModifiers = arcaeaPartnerModifiers, date = date, comment = if (imgFilename != null) "OCR $imgFilename" else null, ) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt index 4fdb776d..63bc5535 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt @@ -54,22 +54,11 @@ data class DeviceOcrResult( } fun DeviceOcrResult.toPlayResult( - arcaeaPartnerModifiers: ArcaeaPartnerModifiers? = null, date: Instant? = null, comment: String? = null, ): PlayResult { - val playResultModifier = - if (arcaeaPartnerModifiers != null) { - arcaeaPartnerModifiers[this.partnerId] - } else { - null - } - val clearType = - if (playResultModifier != null && this.clearStatus != null) { - clearStatusToClearType(this.clearStatus, playResultModifier) - } else { - null - } + val playResultModifier = ArcaeaPartnerModifiers[this.partnerId] + val clearType = this.clearStatus?.let { clearStatusToClearType(it, playResultModifier) } return PlayResult( id = 0, diff --git a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt index 27480fdf..b011675a 100644 --- a/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/xyz/sevive/arcaeaoffline/desktop/Main.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import org.jetbrains.compose.resources.stringResource +import xyz.sevive.arcaeaoffline.core.ArcaeaPartnerModifiers import xyz.sevive.arcaeaoffline.core.calculators.calculatePlayRating import xyz.sevive.arcaeaoffline.resources.Res import xyz.sevive.arcaeaoffline.resources.arcaea_constant @@ -64,5 +65,17 @@ fun CalculatorScreen() { readOnly = true, label = { Text(stringResource(Res.string.arcaea_play_rating)) }, ) + + Text( + "Should be EASY: " + ArcaeaPartnerModifiers["0"].toDisplayString(), + ) + + Text( + "Should be NORMAL: " + ArcaeaPartnerModifiers["1"].toDisplayString(), + ) + + Text( + "Should be HARD: " + ArcaeaPartnerModifiers["7"].toDisplayString(), + ) } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6b48a717..f0fa732e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,8 +3,11 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrains.compose) + + alias(libs.plugins.kotlin.plugin.serialization) } compose { @@ -48,6 +51,8 @@ kotlin { implementation(libs.compose.components.resources) implementation(libs.compose.ui.tooling) + implementation(libs.kotlinx.serialization) + implementation(libs.bignum) } diff --git a/app/src/main/assets/partnerModifiers.json b/shared/src/commonMain/composeResources/files/partnerModifiers.json similarity index 100% rename from app/src/main/assets/partnerModifiers.json rename to shared/src/commonMain/composeResources/files/partnerModifiers.json diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt similarity index 56% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt rename to shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt index e35086ce..9c8365eb 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/ArcaeaPartnerData.kt @@ -1,27 +1,28 @@ package xyz.sevive.arcaeaoffline.core -import android.content.res.AssetManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifierRange +import xyz.sevive.arcaeaoffline.resources.Res + +object ArcaeaPartnerModifiers { + private val PARTNER_ID_REGEX = """^\d+u?$""".toRegex() + + private val format = Json { ignoreUnknownKeys = true } -class ArcaeaPartnerModifiers( - assetManager: AssetManager? = null, -) { private var partnerModifiers: Map = mapOf() init { - if (assetManager != null) { - loadFromAssets(assetManager) - } + CoroutineScope(Dispatchers.IO).launch { loadFromRes() } } - fun loadFromAssets(assetManager: AssetManager) { - val inputStream = assetManager.open("partnerModifiers.json") - val content = inputStream.bufferedReader().use { it.readText() } - val contentParsed = parsePartnerModifiersJson(content) - updateWith(contentParsed) + suspend fun loadFromRes() { + val bundledContent = Res.readBytes("files/partnerModifiers.json").decodeToString() + updateWith(parsePartnerModifiersJson(bundledContent)) } fun updateWith(value: Map) { @@ -30,20 +31,14 @@ class ArcaeaPartnerModifiers( operator fun get(partnerId: String): ArcaeaPlayResultModifier = partnerModifiers[partnerId] ?: ArcaeaPlayResultModifier.NORMAL - companion object { - private val PARTNER_ID_REGEX = """^\d+u?$""".toRegex() - - private val format = Json { ignoreUnknownKeys = true } - - fun parsePartnerModifiersJson(jsonContent: String): Map = - format - .decodeFromString>(jsonContent) - .filter { - PARTNER_ID_REGEX.find(it.key) != null && ArcaeaPlayResultModifierRange.contains(it.value) - }.mapValues { - ArcaeaPlayResultModifier.fromInt(it.value) - } - } + fun parsePartnerModifiersJson(jsonContent: String): Map = + format + .decodeFromString>(jsonContent) + .filter { + PARTNER_ID_REGEX.find(it.key) != null && ArcaeaPlayResultModifierRange.contains(it.value) + }.mapValues { + ArcaeaPlayResultModifier.fromInt(it.value) + } } fun clearStatusToClearType( From 90a4243a82bf57c3b3baed0d5efd55d9b8eab219 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 03:39:49 +0800 Subject: [PATCH 13/21] ci --- .github/workflows/build_unstable.yml | 5 +++-- .github/workflows/check.yml | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_unstable.yml b/.github/workflows/build_unstable.yml index 1836d173..3a12c0e4 100644 --- a/.github/workflows/build_unstable.yml +++ b/.github/workflows/build_unstable.yml @@ -10,11 +10,12 @@ permissions: contents: write discussions: write +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4096M -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + jobs: build: runs-on: ubuntu-latest - env: - GRADLE_OPTS: -Xmx6g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 steps: - name: Check out repository diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3ce1c1b6..eb2e7e04 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,6 +10,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4096M -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + jobs: general: name: General Checks @@ -50,5 +53,4 @@ jobs: with: name: check-results path: | - **/build/reports/tests/ - **/build/reports/lint-results-*.html + **/build/reports/ From d857cdb72c24582c597e01b076c68efa0b9d8a82 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 03:41:18 +0800 Subject: [PATCH 14/21] configure connectedAndroidTest --- .github/workflows/connected-android-test.yml | 49 +++++++++++++------- app/build.gradle.kts | 1 + shared/build.gradle.kts | 5 +- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/.github/workflows/connected-android-test.yml b/.github/workflows/connected-android-test.yml index 25d1ed10..cf061d3b 100644 --- a/.github/workflows/connected-android-test.yml +++ b/.github/workflows/connected-android-test.yml @@ -10,12 +10,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4096M -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + jobs: test: - name: Run Connected Android Tests (unstableDebug) + name: Run Connected Android Tests runs-on: ubuntu-latest - env: - GRADLE_OPTS: -Xmx6g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 strategy: fail-fast: false matrix: @@ -42,7 +43,7 @@ jobs: # Avoid building the application when AVD is running, so Gradle can use more RAM - name: Build Application - run: ./gradlew assembleUnstableDebug assembleUnstableDebugAndroidTest --stacktrace + run: ./gradlew assemble assembleAndroidTest --stacktrace # android-emulator-runner configurations start here # See also https://github.com/ReactiveCircus/android-emulator-runner#usage--examples @@ -53,6 +54,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Run UI Tests + id: ui_tests uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 with: api-level: ${{ matrix.api-level }} @@ -61,28 +63,39 @@ jobs: emulator-options: -no-metrics -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim disable-animations: true script: | - # Disable auto-rotation and lock device to landscape - adb shell settings put system accelerometer_rotation 0 - # Lock device to portrait - adb shell settings put system user_rotation 0 + OUTPUT_ROOT="build/ci-connected-android-test-reports" + echo "reports-path=$OUTPUT_ROOT" >> "$GITHUB_OUTPUT" + + set_orientation() { + # portrait: 0, landscape: 1 + local rotation=$1 + adb shell settings put system accelerometer_rotation 0 + adb shell settings put system user_rotation "$rotation" + } - ./gradlew connectedUnstableDebugAndroidTest --stacktrace + archive_reports() { + # "portrait" or "landscape" + local stage=$1 + local target_dir="$OUTPUT_ROOT/$stage" - mkdir -p app/build/reports/portrait-tests - cp -r app/build/reports/androidTests/connected/* app/build/reports/portrait-tests/ || true + echo "=== Archiving $stage test reports ===" + mkdir -p "$target_dir/app" "$target_dir/shared" - # Lock device to landscape - adb shell settings put system user_rotation 1 + cp -r app/build/reports/androidTests/connected/* "$target_dir/app/" 2>/dev/null || true + cp -r shared/build/reports/androidTests/connected/* "$target_dir/shared/" 2>/dev/null || true + } - ./gradlew connectedUnstableDebugAndroidTest --stacktrace + set_orientation 0 + ./gradlew connectedAndroidTest --stacktrace + archive_reports "portrait" - mkdir -p app/build/reports/landscape-tests - cp -r app/build/reports/androidTests/connected/* app/build/reports/landscape-tests/ || true + set_orientation 1 + ./gradlew connectedAndroidTest --stacktrace + archive_reports "landscape" - name: Upload Test Results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} # run this step even if previous step failed with: name: test-results-api${{ matrix.api-level }}-${{ matrix.target }} - path: | - **/build/reports/ + path: ${{ steps.ui_tests.outputs.reports-path }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc376368..f4d63aea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -236,6 +236,7 @@ dependencies { androidTestImplementation(androidx.test.espresso.core) androidTestImplementation(androidx.test.espresso.contrib) androidTestImplementation(androidx.compose.ui.test.junit4) + androidTestImplementation(project(":shared")) debugImplementation(androidx.compose.ui.test.manifest) } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f0fa732e..d95bea50 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,10 +27,13 @@ kotlin { minSdk = 24 withJava() - withHostTestBuilder {}.configure {} + withHostTest {} withDeviceTestBuilder { + // Link Android device tests to the KMP 'test' source set tree + // so commonTest (including UI tests) will also run on the devices. sourceSetTreeName = "test" } + androidResources { enable = true } From c3b961e0f7b058df82cc8eabbabd3685355fad33 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 03:41:29 +0800 Subject: [PATCH 15/21] style: format code --- .../ui/screens/utilities/UtilitiesChartRecommendScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt index 9b475c59..0478cc68 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesChartRecommendScreen.kt @@ -60,8 +60,8 @@ import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface import xyz.sevive.arcaeaoffline.ui.components.DecimalStepperTextField import xyz.sevive.arcaeaoffline.ui.components.ListGroupHeader -import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.PlayRatingCalculator +import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState import xyz.sevive.arcaeaoffline.ui.components.rememberDecimalStepperTextFieldState import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters From 409b6a717bd6a0414a3c8d410cabc26a413cadda Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 03:47:40 +0800 Subject: [PATCH 16/21] disable ktlint for generated code --- .editorconfig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.editorconfig b/.editorconfig index 96170dc6..8ee55b89 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,3 +23,7 @@ ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_class-naming = disabled # Auto generated SQL lines can be very long ktlint_standard_max-line-length = disabled + +# For generated code +[**/build/generated/**/*] +ktlint = disabled From a9d19b45902cdb0d0c17385d139e58b706beac10 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 06:48:59 +0800 Subject: [PATCH 17/21] skip incompatible android host tests --- shared/build.gradle.kts | 12 +++++++++++- .../test/category/AndroidHostTestIncompatible.kt | 3 +++ .../ui/components/DecimalStepperTextFieldTest.kt | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/test/category/AndroidHostTestIncompatible.kt diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d95bea50..3e4c4c89 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -18,6 +18,14 @@ compose { } } +tasks.withType().configureEach { + if (name == "testAndroidHostTest") { + useJUnit { + excludeCategories("xyz.sevive.arcaeaoffline.test.category.AndroidHostTestIncompatible") + } + } +} + kotlin { jvm() @@ -27,7 +35,9 @@ kotlin { minSdk = 24 withJava() - withHostTest {} + withHostTest { + isIncludeAndroidResources = true + } withDeviceTestBuilder { // Link Android device tests to the KMP 'test' source set tree // so commonTest (including UI tests) will also run on the devices. diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/test/category/AndroidHostTestIncompatible.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/test/category/AndroidHostTestIncompatible.kt new file mode 100644 index 00000000..28683f30 --- /dev/null +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/test/category/AndroidHostTestIncompatible.kt @@ -0,0 +1,3 @@ +package xyz.sevive.arcaeaoffline.test.category + +interface AndroidHostTestIncompatible diff --git a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt index d6d6e80e..c2efb622 100644 --- a/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt +++ b/shared/src/commonTest/kotlin/xyz/sevive/arcaeaoffline/ui/components/DecimalStepperTextFieldTest.kt @@ -18,9 +18,12 @@ import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.v2.runComposeUiTest import com.ionspin.kotlin.bignum.decimal.BigDecimal import org.junit.Assert.assertEquals +import org.junit.experimental.categories.Category +import xyz.sevive.arcaeaoffline.test.category.AndroidHostTestIncompatible import kotlin.test.Test @OptIn(ExperimentalTestApi::class) +@Category(AndroidHostTestIncompatible::class) class DecimalStepperTextFieldTest { @Test fun stepping_up_preserves_decimal_places() = From e3cc33aeeecf1a50a1b08879809aa38acb0aa3c6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 06:49:10 +0800 Subject: [PATCH 18/21] gradle properties --- gradle.properties | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index dd4c0ef8..ada329d0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,9 +4,12 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx1024m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -XX:+UseG1GC -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g -XX:+UseG1GC +org.gradle.parallel=true +org.gradle.caching=true org.gradle.configuration-cache=true +org.gradle.vfs.watch=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects From 6c383546b3400258dd481d4cfa6794bc1b43e905 Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 06:53:35 +0800 Subject: [PATCH 19/21] ci --- .github/workflows/connected-android-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/connected-android-test.yml b/.github/workflows/connected-android-test.yml index cf061d3b..591caf22 100644 --- a/.github/workflows/connected-android-test.yml +++ b/.github/workflows/connected-android-test.yml @@ -43,7 +43,7 @@ jobs: # Avoid building the application when AVD is running, so Gradle can use more RAM - name: Build Application - run: ./gradlew assemble assembleAndroidTest --stacktrace + run: ./gradlew assembleDebugAndroidTest --stacktrace # android-emulator-runner configurations start here # See also https://github.com/ReactiveCircus/android-emulator-runner#usage--examples @@ -86,11 +86,11 @@ jobs: } set_orientation 0 - ./gradlew connectedAndroidTest --stacktrace + ./gradlew connectedDebugAndroidTest --stacktrace archive_reports "portrait" set_orientation 1 - ./gradlew connectedAndroidTest --stacktrace + ./gradlew connectedDebugAndroidTest --stacktrace archive_reports "landscape" - name: Upload Test Results From 27936e2be2ada4b7f3ff0e5c272c2354d55ef2ad Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 07:14:44 +0800 Subject: [PATCH 20/21] ci --- .github/scripts/connected-android-test.sh | 47 ++++++++++++++++++++ .github/workflows/connected-android-test.yml | 31 +------------ 2 files changed, 48 insertions(+), 30 deletions(-) create mode 100755 .github/scripts/connected-android-test.sh diff --git a/.github/scripts/connected-android-test.sh b/.github/scripts/connected-android-test.sh new file mode 100755 index 00000000..e36f8090 --- /dev/null +++ b/.github/scripts/connected-android-test.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +OUTPUT_ROOT="build/ci-connected-android-test-reports" +echo "reports-path=$OUTPUT_ROOT" >>"$GITHUB_OUTPUT" + +TEST_FAILED=0 + +set_orientation() { + # portrait: 0, landscape: 1 + local rotation=$1 + adb shell settings put system accelerometer_rotation 0 + adb shell settings put system user_rotation "$rotation" +} + +archive_reports() { + local stage=$1 + local target_dir="$OUTPUT_ROOT/$stage" + + echo "=== Archiving $stage test reports ===" + mkdir -p "$target_dir/app" "$target_dir/shared" + + cp -r app/build/reports/androidTests/connected/* "$target_dir/app/" 2>/dev/null || true + cp -r shared/build/reports/androidTests/connected/* "$target_dir/shared/" 2>/dev/null || true +} + +echo "=== Starting Portrait Tests ===" +set_orientation 0 +if ! ./gradlew connectedAndroidTest --stacktrace; then + echo "❌ Portrait tests failed!" + TEST_FAILED=1 +fi +archive_reports "portrait" + +echo "=== Starting Landscape Tests ===" +set_orientation 1 +if ! ./gradlew connectedAndroidTest --stacktrace; then + echo "❌ Landscape tests failed!" + TEST_FAILED=1 +fi +archive_reports "landscape" + +if [ $TEST_FAILED -ne 0 ]; then + echo "❌ One or more test stages failed." + exit 1 +fi + +echo "✅ All UI tests passed." diff --git a/.github/workflows/connected-android-test.yml b/.github/workflows/connected-android-test.yml index 591caf22..ebed0e47 100644 --- a/.github/workflows/connected-android-test.yml +++ b/.github/workflows/connected-android-test.yml @@ -62,36 +62,7 @@ jobs: arch: x86_64 emulator-options: -no-metrics -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim disable-animations: true - script: | - OUTPUT_ROOT="build/ci-connected-android-test-reports" - echo "reports-path=$OUTPUT_ROOT" >> "$GITHUB_OUTPUT" - - set_orientation() { - # portrait: 0, landscape: 1 - local rotation=$1 - adb shell settings put system accelerometer_rotation 0 - adb shell settings put system user_rotation "$rotation" - } - - archive_reports() { - # "portrait" or "landscape" - local stage=$1 - local target_dir="$OUTPUT_ROOT/$stage" - - echo "=== Archiving $stage test reports ===" - mkdir -p "$target_dir/app" "$target_dir/shared" - - cp -r app/build/reports/androidTests/connected/* "$target_dir/app/" 2>/dev/null || true - cp -r shared/build/reports/androidTests/connected/* "$target_dir/shared/" 2>/dev/null || true - } - - set_orientation 0 - ./gradlew connectedDebugAndroidTest --stacktrace - archive_reports "portrait" - - set_orientation 1 - ./gradlew connectedDebugAndroidTest --stacktrace - archive_reports "landscape" + script: .github/scripts/connected-android-test.sh - name: Upload Test Results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From bef9f364d4fb5f227c93eb452388ed31e506133c Mon Sep 17 00:00:00 2001 From: 283375 Date: Thu, 2 Jul 2026 07:27:23 +0800 Subject: [PATCH 21/21] ci --- .github/scripts/connected-android-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/connected-android-test.sh b/.github/scripts/connected-android-test.sh index e36f8090..85ae957a 100755 --- a/.github/scripts/connected-android-test.sh +++ b/.github/scripts/connected-android-test.sh @@ -25,7 +25,7 @@ archive_reports() { echo "=== Starting Portrait Tests ===" set_orientation 0 -if ! ./gradlew connectedAndroidTest --stacktrace; then +if ! ./gradlew connectedDebugAndroidTest --stacktrace; then echo "❌ Portrait tests failed!" TEST_FAILED=1 fi @@ -33,7 +33,7 @@ archive_reports "portrait" echo "=== Starting Landscape Tests ===" set_orientation 1 -if ! ./gradlew connectedAndroidTest --stacktrace; then +if ! ./gradlew connectedDebugAndroidTest --stacktrace; then echo "❌ Landscape tests failed!" TEST_FAILED=1 fi