diff --git a/.gitignore b/.gitignore index 3d4de11..464f5f9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.iml local.properties captures/ +hs_err_pid*.log +replay_pid*.log # Signing material and local credentials must never enter the repository. *.jks diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed1e105..0ca9aeb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(composeBom) implementation(libs.androidx.compose.ui) @@ -99,6 +100,7 @@ dependencies { androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.room.testing) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) @@ -106,6 +108,9 @@ dependencies { tasks.withType().configureEach { useJUnit() + maxParallelForks = 1 + maxHeapSize = "192m" + jvmArgs("-XX:MaxMetaspaceSize=128m", "-XX:+UseSerialGC") testLogging { events("failed", "skipped") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL @@ -124,7 +129,11 @@ tasks.register("verifyDependencyLicenses") { @Suppress("UNCHECKED_CAST") val groupPolicies = manifest.getValue("groupPolicies") as List> val reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet() - val resolvedCoordinates = listOf("debugRuntimeClasspath", "debugUnitTestRuntimeClasspath") + val resolvedCoordinates = listOf( + "debugRuntimeClasspath", + "debugUnitTestRuntimeClasspath", + "debugAndroidTestRuntimeClasspath", + ) .flatMap { configurationName -> configurations.getByName(configurationName) .resolvedConfiguration diff --git a/app/schemas/net.opcapp.flash.data.history.LingjiDatabase/1.json b/app/schemas/net.opcapp.flash.data.history.LingjiDatabase/1.json new file mode 100644 index 0000000..529e25a --- /dev/null +++ b/app/schemas/net.opcapp.flash.data.history.LingjiDatabase/1.json @@ -0,0 +1,128 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "29d4064d6ad955275a848abef712ab97", + "entities": [ + { + "tableName": "casting_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `created_at_epoch_millis` INTEGER NOT NULL, `created_at_text` TEXT NOT NULL, `method_version` TEXT NOT NULL, `content_version` TEXT NOT NULL, `coin_convention` TEXT NOT NULL, `rounds_json` TEXT NOT NULL, `line_values` TEXT NOT NULL, `primary_pattern` TEXT NOT NULL, `moving_positions` TEXT NOT NULL, `transformed_pattern` TEXT NOT NULL, `primary_hexagram_id` INTEGER NOT NULL, `transformed_hexagram_id` INTEGER NOT NULL, `question_text` TEXT, `question_saved` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtEpochMillis", + "columnName": "created_at_epoch_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtText", + "columnName": "created_at_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "methodVersion", + "columnName": "method_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentVersion", + "columnName": "content_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coinConvention", + "columnName": "coin_convention", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roundsJson", + "columnName": "rounds_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lineValues", + "columnName": "line_values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryPattern", + "columnName": "primary_pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "movingPositions", + "columnName": "moving_positions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transformedPattern", + "columnName": "transformed_pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryHexagramId", + "columnName": "primary_hexagram_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transformedHexagramId", + "columnName": "transformed_hexagram_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionText", + "columnName": "question_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "questionSaved", + "columnName": "question_saved", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_casting_sessions_created_at_epoch_millis", + "unique": false, + "columnNames": [ + "created_at_epoch_millis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_casting_sessions_created_at_epoch_millis` ON `${TABLE_NAME}` (`created_at_epoch_millis`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '29d4064d6ad955275a848abef712ab97')" + ] + } +} diff --git a/app/src/androidTest/java/net/opcapp/flash/app/MainActivityTest.kt b/app/src/androidTest/java/net/opcapp/flash/app/MainActivityTest.kt index 641cf16..aa0133d 100644 --- a/app/src/androidTest/java/net/opcapp/flash/app/MainActivityTest.kt +++ b/app/src/androidTest/java/net/opcapp/flash/app/MainActivityTest.kt @@ -1,10 +1,23 @@ package net.opcapp.flash.app import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.printToString import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.runBlocking +import net.opcapp.flash.data.di.DebugPersistenceEntryPoint +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -14,11 +27,96 @@ class MainActivityTest { @get:Rule val composeRule = createAndroidComposeRule() - @Test - fun domainFixtureCanBeVerifiedFromHome() { - composeRule.onNodeWithText("灵机").assertIsDisplayed() - composeRule.onNodeWithText("验证起卦核心").performClick() - composeRule.onNodeWithText("复 24 → 坤 2").assertIsDisplayed() - composeRule.onNodeWithText("初爻动").assertIsDisplayed() + @Before + fun resetLocalData() = runBlocking { + val dependencies = testDependencies() + dependencies.database().clearAllTables() + dependencies.settings().setHasSeenMethodIntro(true) + dependencies.settings().setAutoSaveHistory(true) + dependencies.settings().setSaveQuestionText(true) + dependencies.settings().setSaveExplanationContent(true) + dependencies.settings().setSaveActionNote(true) } + + @Test + fun completeCastingIsCalculatedAndSavedLocally() { + openHome() + composeRule.onNodeWithText("灵机").assertIsDisplayed() + composeRule.onNodeWithText("开始一问").performClick() + composeRule.onNodeWithTag("question_input").performTextInput("我该先验证哪个假设?") + composeRule.onNodeWithText("开始录入铜币").performClick() + + selectRound(characterIndices = emptySet()) + repeat(5) { selectRound(characterIndices = setOf(0)) } + + composeRule.onNodeWithText("复 24 → 坤 2").assertIsDisplayed() + composeRule.onNodeWithText("动爻:初爻").assertIsDisplayed() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("已自动保存完整记录到本机") + .fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithTag("do_not_save_this_time").performScrollTo().performClick() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("confirm_delete_record") + .fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithTag("confirm_delete_record").performScrollTo().performClick() + try { + composeRule.waitUntil(timeoutMillis = 15_000) { + composeRule.onAllNodesWithText("本次尚未保存").fetchSemanticsNodes().isNotEmpty() || + composeRule.onAllNodesWithText("删除失败,请重试").fetchSemanticsNodes().isNotEmpty() + } + } catch (error: Throwable) { + throw AssertionError(composeRule.onRoot().printToString(), error) + } + composeRule.onNodeWithText("本次尚未保存").assertIsDisplayed() + } + + @Test + fun autoSaveSettingIsRealAndReflectedOnHome() { + openHome() + composeRule.onNodeWithText("管理保存设置").performClick() + composeRule.onNodeWithTag("auto_save_history").assertIsOn().performClick() + composeRule.onNodeWithTag("auto_save_history").assertIsOff() + composeRule.onNodeWithText("返回").performClick() + composeRule.onNodeWithText("自动保存完整记录:已关闭").assertIsDisplayed() + + composeRule.onNodeWithText("管理保存设置").performClick() + composeRule.onNodeWithTag("auto_save_history").performClick() + composeRule.onNodeWithTag("auto_save_history").assertIsOn() + } + + @Test + fun methodIntroRemainsAvailableFromHome() { + openHome() + composeRule.onNodeWithText("方法说明").performClick() + composeRule.onNodeWithText("你投币,应用记录;AI 不参与起卦。").assertIsDisplayed() + composeRule.onNodeWithText("字为 2,背为 3;第一次是初爻,由下而上。").assertIsDisplayed() + composeRule.onNodeWithTag("method_intro_continue").performClick() + composeRule.onNodeWithText("开始一问").assertIsDisplayed() + } + + private fun openHome() { + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("开始一问").fetchSemanticsNodes().isNotEmpty() || + composeRule.onAllNodesWithTag("method_intro_continue").fetchSemanticsNodes().isNotEmpty() + } + if (composeRule.onAllNodesWithTag("method_intro_continue").fetchSemanticsNodes().isNotEmpty()) { + composeRule.onNodeWithTag("method_intro_continue").performClick() + } + composeRule.onNodeWithText("开始一问").assertIsDisplayed() + } + + private fun selectRound(characterIndices: Set) { + repeat(3) { index -> + val side = if (index in characterIndices) "character" else "reverse" + composeRule.onNodeWithTag("coin_${index}_$side").performClick() + } + composeRule.onNodeWithTag("confirm_round").performClick() + } + + private fun testDependencies(): DebugPersistenceEntryPoint = EntryPointAccessors.fromApplication( + composeRule.activity.applicationContext, + DebugPersistenceEntryPoint::class.java, + ) } diff --git a/app/src/androidTest/java/net/opcapp/flash/data/history/LingjiDatabaseTest.kt b/app/src/androidTest/java/net/opcapp/flash/data/history/LingjiDatabaseTest.kt new file mode 100644 index 0000000..626beb9 --- /dev/null +++ b/app/src/androidTest/java/net/opcapp/flash/data/history/LingjiDatabaseTest.kt @@ -0,0 +1,78 @@ +package net.opcapp.flash.data.history + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.opcapp.flash.data.settings.HistorySavePolicy +import net.opcapp.flash.domain.casting.CastEngine +import net.opcapp.flash.domain.casting.CastMetadata +import net.opcapp.flash.domain.casting.CastResult +import net.opcapp.flash.domain.casting.CastRound +import net.opcapp.flash.domain.casting.LineValue +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LingjiDatabaseTest { + private lateinit var database: LingjiDatabase + private lateinit var repository: RoomHistoryRepository + + @Before + fun createDatabase() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, LingjiDatabase::class.java).build() + repository = RoomHistoryRepository(database, database.castingSessionDao()) + } + + @After + fun closeDatabase() { + database.close() + } + + @Test + fun completedSessionCanBeSavedReadAndDeleted() = runBlocking { + val result = fixtureResult() + repository.saveSession( + id = "room-fixture", + result = result, + questionText = "我该先验证哪个假设?", + policy = HistorySavePolicy(), + ) + + val restored = repository.findById("room-fixture") + + assertNotNull(restored) + assertEquals(result, restored?.result) + assertEquals("我该先验证哪个假设?", restored?.questionText) + assertEquals(1, repository.observeCount().first()) + assertEquals(24, repository.observeLatest().first()?.primaryHexagramId) + + assertEquals(true, repository.deleteById("room-fixture")) + assertFalse(repository.exists("room-fixture")) + } + + private fun fixtureResult(): CastResult = CastResult.record( + computation = CastEngine.cast( + listOf( + CastRound.fromLineValue(LineValue.OLD_YANG), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + ), + ), + metadata = CastMetadata( + contentVersion = "hexagram-names-v1", + createdAt = "2026-08-08T00:00:00Z", + ), + ) +} diff --git a/app/src/debug/java/net/opcapp/flash/data/di/DebugPersistenceEntryPoint.kt b/app/src/debug/java/net/opcapp/flash/data/di/DebugPersistenceEntryPoint.kt new file mode 100644 index 0000000..a12cb1d --- /dev/null +++ b/app/src/debug/java/net/opcapp/flash/data/di/DebugPersistenceEntryPoint.kt @@ -0,0 +1,15 @@ +package net.opcapp.flash.data.di + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import net.opcapp.flash.data.history.LingjiDatabase +import net.opcapp.flash.data.settings.HistorySettingsRepository + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DebugPersistenceEntryPoint { + fun database(): LingjiDatabase + + fun settings(): HistorySettingsRepository +} diff --git a/app/src/main/java/net/opcapp/flash/app/LingjiApp.kt b/app/src/main/java/net/opcapp/flash/app/LingjiApp.kt index 6e4abef..d9457bc 100644 --- a/app/src/main/java/net/opcapp/flash/app/LingjiApp.kt +++ b/app/src/main/java/net/opcapp/flash/app/LingjiApp.kt @@ -1,32 +1,129 @@ package net.opcapp.flash.app +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import net.opcapp.flash.feature.home.DevelopmentHomeScreen -import net.opcapp.flash.feature.result.DomainVerificationScreen +import net.opcapp.flash.feature.casting.CastingScreen +import net.opcapp.flash.feature.casting.CastingViewModel +import net.opcapp.flash.feature.home.HomeScreen +import net.opcapp.flash.feature.home.HomeViewModel +import net.opcapp.flash.feature.onboarding.MethodIntroScreen +import net.opcapp.flash.feature.question.QuestionScreen +import net.opcapp.flash.feature.result.CastResultScreen +import net.opcapp.flash.feature.settings.HistorySettingsScreen +import net.opcapp.flash.feature.settings.HistorySettingsViewModel private object Destination { + const val Welcome = "welcome" const val Home = "home" - const val DomainResult = "domain-result" + const val Question = "question" + const val Casting = "casting" + const val Result = "result" + const val Settings = "settings" } @Composable -fun LingjiApp() { - val navController = rememberNavController() +fun LingjiApp( + castingViewModel: CastingViewModel, + homeViewModel: HomeViewModel, + settingsViewModel: HistorySettingsViewModel, + navController: NavHostController = rememberNavController(), +) { + val castingState = castingViewModel.uiState.collectAsStateWithLifecycle().value + val homeState = homeViewModel.uiState.collectAsStateWithLifecycle().value + val savePolicy = settingsViewModel.policy.collectAsStateWithLifecycle().value + + if (!homeState.isReady) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) {} + return + } NavHost( navController = navController, - startDestination = Destination.Home, + startDestination = if (homeState.hasSeenMethodIntro) { + Destination.Home + } else { + Destination.Welcome + }, ) { - composable(Destination.Home) { - DevelopmentHomeScreen( - onVerifyDomain = { navController.navigate(Destination.DomainResult) }, + composable(Destination.Welcome) { + MethodIntroScreen( + showBack = navController.previousBackStackEntry != null, + onContinue = { + homeViewModel.markMethodIntroSeen() + navController.navigate(Destination.Home) { + popUpTo(Destination.Welcome) { inclusive = true } + launchSingleTop = true + } + }, + onBack = { navController.popBackStack() }, ) } - composable(Destination.DomainResult) { - DomainVerificationScreen( + composable(Destination.Home) { + HomeScreen( + uiState = homeState, + onStartCasting = { + castingViewModel.startNewSession() + navController.navigate(Destination.Question) + }, + onOpenSettings = { navController.navigate(Destination.Settings) }, + onOpenMethodIntro = { navController.navigate(Destination.Welcome) }, + ) + } + composable(Destination.Question) { + QuestionScreen( + question = castingState.question, + onQuestionChanged = castingViewModel::updateQuestion, + onContinue = { navController.navigate(Destination.Casting) }, + onBack = navController::popBackStack, + ) + } + composable(Destination.Casting) { + CastingScreen( + uiState = castingState, + onSelectCoin = castingViewModel::selectCoin, + onConfirmRound = { + if (castingViewModel.confirmCurrentRound()) { + navController.navigate(Destination.Result) + } + }, + onEditRound = castingViewModel::editFrom, + onBack = navController::popBackStack, + ) + } + composable(Destination.Result) { + CastResultScreen( + uiState = castingState, + onBackHome = { navController.popBackStack(Destination.Home, inclusive = false) }, + onStartAgain = { + castingViewModel.startNewSession() + navController.navigate(Destination.Question) { + popUpTo(Destination.Home) + launchSingleTop = true + } + }, + onSave = castingViewModel::saveManually, + onDelete = castingViewModel::deleteSavedSession, + onRetry = castingViewModel::retryLastPersistenceOperation, + ) + } + composable(Destination.Settings) { + HistorySettingsScreen( + policy = savePolicy, + onAutoSaveChanged = settingsViewModel::setAutoSaveHistory, + onSaveQuestionChanged = settingsViewModel::setSaveQuestionText, + onSaveExplanationChanged = settingsViewModel::setSaveExplanationContent, + onSaveActionChanged = settingsViewModel::setSaveActionNote, onBack = navController::popBackStack, ) } diff --git a/app/src/main/java/net/opcapp/flash/app/MainActivity.kt b/app/src/main/java/net/opcapp/flash/app/MainActivity.kt index db57180..6c11619 100644 --- a/app/src/main/java/net/opcapp/flash/app/MainActivity.kt +++ b/app/src/main/java/net/opcapp/flash/app/MainActivity.kt @@ -3,16 +3,28 @@ package net.opcapp.flash.app import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint import net.opcapp.flash.core.designsystem.LingjiTheme +import net.opcapp.flash.feature.casting.CastingViewModel +import net.opcapp.flash.feature.home.HomeViewModel +import net.opcapp.flash.feature.settings.HistorySettingsViewModel @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val castingViewModel: CastingViewModel by viewModels() + private val homeViewModel: HomeViewModel by viewModels() + private val settingsViewModel: HistorySettingsViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { LingjiTheme { - LingjiApp() + LingjiApp( + castingViewModel = castingViewModel, + homeViewModel = homeViewModel, + settingsViewModel = settingsViewModel, + ) } } } diff --git a/app/src/main/java/net/opcapp/flash/core/model/HexagramNames.kt b/app/src/main/java/net/opcapp/flash/core/model/HexagramNames.kt new file mode 100644 index 0000000..6019e89 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/core/model/HexagramNames.kt @@ -0,0 +1,24 @@ +package net.opcapp.flash.core.model + +import net.opcapp.flash.domain.casting.HexagramId + +object HexagramNames { + private val names = listOf( + "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", + "小畜", "履", "泰", "否", "同人", "大有", "谦", "豫", + "随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复", + "无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒", + "遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解", + "损", "益", "夬", "姤", "萃", "升", "困", "井", + "革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅", + "巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济", + ) + + init { + check(names.size == 64) { "Hexagram name table must contain 64 names" } + } + + fun nameFor(id: HexagramId): String = names[id.value - 1] + + fun nameFor(id: Int): String = nameFor(HexagramId(id)) +} diff --git a/app/src/main/java/net/opcapp/flash/data/di/PersistenceModule.kt b/app/src/main/java/net/opcapp/flash/data/di/PersistenceModule.kt new file mode 100644 index 0000000..fc04f66 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/di/PersistenceModule.kt @@ -0,0 +1,40 @@ +package net.opcapp.flash.data.di + +import android.content.Context +import androidx.room.Room +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import net.opcapp.flash.data.history.CastingSessionDao +import net.opcapp.flash.data.history.HistoryRepository +import net.opcapp.flash.data.history.LingjiDatabase +import net.opcapp.flash.data.history.RoomHistoryRepository + +@Module +@InstallIn(SingletonComponent::class) +abstract class PersistenceBindingsModule { + @Binds + @Singleton + abstract fun bindHistoryRepository(implementation: RoomHistoryRepository): HistoryRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object PersistenceModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): LingjiDatabase = + Room.databaseBuilder( + context, + LingjiDatabase::class.java, + LingjiDatabase.NAME, + ).build() + + @Provides + fun provideCastingSessionDao(database: LingjiDatabase): CastingSessionDao = + database.castingSessionDao() +} diff --git a/app/src/main/java/net/opcapp/flash/data/history/CastingSessionDao.kt b/app/src/main/java/net/opcapp/flash/data/history/CastingSessionDao.kt new file mode 100644 index 0000000..488f1af --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/history/CastingSessionDao.kt @@ -0,0 +1,28 @@ +package net.opcapp.flash.data.history + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface CastingSessionDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(session: CastingSessionEntity): Long + + @Query("SELECT * FROM casting_sessions WHERE id = :id LIMIT 1") + suspend fun findById(id: String): CastingSessionEntity? + + @Query("SELECT EXISTS(SELECT 1 FROM casting_sessions WHERE id = :id)") + suspend fun exists(id: String): Boolean + + @Query("SELECT COUNT(*) FROM casting_sessions") + fun observeCount(): Flow + + @Query("SELECT * FROM casting_sessions ORDER BY created_at_epoch_millis DESC LIMIT 1") + fun observeLatest(): Flow + + @Query("DELETE FROM casting_sessions WHERE id = :id") + suspend fun deleteById(id: String): Int +} diff --git a/app/src/main/java/net/opcapp/flash/data/history/CastingSessionEntity.kt b/app/src/main/java/net/opcapp/flash/data/history/CastingSessionEntity.kt new file mode 100644 index 0000000..b15e142 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/history/CastingSessionEntity.kt @@ -0,0 +1,28 @@ +package net.opcapp.flash.data.history + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "casting_sessions", + indices = [Index(value = ["created_at_epoch_millis"])], +) +data class CastingSessionEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "created_at_epoch_millis") val createdAtEpochMillis: Long, + @ColumnInfo(name = "created_at_text") val createdAtText: String, + @ColumnInfo(name = "method_version") val methodVersion: String, + @ColumnInfo(name = "content_version") val contentVersion: String, + @ColumnInfo(name = "coin_convention") val coinConvention: String, + @ColumnInfo(name = "rounds_json") val roundsJson: String, + @ColumnInfo(name = "line_values") val lineValues: String, + @ColumnInfo(name = "primary_pattern") val primaryPattern: String, + @ColumnInfo(name = "moving_positions") val movingPositions: String, + @ColumnInfo(name = "transformed_pattern") val transformedPattern: String, + @ColumnInfo(name = "primary_hexagram_id") val primaryHexagramId: Int, + @ColumnInfo(name = "transformed_hexagram_id") val transformedHexagramId: Int, + @ColumnInfo(name = "question_text") val questionText: String?, + @ColumnInfo(name = "question_saved") val questionSaved: Boolean, +) diff --git a/app/src/main/java/net/opcapp/flash/data/history/HistoryRecordCodec.kt b/app/src/main/java/net/opcapp/flash/data/history/HistoryRecordCodec.kt new file mode 100644 index 0000000..19f9432 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/history/HistoryRecordCodec.kt @@ -0,0 +1,72 @@ +package net.opcapp.flash.data.history + +import java.time.Instant +import net.opcapp.flash.domain.casting.CastRecordDto +import net.opcapp.flash.domain.casting.CastResult + +internal object HistoryRecordCodec { + fun toEntity( + id: String, + result: CastResult, + questionText: String?, + saveQuestionText: Boolean, + ): CastingSessionEntity { + val dto = CastRecordDto.fromDomain(result) + return CastingSessionEntity( + id = id, + createdAtEpochMillis = Instant.parse(dto.createdAt).toEpochMilli(), + createdAtText = dto.createdAt, + methodVersion = dto.methodVersion, + contentVersion = dto.contentVersion, + coinConvention = dto.coinConvention, + roundsJson = encodeRounds(dto.roundsBottomUp), + lineValues = dto.lineValuesBottomUp.joinToString(","), + primaryPattern = dto.primaryPatternBottomUp, + movingPositions = dto.movingLinePositions.joinToString(","), + transformedPattern = dto.transformedPatternBottomUp, + primaryHexagramId = dto.primaryHexagramId, + transformedHexagramId = dto.transformedHexagramId, + questionText = questionText?.takeIf { saveQuestionText }, + questionSaved = saveQuestionText && questionText != null, + ) + } + + fun toDomain(entity: CastingSessionEntity): CastResult = + CastRecordDto( + schemaVersion = CastRecordDto.CURRENT_SCHEMA_VERSION, + methodVersion = entity.methodVersion, + coinConvention = entity.coinConvention, + roundsBottomUp = decodeRounds(entity.roundsJson), + lineValuesBottomUp = decodeIntegers(entity.lineValues), + primaryPatternBottomUp = entity.primaryPattern, + movingLinePositions = decodeIntegers(entity.movingPositions), + transformedPatternBottomUp = entity.transformedPattern, + primaryHexagramId = entity.primaryHexagramId, + transformedHexagramId = entity.transformedHexagramId, + contentVersion = entity.contentVersion, + createdAt = entity.createdAtText, + ).toDomain() + + private fun encodeRounds(rounds: List>): String = + rounds.joinToString(separator = ",", prefix = "[", postfix = "]") { round -> + round.joinToString(separator = ",", prefix = "[", postfix = "]") + } + + private fun decodeRounds(value: String): List> { + require(value.startsWith("[[") && value.endsWith("]]")) { + "Stored rounds must be a JSON array" + } + val rounds = value.removePrefix("[").removeSuffix("]") + .split("],[") + .map { encodedRound -> + encodedRound.removePrefix("[").removeSuffix("]") + .split(",") + .map(String::toInt) + } + require(encodeRounds(rounds) == value) { "Stored rounds are not canonical" } + return rounds + } + + private fun decodeIntegers(value: String): List = + if (value.isEmpty()) emptyList() else value.split(",").map(String::toInt) +} diff --git a/app/src/main/java/net/opcapp/flash/data/history/HistoryRepository.kt b/app/src/main/java/net/opcapp/flash/data/history/HistoryRepository.kt new file mode 100644 index 0000000..81a3a94 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/history/HistoryRepository.kt @@ -0,0 +1,39 @@ +package net.opcapp.flash.data.history + +import kotlinx.coroutines.flow.Flow +import net.opcapp.flash.data.settings.HistorySavePolicy +import net.opcapp.flash.domain.casting.CastResult + +data class SavedSessionSummary( + val id: String, + val createdAtEpochMillis: Long, + val primaryHexagramId: Int, + val transformedHexagramId: Int, + val movingLineCount: Int, +) + +data class SavedCastingSession( + val id: String, + val questionText: String?, + val questionSaved: Boolean, + val result: CastResult, +) + +interface HistoryRepository { + fun observeCount(): Flow + + fun observeLatest(): Flow + + suspend fun saveSession( + id: String, + result: CastResult, + questionText: String, + policy: HistorySavePolicy, + ): SavedCastingSession + + suspend fun findById(id: String): SavedCastingSession? + + suspend fun exists(id: String): Boolean + + suspend fun deleteById(id: String): Boolean +} diff --git a/app/src/main/java/net/opcapp/flash/data/history/LingjiDatabase.kt b/app/src/main/java/net/opcapp/flash/data/history/LingjiDatabase.kt new file mode 100644 index 0000000..f5f16e5 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/history/LingjiDatabase.kt @@ -0,0 +1,17 @@ +package net.opcapp.flash.data.history + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [CastingSessionEntity::class], + version = 1, + exportSchema = true, +) +abstract class LingjiDatabase : RoomDatabase() { + abstract fun castingSessionDao(): CastingSessionDao + + companion object { + const val NAME = "lingji-history.db" + } +} diff --git a/app/src/main/java/net/opcapp/flash/data/history/RoomHistoryRepository.kt b/app/src/main/java/net/opcapp/flash/data/history/RoomHistoryRepository.kt new file mode 100644 index 0000000..539efcd --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/history/RoomHistoryRepository.kt @@ -0,0 +1,65 @@ +package net.opcapp.flash.data.history + +import androidx.room.withTransaction +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.opcapp.flash.data.settings.HistorySavePolicy +import net.opcapp.flash.domain.casting.CastResult + +@Singleton +class RoomHistoryRepository @Inject constructor( + private val database: LingjiDatabase, + private val dao: CastingSessionDao, +) : HistoryRepository { + override fun observeCount(): Flow = dao.observeCount() + + override fun observeLatest(): Flow = dao.observeLatest().map { entity -> + entity?.toSummary() + } + + override suspend fun saveSession( + id: String, + result: CastResult, + questionText: String, + policy: HistorySavePolicy, + ): SavedCastingSession = database.withTransaction { + dao.insert( + HistoryRecordCodec.toEntity( + id = id, + result = result, + questionText = questionText, + saveQuestionText = policy.saveQuestionText, + ), + ) + checkNotNull(dao.findById(id)).toSavedSession() + } + + override suspend fun findById(id: String): SavedCastingSession? = + dao.findById(id)?.toSavedSession() + + override suspend fun exists(id: String): Boolean = dao.exists(id) + + override suspend fun deleteById(id: String): Boolean = database.withTransaction { + dao.deleteById(id) > 0 + } + + private fun CastingSessionEntity.toSummary() = SavedSessionSummary( + id = id, + createdAtEpochMillis = createdAtEpochMillis, + primaryHexagramId = primaryHexagramId, + transformedHexagramId = transformedHexagramId, + movingLineCount = decodeMovingLineCount(movingPositions), + ) + + private fun CastingSessionEntity.toSavedSession() = SavedCastingSession( + id = id, + questionText = questionText, + questionSaved = questionSaved, + result = HistoryRecordCodec.toDomain(this), + ) + + private fun decodeMovingLineCount(value: String): Int = + if (value.isEmpty()) 0 else value.split(",").size +} diff --git a/app/src/main/java/net/opcapp/flash/data/settings/HistorySavePolicy.kt b/app/src/main/java/net/opcapp/flash/data/settings/HistorySavePolicy.kt new file mode 100644 index 0000000..5170fe7 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/settings/HistorySavePolicy.kt @@ -0,0 +1,8 @@ +package net.opcapp.flash.data.settings + +data class HistorySavePolicy( + val autoSaveHistory: Boolean = true, + val saveQuestionText: Boolean = true, + val saveExplanationContent: Boolean = true, + val saveActionNote: Boolean = true, +) diff --git a/app/src/main/java/net/opcapp/flash/data/settings/HistorySettingsRepository.kt b/app/src/main/java/net/opcapp/flash/data/settings/HistorySettingsRepository.kt new file mode 100644 index 0000000..aab964e --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/data/settings/HistorySettingsRepository.kt @@ -0,0 +1,73 @@ +package net.opcapp.flash.data.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +private const val DATASTORE_NAME = "history-save-settings" +private val Context.historySettingsDataStore: DataStore by preferencesDataStore( + name = DATASTORE_NAME, +) + +@Singleton +class HistorySettingsRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val preferencesData: Flow = context.historySettingsDataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + + val policy: Flow = preferencesData.map { preferences -> + HistorySavePolicy( + autoSaveHistory = preferences[Keys.AutoSaveHistory] ?: true, + saveQuestionText = preferences[Keys.SaveQuestionText] ?: true, + saveExplanationContent = preferences[Keys.SaveExplanationContent] ?: true, + saveActionNote = preferences[Keys.SaveActionNote] ?: true, + ) + } + + val hasSeenMethodIntro: Flow = preferencesData.map { preferences -> + preferences[Keys.HasSeenMethodIntro] ?: false + } + + suspend fun setHasSeenMethodIntro(seen: Boolean) = set(Keys.HasSeenMethodIntro, seen) + + suspend fun setAutoSaveHistory(enabled: Boolean) = set(Keys.AutoSaveHistory, enabled) + + suspend fun setSaveQuestionText(enabled: Boolean) = set(Keys.SaveQuestionText, enabled) + + suspend fun setSaveExplanationContent(enabled: Boolean) = + set(Keys.SaveExplanationContent, enabled) + + suspend fun setSaveActionNote(enabled: Boolean) = set(Keys.SaveActionNote, enabled) + + private suspend fun set(key: Preferences.Key, enabled: Boolean) { + context.historySettingsDataStore.edit { preferences -> + preferences[key] = enabled + } + } + + private object Keys { + val HasSeenMethodIntro = booleanPreferencesKey("has_seen_method_intro") + val AutoSaveHistory = booleanPreferencesKey("auto_save_history") + val SaveQuestionText = booleanPreferencesKey("save_question_text") + val SaveExplanationContent = booleanPreferencesKey("save_explanation_content") + val SaveActionNote = booleanPreferencesKey("save_action_note") + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/casting/CastingScreen.kt b/app/src/main/java/net/opcapp/flash/feature/casting/CastingScreen.kt new file mode 100644 index 0000000..fae5d24 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/casting/CastingScreen.kt @@ -0,0 +1,325 @@ +package net.opcapp.flash.feature.casting + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.testTag +import net.opcapp.flash.R +import net.opcapp.flash.domain.casting.CastRound +import net.opcapp.flash.domain.casting.CoinSide +import net.opcapp.flash.domain.casting.LineValue + +@Composable +fun CastingScreen( + uiState: CastingUiState, + onSelectCoin: (Int, CoinSide) -> Unit, + onConfirmRound: () -> Unit, + onEditRound: (Int) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBack) + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TextButton( + onClick = onBack, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.back_to_question)) + } + Text( + text = stringResource(R.string.casting_step, uiState.currentRoundNumber), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.casting_title), + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = stringResource(R.string.coin_convention_visible), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator( + progress = uiState.confirmedRounds.size / 6f, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource( + R.string.round_progress, + uiState.confirmedRounds.size, + 6, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CoinEntryCard( + currentRoundNumber = uiState.currentRoundNumber, + selections = uiState.currentCoins, + preview = uiState.currentRound, + onSelectCoin = onSelectCoin, + onConfirmRound = onConfirmRound, + ) + if (uiState.confirmedRounds.isNotEmpty()) { + ConfirmedRoundsCard( + rounds = uiState.confirmedRounds, + onEditRound = onEditRound, + ) + } + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun CoinEntryCard( + currentRoundNumber: Int, + selections: List, + preview: CastRound?, + onSelectCoin: (Int, CoinSide) -> Unit, + onConfirmRound: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.current_line_title, linePositionName(currentRoundNumber)), + style = MaterialTheme.typography.titleLarge, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(CastRound.COINS_PER_ROUND) { index -> + CoinSelector( + index = index, + selected = selections[index], + onSelected = { side -> onSelectCoin(index, side) }, + modifier = Modifier.weight(1f), + ) + } + } + if (preview != null) { + val lineValue = preview.lineValue + Text( + text = stringResource( + R.string.current_line_preview, + lineValue.score, + lineValue.displayName(), + if (lineValue.isMoving) { + stringResource(R.string.moving_line) + } else { + stringResource(R.string.static_line) + }, + ), + style = MaterialTheme.typography.bodyLarge, + color = if (lineValue.isMoving) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } else { + Text( + text = stringResource(R.string.select_all_three_coins), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button( + onClick = onConfirmRound, + enabled = preview != null, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag("confirm_round"), + shape = RoundedCornerShape(16.dp), + ) { + Text( + if (currentRoundNumber == 6) { + stringResource(R.string.confirm_and_finish) + } else { + stringResource(R.string.confirm_this_line) + }, + ) + } + } + } +} + +@Composable +private fun CoinSelector( + index: Int, + selected: CoinSide?, + onSelected: (CoinSide) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.coin_index, index + 1), + style = MaterialTheme.typography.labelLarge, + ) + CoinChoice( + text = stringResource(R.string.coin_character), + isSelected = selected == CoinSide.CHARACTER, + onClick = { onSelected(CoinSide.CHARACTER) }, + testTag = "coin_${index}_character", + ) + CoinChoice( + text = stringResource(R.string.coin_reverse), + isSelected = selected == CoinSide.REVERSE, + onClick = { onSelected(CoinSide.REVERSE) }, + testTag = "coin_${index}_reverse", + ) + } +} + +@Composable +private fun CoinChoice( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + testTag: String, +) { + OutlinedButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .semantics { selected = isSelected } + .testTag(testTag), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + contentColor = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Text(text) + } +} + +@Composable +private fun ConfirmedRoundsCard( + rounds: List, + onEditRound: (Int) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(20.dp), + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = stringResource(R.string.confirmed_lines_title), + style = MaterialTheme.typography.titleLarge, + ) + rounds.forEachIndexed { index, round -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource( + R.string.confirmed_line_summary, + linePositionName(index + 1), + round.lineValue.score, + round.lineValue.displayName(), + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { onEditRound(index) }, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.rerecord_from_line)) + } + } + } + } + } +} + +@Composable +private fun linePositionName(position: Int): String = when (position) { + 1 -> stringResource(R.string.line_first) + 2 -> stringResource(R.string.line_second) + 3 -> stringResource(R.string.line_third) + 4 -> stringResource(R.string.line_fourth) + 5 -> stringResource(R.string.line_fifth) + 6 -> stringResource(R.string.line_top) + else -> error("Line position must be 1..6") +} + +@Composable +private fun LineValue.displayName(): String = when (this) { + LineValue.OLD_YIN -> stringResource(R.string.old_yin) + LineValue.YOUNG_YANG -> stringResource(R.string.young_yang) + LineValue.YOUNG_YIN -> stringResource(R.string.young_yin) + LineValue.OLD_YANG -> stringResource(R.string.old_yang) +} diff --git a/app/src/main/java/net/opcapp/flash/feature/casting/CastingViewModel.kt b/app/src/main/java/net/opcapp/flash/feature/casting/CastingViewModel.kt new file mode 100644 index 0000000..499ad07 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/casting/CastingViewModel.kt @@ -0,0 +1,317 @@ +package net.opcapp.flash.feature.casting + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import net.opcapp.flash.data.history.HistoryRepository +import net.opcapp.flash.data.history.SavedCastingSession +import net.opcapp.flash.data.settings.HistorySettingsRepository +import net.opcapp.flash.domain.casting.CastEngine +import net.opcapp.flash.domain.casting.CastMetadata +import net.opcapp.flash.domain.casting.CastResult +import net.opcapp.flash.domain.casting.CastRound +import net.opcapp.flash.domain.casting.CoinSide + +sealed interface HistorySaveStatus { + data object Draft : HistorySaveStatus + + data object Saving : HistorySaveStatus + + data class Saved( + val sessionId: String, + val questionSaved: Boolean, + ) : HistorySaveStatus + + data object NotSaved : HistorySaveStatus + + data object Deleting : HistorySaveStatus + + data class Failed(val operation: Operation) : HistorySaveStatus { + enum class Operation { + SAVE, + DELETE, + } + } +} + +data class CastingUiState( + val question: String = "", + val confirmedRounds: List = emptyList(), + val currentCoins: List = List(CastRound.COINS_PER_ROUND) { null }, + val result: CastResult? = null, + val sessionId: String? = null, + val saveStatus: HistorySaveStatus = HistorySaveStatus.Draft, +) { + val currentRoundNumber: Int + get() = (confirmedRounds.size + 1).coerceAtMost(6) + + val currentRound: CastRound? + get() = currentCoins.filterNotNull() + .takeIf { it.size == CastRound.COINS_PER_ROUND } + ?.let(CastRound::of) +} + +@HiltViewModel +class CastingViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val historyRepository: HistoryRepository, + private val settingsRepository: HistorySettingsRepository, +) : ViewModel() { + private val restoredRounds = decodeRounds(savedStateHandle[Keys.Rounds]) + private val restoredCreatedAt = savedStateHandle.get(Keys.CreatedAt) + private val restoredResult = restoredCreatedAt + ?.takeIf { restoredRounds.size == 6 } + ?.let { createdAt -> createResult(restoredRounds, createdAt) } + + private val _uiState = MutableStateFlow( + CastingUiState( + question = savedStateHandle[Keys.Question] ?: "", + confirmedRounds = restoredRounds, + currentCoins = decodeCurrentCoins(savedStateHandle[Keys.CurrentCoins]), + result = restoredResult, + sessionId = savedStateHandle[Keys.SessionId], + saveStatus = if (restoredResult == null) { + HistorySaveStatus.Draft + } else { + HistorySaveStatus.Saving + }, + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var persistenceJob: Job? = null + + init { + if (restoredResult != null) reconcileRestoredResult() + } + + fun startNewSession() { + persistenceJob?.cancel() + savedStateHandle.remove(Keys.CreatedAt) + savedStateHandle.remove(Keys.SessionId) + saveQuestion("") + saveRounds(emptyList()) + saveCurrentCoins(List(CastRound.COINS_PER_ROUND) { null }) + _uiState.value = CastingUiState() + } + + fun updateQuestion(question: String) { + saveQuestion(question) + _uiState.value = _uiState.value.copy(question = question) + } + + fun selectCoin(index: Int, side: CoinSide) { + require(index in 0 until CastRound.COINS_PER_ROUND) + check(_uiState.value.result == null) { "A sealed casting session cannot be edited" } + val updated = _uiState.value.currentCoins.toMutableList().apply { this[index] = side } + saveCurrentCoins(updated) + _uiState.value = _uiState.value.copy(currentCoins = updated) + } + + fun confirmCurrentRound(): Boolean { + val state = _uiState.value + check(state.result == null) { "A sealed casting session cannot be confirmed again" } + val round = state.currentRound ?: return false + val updatedRounds = state.confirmedRounds + round + saveRounds(updatedRounds) + saveCurrentCoins(List(CastRound.COINS_PER_ROUND) { null }) + + if (updatedRounds.size < 6) { + _uiState.value = state.copy( + confirmedRounds = updatedRounds, + currentCoins = List(CastRound.COINS_PER_ROUND) { null }, + ) + return false + } + + val createdAt = Instant.now().toString() + val sessionId = UUID.randomUUID().toString() + val result = createResult(updatedRounds, createdAt) + savedStateHandle[Keys.CreatedAt] = createdAt + savedStateHandle[Keys.SessionId] = sessionId + _uiState.value = state.copy( + confirmedRounds = updatedRounds, + currentCoins = List(CastRound.COINS_PER_ROUND) { null }, + result = result, + sessionId = sessionId, + saveStatus = HistorySaveStatus.Saving, + ) + persistResult(autoOnly = true) + return true + } + + fun editFrom(roundIndex: Int) { + val state = _uiState.value + check(state.result == null) { "A sealed casting session cannot be edited" } + require(roundIndex in state.confirmedRounds.indices) + val roundToEdit = state.confirmedRounds[roundIndex] + val retainedRounds = state.confirmedRounds.take(roundIndex) + saveRounds(retainedRounds) + saveCurrentCoins(roundToEdit.coins) + _uiState.value = state.copy( + confirmedRounds = retainedRounds, + currentCoins = roundToEdit.coins, + ) + } + + fun saveManually() = persistResult(autoOnly = false) + + fun retryLastPersistenceOperation() { + when ((_uiState.value.saveStatus as? HistorySaveStatus.Failed)?.operation) { + HistorySaveStatus.Failed.Operation.SAVE -> persistResult(autoOnly = false) + HistorySaveStatus.Failed.Operation.DELETE -> deleteSavedSession() + null -> Unit + } + } + + fun deleteSavedSession() { + val sessionId = _uiState.value.sessionId + ?: (_uiState.value.saveStatus as? HistorySaveStatus.Saved)?.sessionId + if (sessionId == null) { + persistenceJob?.cancel() + _uiState.value = _uiState.value.copy(saveStatus = HistorySaveStatus.NotSaved) + return + } + persistenceJob?.cancel() + _uiState.value = _uiState.value.copy(saveStatus = HistorySaveStatus.Deleting) + persistenceJob = viewModelScope.launch { + try { + historyRepository.deleteById(sessionId) + _uiState.value = _uiState.value.copy(saveStatus = HistorySaveStatus.NotSaved) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + _uiState.value = _uiState.value.copy( + saveStatus = HistorySaveStatus.Failed( + HistorySaveStatus.Failed.Operation.DELETE, + ), + ) + } + } + } + + private fun reconcileRestoredResult() { + val state = _uiState.value + val sessionId = state.sessionId ?: return + persistenceJob = viewModelScope.launch { + try { + applySaveSuccess( + historyRepository.findById(sessionId) ?: saveState(state, autoOnly = true), + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + applySaveFailure() + } + } + } + + private fun persistResult(autoOnly: Boolean) { + val state = _uiState.value + if (state.result == null || state.sessionId == null) return + persistenceJob?.cancel() + _uiState.value = state.copy(saveStatus = HistorySaveStatus.Saving) + persistenceJob = viewModelScope.launch { + try { + applySaveSuccess(saveState(state, autoOnly)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + applySaveFailure() + } + } + } + + private suspend fun saveState( + state: CastingUiState, + autoOnly: Boolean, + ): SavedCastingSession? { + val result = state.result ?: return null + val sessionId = state.sessionId ?: return null + val policy = settingsRepository.policy.first() + if (autoOnly && !policy.autoSaveHistory) return null + return historyRepository.saveSession( + id = sessionId, + result = result, + questionText = state.question.trim(), + policy = policy, + ) + } + + private fun applySaveSuccess(savedSession: SavedCastingSession?) { + _uiState.value = _uiState.value.copy( + saveStatus = if (savedSession == null) { + HistorySaveStatus.NotSaved + } else { + HistorySaveStatus.Saved( + sessionId = savedSession.id, + questionSaved = savedSession.questionSaved, + ) + }, + ) + } + + private fun applySaveFailure() { + _uiState.value = _uiState.value.copy( + saveStatus = HistorySaveStatus.Failed( + HistorySaveStatus.Failed.Operation.SAVE, + ), + ) + } + + private fun saveQuestion(question: String) { + savedStateHandle[Keys.Question] = question + } + + private fun saveRounds(rounds: List) { + savedStateHandle[Keys.Rounds] = ArrayList( + rounds.flatMap { round -> round.coins.map(CoinSide::score) }, + ) + } + + private fun saveCurrentCoins(coins: List) { + savedStateHandle[Keys.CurrentCoins] = ArrayList(coins.map { it?.score ?: 0 }) + } + + private fun createResult(rounds: List, createdAt: String): CastResult = + CastResult.record( + computation = CastEngine.cast(rounds), + metadata = CastMetadata( + contentVersion = CONTENT_VERSION, + createdAt = createdAt, + ), + ) + + private fun decodeRounds(scores: ArrayList?): List = + scores.orEmpty().chunked(CastRound.COINS_PER_ROUND).map(CastRound::fromScores) + + private fun decodeCurrentCoins(scores: ArrayList?): List { + val restored = scores.orEmpty().take(CastRound.COINS_PER_ROUND).map { score -> + if (score == 0) null else CoinSide.fromScore(score) + } + return restored + List(CastRound.COINS_PER_ROUND - restored.size) { null } + } + + private object Keys { + const val Question = "casting_question" + const val Rounds = "casting_round_scores" + const val CurrentCoins = "casting_current_coin_scores" + const val CreatedAt = "casting_created_at" + const val SessionId = "casting_session_id" + } + + companion object { + const val CONTENT_VERSION = "hexagram-names-v1" + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/home/DevelopmentHomeScreen.kt b/app/src/main/java/net/opcapp/flash/feature/home/DevelopmentHomeScreen.kt deleted file mode 100644 index 940b5ee..0000000 --- a/app/src/main/java/net/opcapp/flash/feature/home/DevelopmentHomeScreen.kt +++ /dev/null @@ -1,131 +0,0 @@ -package net.opcapp.flash.feature.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import net.opcapp.flash.R - -@Composable -fun DevelopmentHomeScreen( - onVerifyDomain: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column( - modifier = Modifier - .windowInsetsPadding(WindowInsets.safeDrawing) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.development_build), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(28.dp)) - HexagramSeal() - Spacer(Modifier.height(20.dp)) - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.home_tagline), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - Spacer(Modifier.height(32.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - shape = RoundedCornerShape(20.dp), - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(R.string.core_check_title), - style = MaterialTheme.typography.titleLarge, - ) - Text( - text = stringResource(R.string.core_check_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = onVerifyDomain, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(horizontal = 20.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringResource(R.string.verify_casting_core)) - } - } - } - Spacer(Modifier.height(32.dp)) - Text( - text = stringResource(R.string.local_data_notice), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } -} - -@Composable -private fun HexagramSeal() { - Surface( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(18.dp), - modifier = Modifier.height(72.dp), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.padding(horizontal = 22.dp), - ) { - Text( - text = stringResource(R.string.seal_character), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onPrimary, - ) - } - } -} diff --git a/app/src/main/java/net/opcapp/flash/feature/home/HomeScreen.kt b/app/src/main/java/net/opcapp/flash/feature/home/HomeScreen.kt new file mode 100644 index 0000000..6439481 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/home/HomeScreen.kt @@ -0,0 +1,216 @@ +package net.opcapp.flash.feature.home + +import android.text.format.DateFormat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.util.Date +import net.opcapp.flash.R +import net.opcapp.flash.core.model.HexagramNames + +@Composable +fun HomeScreen( + uiState: HomeUiState, + onStartCasting: () -> Unit, + onOpenSettings: () -> Unit, + onOpenMethodIntro: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.home_kicker), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(18.dp), + ) { + Text( + text = stringResource(R.string.seal_character), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp), + ) + } + Spacer(Modifier.height(20.dp)) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.displaySmall, + ) + Text( + text = stringResource(R.string.home_lede), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Spacer(Modifier.height(32.dp)) + Button( + onClick = onStartCasting, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringResource(R.string.start_casting)) + } + Spacer(Modifier.height(20.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.local_storage_title), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.local_storage_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (uiState.savePolicy.autoSaveHistory) { + stringResource(R.string.auto_save_on) + } else { + stringResource(R.string.auto_save_off) + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + TextButton( + onClick = onOpenSettings, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.open_save_settings)) + } + TextButton( + onClick = onOpenMethodIntro, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.open_method_intro)) + } + } + } + Spacer(Modifier.height(16.dp)) + HistorySummary(uiState) + } + } + } +} + +@Composable +private fun HistorySummary(uiState: HomeUiState) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.local_records_title), + style = MaterialTheme.typography.titleLarge, + ) + if (uiState.latest == null) { + Text( + text = stringResource(R.string.no_saved_records), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val latest = checkNotNull(uiState.latest) + val context = LocalContext.current + val recordedAt = DateFormat.getMediumDateFormat(context) + .format(Date(latest.createdAtEpochMillis)) + Text( + text = stringResource(R.string.saved_record_count, uiState.savedCount), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.latest_record_date, recordedAt), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (latest.movingLineCount == 0) { + stringResource( + R.string.latest_static_hexagram_summary, + HexagramNames.nameFor(latest.primaryHexagramId), + latest.primaryHexagramId, + ) + } else { + stringResource( + R.string.latest_hexagram_summary, + HexagramNames.nameFor(latest.primaryHexagramId), + latest.primaryHexagramId, + HexagramNames.nameFor(latest.transformedHexagramId), + latest.transformedHexagramId, + ) + }, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start, + ) + Text( + text = if (latest.movingLineCount == 0) { + stringResource(R.string.latest_no_moving_lines) + } else { + stringResource(R.string.latest_moving_line_count, latest.movingLineCount) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.latest_explanation_pending), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/home/HomeViewModel.kt b/app/src/main/java/net/opcapp/flash/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..1089783 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/home/HomeViewModel.kt @@ -0,0 +1,54 @@ +package net.opcapp.flash.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.opcapp.flash.data.history.HistoryRepository +import net.opcapp.flash.data.history.SavedSessionSummary +import net.opcapp.flash.data.settings.HistorySavePolicy +import net.opcapp.flash.data.settings.HistorySettingsRepository + +data class HomeUiState( + val savedCount: Int = 0, + val latest: SavedSessionSummary? = null, + val savePolicy: HistorySavePolicy = HistorySavePolicy(), + val hasSeenMethodIntro: Boolean = false, + val isReady: Boolean = false, +) + +@HiltViewModel +class HomeViewModel @Inject constructor( + historyRepository: HistoryRepository, + private val settingsRepository: HistorySettingsRepository, +) : ViewModel() { + val uiState: StateFlow = combine( + historyRepository.observeCount(), + historyRepository.observeLatest(), + settingsRepository.policy, + settingsRepository.hasSeenMethodIntro, + ) { savedCount, latest, savePolicy, hasSeenMethodIntro -> + HomeUiState( + savedCount = savedCount, + latest = latest, + savePolicy = savePolicy, + hasSeenMethodIntro = hasSeenMethodIntro, + isReady = true, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = HomeUiState(), + ) + + fun markMethodIntroSeen() { + viewModelScope.launch { + settingsRepository.setHasSeenMethodIntro(true) + } + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/onboarding/MethodIntroScreen.kt b/app/src/main/java/net/opcapp/flash/feature/onboarding/MethodIntroScreen.kt new file mode 100644 index 0000000..aef5324 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/onboarding/MethodIntroScreen.kt @@ -0,0 +1,103 @@ +package net.opcapp.flash.feature.onboarding + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import net.opcapp.flash.R + +@Composable +fun MethodIntroScreen( + showBack: Boolean, + onContinue: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = showBack, onBack = onBack) + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (showBack) { + TextButton( + onClick = onBack, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.back)) + } + } else { + Spacer(Modifier.height(12.dp)) + } + Text( + text = stringResource(R.string.method_intro_kicker), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.method_intro_title), + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = stringResource(R.string.method_intro_lede), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + MethodPoint(stringResource(R.string.method_intro_you_cast)) + MethodPoint(stringResource(R.string.method_intro_coin_rule)) + MethodPoint(stringResource(R.string.method_intro_not_decision)) + Spacer(Modifier.height(8.dp)) + Button( + onClick = onContinue, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag("method_intro_continue"), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringResource(R.string.method_intro_start)) + } + } + } + } +} + +@Composable +private fun MethodPoint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + ) +} diff --git a/app/src/main/java/net/opcapp/flash/feature/question/QuestionScreen.kt b/app/src/main/java/net/opcapp/flash/feature/question/QuestionScreen.kt new file mode 100644 index 0000000..0f6be3a --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/question/QuestionScreen.kt @@ -0,0 +1,133 @@ +package net.opcapp.flash.feature.question + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import net.opcapp.flash.R + +@Composable +fun QuestionScreen( + question: String, + onQuestionChanged: (String) -> Unit, + onContinue: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBack) + val count = QuestionValidation.characterCount(question) + val isValid = QuestionValidation.isValid(question) + val isTooLong = count > QuestionValidation.MAX_CHARACTERS + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TextButton( + onClick = onBack, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.back)) + } + Text( + text = stringResource(R.string.question_step), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.question_title), + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = stringResource(R.string.question_guidance), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = question, + onValueChange = onQuestionChanged, + modifier = Modifier + .fillMaxWidth() + .testTag("question_input"), + label = { Text(stringResource(R.string.question_label)) }, + supportingText = { + Text( + text = if (isTooLong) { + stringResource(R.string.question_too_long) + } else { + stringResource(R.string.question_privacy_helper) + }, + ) + }, + isError = isTooLong, + minLines = 4, + maxLines = 8, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + ), + ) + Text( + text = stringResource( + R.string.character_count, + count, + QuestionValidation.MAX_CHARACTERS, + ), + style = MaterialTheme.typography.bodyMedium, + color = if (isTooLong) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.align(Alignment.End), + ) + Button( + onClick = onContinue, + enabled = isValid, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringResource(R.string.start_coin_entry)) + } + } + } + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/question/QuestionValidation.kt b/app/src/main/java/net/opcapp/flash/feature/question/QuestionValidation.kt new file mode 100644 index 0000000..af3486d --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/question/QuestionValidation.kt @@ -0,0 +1,12 @@ +package net.opcapp.flash.feature.question + +object QuestionValidation { + const val MAX_CHARACTERS = 200 + + fun characterCount(text: String): Int = text.codePointCount(0, text.length) + + fun isValid(text: String): Boolean { + val count = characterCount(text.trim()) + return count in 1..MAX_CHARACTERS + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/result/CastResultScreen.kt b/app/src/main/java/net/opcapp/flash/feature/result/CastResultScreen.kt new file mode 100644 index 0000000..68a6c0c --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/result/CastResultScreen.kt @@ -0,0 +1,458 @@ +package net.opcapp.flash.feature.result + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import net.opcapp.flash.R +import net.opcapp.flash.core.model.HexagramNames +import net.opcapp.flash.domain.casting.CastResult +import net.opcapp.flash.domain.casting.LineValue +import net.opcapp.flash.domain.casting.Polarity +import net.opcapp.flash.feature.casting.CastingUiState +import net.opcapp.flash.feature.casting.HistorySaveStatus + +@Composable +fun CastResultScreen( + uiState: CastingUiState, + onBackHome: () -> Unit, + onStartAgain: () -> Unit, + onSave: () -> Unit, + onDelete: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBackHome) + val result = uiState.result + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TextButton( + onClick = onBackHome, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.back_home)) + } + if (result == null) { + Text( + text = stringResource(R.string.result_missing), + style = MaterialTheme.typography.headlineMedium, + ) + } else { + ResultContent( + result = result, + saveStatus = uiState.saveStatus, + onSave = onSave, + onDelete = onDelete, + onRetry = onRetry, + onStartAgain = onStartAgain, + ) + } + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun ResultContent( + result: CastResult, + saveStatus: HistorySaveStatus, + onSave: () -> Unit, + onDelete: () -> Unit, + onRetry: () -> Unit, + onStartAgain: () -> Unit, +) { + val primaryName = HexagramNames.nameFor(result.primaryHexagramId) + val transformedName = HexagramNames.nameFor(result.transformedHexagramId) + Text( + text = stringResource(R.string.result_kicker), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = if (result.movingLinePositions.isEmpty()) { + stringResource( + R.string.static_result_title, + primaryName, + result.primaryHexagramId.value, + ) + } else { + stringResource( + R.string.moving_result_title, + primaryName, + result.primaryHexagramId.value, + transformedName, + result.transformedHexagramId.value, + ) + }, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = movingLinesText(result.movingLinePositions), + style = MaterialTheme.typography.titleLarge, + color = if (result.movingLinePositions.isEmpty()) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + HexagramDiagram( + linesBottomUp = result.primaryPatternBottomUp.linesBottomUp, + movingPositions = result.movingLinePositions, + description = stringResource( + R.string.primary_hexagram_accessibility, + primaryName, + result.primaryHexagramId.value, + ), + ) + if (result.movingLinePositions.isNotEmpty()) { + HexagramDiagram( + linesBottomUp = result.transformedPatternBottomUp.linesBottomUp, + movingPositions = emptyList(), + description = stringResource( + R.string.transformed_hexagram_accessibility, + transformedName, + result.transformedHexagramId.value, + ), + ) + } + } + RawLinesCard(result.lineValuesBottomUp) + SaveStatusCard( + status = saveStatus, + onSave = onSave, + onDelete = onDelete, + onRetry = onRetry, + ) + Text( + text = stringResource(R.string.content_package_pending), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onStartAgain, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringResource(R.string.start_again)) + } +} + +@Composable +private fun RawLinesCard(lines: List) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.raw_lines_title), + style = MaterialTheme.typography.titleLarge, + ) + lines.forEachIndexed { index, line -> + Text( + text = stringResource( + R.string.raw_line_summary, + linePositionName(index + 1), + line.score, + lineValueName(line), + if (line.isMoving) { + stringResource(R.string.moving_line) + } else { + stringResource(R.string.static_line) + }, + ), + style = MaterialTheme.typography.bodyLarge, + color = if (line.isMoving) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } + } +} + +@Composable +private fun SaveStatusCard( + status: HistorySaveStatus, + onSave: () -> Unit, + onDelete: () -> Unit, + onRetry: () -> Unit, +) { + var confirmDelete by remember { mutableStateOf(false) } + LaunchedEffect(status) { + if (status !is HistorySaveStatus.Saved) { + confirmDelete = false + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (status is HistorySaveStatus.Saving || status is HistorySaveStatus.Deleting) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + Text( + text = saveStatusText(status), + style = MaterialTheme.typography.titleLarge, + ) + } + Text( + text = stringResource(R.string.save_status_privacy), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when (status) { + HistorySaveStatus.NotSaved -> OutlinedButton( + onClick = onSave, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + ) { + Text(stringResource(R.string.save_this_time)) + } + + is HistorySaveStatus.Saved -> if (confirmDelete) { + Text( + text = stringResource(R.string.delete_current_record_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.delete_current_record_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton( + onClick = onDelete, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag("confirm_delete_record"), + ) { + Text( + text = stringResource(R.string.delete_record), + color = MaterialTheme.colorScheme.error, + ) + } + TextButton( + onClick = { confirmDelete = false }, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.cancel)) + } + } else { + TextButton( + onClick = { confirmDelete = true }, + modifier = Modifier + .height(48.dp) + .testTag("do_not_save_this_time"), + ) { + Text( + text = stringResource(R.string.do_not_save_this_time), + color = MaterialTheme.colorScheme.error, + ) + } + } + + is HistorySaveStatus.Failed -> OutlinedButton( + onClick = onRetry, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + ) { + Text(stringResource(R.string.retry)) + } + + else -> Unit + } + } + } +} + +@Composable +private fun saveStatusText(status: HistorySaveStatus): String = when (status) { + HistorySaveStatus.Draft -> stringResource(R.string.save_status_preparing) + HistorySaveStatus.Saving -> stringResource(R.string.save_status_saving) + HistorySaveStatus.Deleting -> stringResource(R.string.save_status_deleting) + HistorySaveStatus.NotSaved -> stringResource(R.string.save_status_not_saved) + is HistorySaveStatus.Saved -> if (status.questionSaved) { + stringResource(R.string.save_status_saved_complete) + } else { + stringResource(R.string.save_status_saved_without_question) + } + + is HistorySaveStatus.Failed -> when (status.operation) { + HistorySaveStatus.Failed.Operation.SAVE -> stringResource(R.string.save_status_failed) + HistorySaveStatus.Failed.Operation.DELETE -> stringResource(R.string.delete_status_failed) + } +} + +@Composable +private fun movingLinesText(positions: List): String = + if (positions.isEmpty()) { + stringResource(R.string.no_moving_lines) + } else { + var positionNames = "" + for (position in positions) { + if (positionNames.isNotEmpty()) positionNames += "、" + positionNames += linePositionName(position) + } + stringResource( + R.string.moving_lines_summary, + positionNames, + ) + } + +@Composable +private fun linePositionName(position: Int): String = when (position) { + 1 -> stringResource(R.string.line_first) + 2 -> stringResource(R.string.line_second) + 3 -> stringResource(R.string.line_third) + 4 -> stringResource(R.string.line_fourth) + 5 -> stringResource(R.string.line_fifth) + 6 -> stringResource(R.string.line_top) + else -> error("Line position must be 1..6") +} + +@Composable +private fun lineValueName(line: LineValue): String = when (line) { + LineValue.OLD_YIN -> stringResource(R.string.old_yin) + LineValue.YOUNG_YANG -> stringResource(R.string.young_yang) + LineValue.YOUNG_YIN -> stringResource(R.string.young_yin) + LineValue.OLD_YANG -> stringResource(R.string.old_yang) +} + +@Composable +private fun HexagramDiagram( + linesBottomUp: List, + movingPositions: List, + description: String, +) { + val ink = MaterialTheme.colorScheme.onBackground + val movingInk = MaterialTheme.colorScheme.primary + val lineWidth = with(LocalDensity.current) { 58.dp.toPx() } + val gap = with(LocalDensity.current) { 12.dp.toPx() } + val strokeWidth = with(LocalDensity.current) { 5.dp.toPx() } + val markerRadius = with(LocalDensity.current) { 4.dp.toPx() } + + Canvas( + modifier = Modifier + .size(width = 100.dp, height = 136.dp) + .semantics { contentDescription = description }, + ) { + val verticalStep = size.height / 6f + linesBottomUp.forEachIndexed { index, polarity -> + val y = size.height - verticalStep * (index + 0.5f) + val left = (size.width - lineWidth) / 2f + val right = left + lineWidth + val color = if (index + 1 in movingPositions) movingInk else ink + if (polarity == Polarity.YANG) { + drawLine( + color = color, + start = Offset(left, y), + end = Offset(right, y), + strokeWidth = strokeWidth, + cap = StrokeCap.Square, + ) + } else { + val center = size.width / 2f + drawLine( + color = color, + start = Offset(left, y), + end = Offset(center - gap / 2f, y), + strokeWidth = strokeWidth, + cap = StrokeCap.Square, + ) + drawLine( + color = color, + start = Offset(center + gap / 2f, y), + end = Offset(right, y), + strokeWidth = strokeWidth, + cap = StrokeCap.Square, + ) + } + if (index + 1 in movingPositions) { + drawCircle( + color = movingInk, + radius = markerRadius, + center = Offset(right + gap, y), + ) + } + } + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/result/DomainVerificationScreen.kt b/app/src/main/java/net/opcapp/flash/feature/result/DomainVerificationScreen.kt deleted file mode 100644 index 91d65ad..0000000 --- a/app/src/main/java/net/opcapp/flash/feature/result/DomainVerificationScreen.kt +++ /dev/null @@ -1,164 +0,0 @@ -package net.opcapp.flash.feature.result - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import net.opcapp.flash.R -import net.opcapp.flash.domain.casting.CastEngine -import net.opcapp.flash.domain.casting.CastRound -import net.opcapp.flash.domain.casting.LineValue -import net.opcapp.flash.domain.casting.Polarity - -@Composable -fun DomainVerificationScreen( - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val result = remember { - CastEngine.cast( - listOf( - CastRound.fromLineValue(LineValue.OLD_YANG), - CastRound.fromLineValue(LineValue.YOUNG_YIN), - CastRound.fromLineValue(LineValue.YOUNG_YIN), - CastRound.fromLineValue(LineValue.YOUNG_YIN), - CastRound.fromLineValue(LineValue.YOUNG_YIN), - CastRound.fromLineValue(LineValue.YOUNG_YIN), - ), - ) - } - - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column( - modifier = Modifier - .windowInsetsPadding(WindowInsets.safeDrawing) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 12.dp), - ) { - TextButton( - onClick = onBack, - modifier = Modifier.height(48.dp), - ) { - Text(stringResource(R.string.back)) - } - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.core_check_passed), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource( - R.string.fixture_result, - result.primaryHexagramId.value, - result.transformedHexagramId.value, - ), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(top = 12.dp), - ) - Text( - text = stringResource(R.string.moving_line_result), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - Spacer(Modifier.height(36.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - HexagramDiagram( - linesBottomUp = result.primaryPatternBottomUp.linesBottomUp, - description = stringResource(R.string.primary_hexagram_description), - ) - HexagramDiagram( - linesBottomUp = result.transformedPatternBottomUp.linesBottomUp, - description = stringResource(R.string.transformed_hexagram_description), - ) - } - Spacer(Modifier.height(36.dp)) - Text( - text = stringResource(R.string.fixture_explanation), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun HexagramDiagram( - linesBottomUp: List, - description: String, -) { - val ink = MaterialTheme.colorScheme.onBackground - val lineWidth = with(LocalDensity.current) { 58.dp.toPx() } - val gap = with(LocalDensity.current) { 12.dp.toPx() } - val strokeWidth = with(LocalDensity.current) { 5.dp.toPx() } - - Canvas( - modifier = Modifier - .size(width = 88.dp, height = 136.dp) - .semantics { contentDescription = description }, - ) { - val verticalStep = size.height / 6f - linesBottomUp.forEachIndexed { index, polarity -> - val y = size.height - verticalStep * (index + 0.5f) - val left = (size.width - lineWidth) / 2f - val right = left + lineWidth - if (polarity == Polarity.YANG) { - drawLine( - color = ink, - start = Offset(left, y), - end = Offset(right, y), - strokeWidth = strokeWidth, - cap = StrokeCap.Square, - ) - } else { - val center = size.width / 2f - drawLine( - color = ink, - start = Offset(left, y), - end = Offset(center - gap / 2f, y), - strokeWidth = strokeWidth, - cap = StrokeCap.Square, - ) - drawLine( - color = ink, - start = Offset(center + gap / 2f, y), - end = Offset(right, y), - strokeWidth = strokeWidth, - cap = StrokeCap.Square, - ) - } - } - } -} diff --git a/app/src/main/java/net/opcapp/flash/feature/settings/HistorySettingsScreen.kt b/app/src/main/java/net/opcapp/flash/feature/settings/HistorySettingsScreen.kt new file mode 100644 index 0000000..dfa0411 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/settings/HistorySettingsScreen.kt @@ -0,0 +1,185 @@ +package net.opcapp.flash.feature.settings + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import net.opcapp.flash.R +import net.opcapp.flash.data.settings.HistorySavePolicy + +@Composable +fun HistorySettingsScreen( + policy: HistorySavePolicy, + onAutoSaveChanged: (Boolean) -> Unit, + onSaveQuestionChanged: (Boolean) -> Unit, + onSaveExplanationChanged: (Boolean) -> Unit, + onSaveActionChanged: (Boolean) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBack) + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TextButton( + onClick = onBack, + modifier = Modifier.height(48.dp), + ) { + Text(stringResource(R.string.back)) + } + Text( + text = stringResource(R.string.save_settings_title), + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = stringResource(R.string.save_settings_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(20.dp), + ) { + Column { + SettingsToggle( + title = stringResource(R.string.auto_save_complete_record), + description = stringResource(R.string.auto_save_complete_record_description), + checked = policy.autoSaveHistory, + testTag = "auto_save_history", + onCheckedChange = onAutoSaveChanged, + ) + SettingsToggle( + title = stringResource(R.string.save_question_text), + description = stringResource(R.string.save_question_text_description), + checked = policy.saveQuestionText, + enabled = policy.autoSaveHistory, + onCheckedChange = onSaveQuestionChanged, + ) + SettingsToggle( + title = stringResource(R.string.save_explanation_content), + description = stringResource(R.string.save_explanation_content_description), + checked = policy.saveExplanationContent, + enabled = policy.autoSaveHistory, + onCheckedChange = onSaveExplanationChanged, + ) + SettingsToggle( + title = stringResource(R.string.save_action_note), + description = stringResource(R.string.save_action_note_description), + checked = policy.saveActionNote, + enabled = policy.autoSaveHistory, + onCheckedChange = onSaveActionChanged, + ) + } + } + if (!policy.autoSaveHistory) { + Text( + text = stringResource(R.string.disabled_settings_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(R.string.no_cloud_backup_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SettingsToggle( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + testTag: String? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (testTag == null) Modifier else Modifier.testTag(testTag)) + .then( + if (enabled) { + Modifier.toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + } else { + Modifier.semantics { disabled() } + }, + ) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } +} diff --git a/app/src/main/java/net/opcapp/flash/feature/settings/HistorySettingsViewModel.kt b/app/src/main/java/net/opcapp/flash/feature/settings/HistorySettingsViewModel.kt new file mode 100644 index 0000000..9324ee8 --- /dev/null +++ b/app/src/main/java/net/opcapp/flash/feature/settings/HistorySettingsViewModel.kt @@ -0,0 +1,43 @@ +package net.opcapp.flash.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.opcapp.flash.data.settings.HistorySavePolicy +import net.opcapp.flash.data.settings.HistorySettingsRepository + +@HiltViewModel +class HistorySettingsViewModel @Inject constructor( + private val repository: HistorySettingsRepository, +) : ViewModel() { + val policy: StateFlow = repository.policy.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = HistorySavePolicy(), + ) + + fun setAutoSaveHistory(enabled: Boolean) = update { + repository.setAutoSaveHistory(enabled) + } + + fun setSaveQuestionText(enabled: Boolean) = update { + repository.setSaveQuestionText(enabled) + } + + fun setSaveExplanationContent(enabled: Boolean) = update { + repository.setSaveExplanationContent(enabled) + } + + fun setSaveActionNote(enabled: Boolean) = update { + repository.setSaveActionNote(enabled) + } + + private fun update(block: suspend () -> Unit) { + viewModelScope.launch { block() } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d353134..5ba9712 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,18 +1,113 @@ 灵机 - 内部构建 · 开发验证页 - 本机起卦 · 离线可用 + 三枚铜币 · 六爻由下而上 + 你投币,应用记录;结果用于整理想法,不替你预测或决定。 易 - 领域核心已接入 - 用固定六爻夹具验证卦象、变卦与动爻计算链路。 - 验证起卦核心 - 当前验证页不写入记录。正式功能将默认保存在本机,只有明确发起 AI 解读时才会联网。 + 开始一问 + 你的数据留在本机 + 起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。 + 自动保存完整记录:已开启 + 自动保存完整记录:已关闭 + 管理保存设置 + 方法说明 + 你的观照 + 还没有已完成的记录。未完成的问题和投币不会进入长期历史。 + 已保存 %1$d 次起卦 + 最近一次 · %1$s + %1$s %2$d → %3$s %4$d + %1$s %2$d + %1$d 个动爻 + 无动爻 + 尚未解读 + + 第一次起卦前 + 把一个犹豫,安静地放在这里。 + 应用只记录你亲手投出的铜币,并在本机算出本卦、之卦和动爻。 + 你投币,应用记录;AI 不参与起卦。 + 字为 2,背为 3;第一次是初爻,由下而上。 + 结果用于整理想法,不替你做决定。 + 开始 + 返回 - 计算通过 - 复 %1$d → 坤 %2$d - 初爻动 - 固定输入为初爻老阳、其余五爻少阴。结果来自纯 Kotlin 起卦核心,不是界面写死的演示值。 - 本卦复,第二十四卦 - 变卦坤,第二卦 + 返回首页 + 返回修改问题 + 起念 · 第一步 + 此刻,想慢下来看看什么? + 尽量写成一个具体、可由自己采取行动的问题。这里不是预测,也不替你做决定。 + 此刻想问的事 + 1–200 字。完成后按保存设置留在本机,不会因为保存而上传。 + 超过 200 字,请缩短后继续。 + %1$d / %2$d 字 + 开始录入铜币 + + 投币 · 第 %1$d 爻 + 依次录入三枚铜币 + 固定计值:字 2,背 3。请真实投掷后逐枚选择,应用不代投。 + 已确认 %1$d / %2$d 爻 + 正在录入:%1$s + 第 %1$d 枚 + 字 2 + 背 3 + 和值 %1$d · %2$s · %3$s + 选完三枚后,将在这里显示本爻的和值与动静。 + 确认本爻 + 确认上爻并成卦 + 已确认(由下而上) + %1$s · %2$d %3$s + 从此重录 + 动爻 + 静爻 + 初爻 + 二爻 + 三爻 + 四爻 + 五爻 + 上爻 + 老阴 + 少阳 + 少阴 + 老阳 + + 本机保存设置 + 这些开关只控制应用私有存储,不构成上传或 AI 授权。 + 自动保存完整记录 + 第六爻确认后立即创建本机会话。 + 保存问题原文 + 关闭后仍保存可复核卦象,但不保存你写的问题。 + 保存解读全文 + 解读功能接入后,按此选择关联到同一会话。 + 保存行动记录 + 行动记录功能接入后,按此选择保存在本机。 + 总开关关闭时,三个内容选择保留但暂不参与自动保存;重新开启后恢复。 + 历史数据库已排除系统自动备份和设备迁移;当前版本不提供账号、导出或云同步。 + + 本地计算完成 + %1$s %2$d → %3$s %4$d + %1$s %2$d + 动爻:%1$s + 无动爻,本卦不变 + 本卦 %1$s,第 %2$d 卦 + 之卦 %1$s,第 %2$d 卦 + 六爻复核(由下而上) + %1$s · %2$d %3$s · %4$s + 保存仅发生在应用私有本机数据库中,不会触发网络请求。 + 正在准备记录 + 正在保存到本机 + 正在删除本机记录 + 本次尚未保存 + 已自动保存完整记录到本机 + 卦象已保存到本机,问题原文未保存 + 保存失败,原始结果仍可查看 + 删除失败,请重试 + 保存本次 + 本次不保存 + 重试 + 当前显示可复核的卦号、卦名和六爻事实。经典原文与现代白话将在来源和授权审核完成后接入。 + 再起一卦 + 结果状态已丢失,请返回首页重新开始。 + 删除本次本机记录? + 将删除已经自动保存的问题和卦象快照,不改变全局保存设置。 + 删除记录 + 取消 diff --git a/app/src/test/java/net/opcapp/flash/core/model/HexagramNamesTest.kt b/app/src/test/java/net/opcapp/flash/core/model/HexagramNamesTest.kt new file mode 100644 index 0000000..ca10376 --- /dev/null +++ b/app/src/test/java/net/opcapp/flash/core/model/HexagramNamesTest.kt @@ -0,0 +1,14 @@ +package net.opcapp.flash.core.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HexagramNamesTest { + @Test + fun knownKingWenNamesAreStable() { + assertEquals("乾", HexagramNames.nameFor(1)) + assertEquals("坤", HexagramNames.nameFor(2)) + assertEquals("复", HexagramNames.nameFor(24)) + assertEquals("未济", HexagramNames.nameFor(64)) + } +} diff --git a/app/src/test/java/net/opcapp/flash/data/history/HistoryRecordCodecTest.kt b/app/src/test/java/net/opcapp/flash/data/history/HistoryRecordCodecTest.kt new file mode 100644 index 0000000..893c3cc --- /dev/null +++ b/app/src/test/java/net/opcapp/flash/data/history/HistoryRecordCodecTest.kt @@ -0,0 +1,85 @@ +package net.opcapp.flash.data.history + +import net.opcapp.flash.domain.casting.CastEngine +import net.opcapp.flash.domain.casting.CastMetadata +import net.opcapp.flash.domain.casting.CastResult +import net.opcapp.flash.domain.casting.CastRound +import net.opcapp.flash.domain.casting.LineValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class HistoryRecordCodecTest { + private val result = CastResult.record( + computation = CastEngine.cast( + listOf( + CastRound.fromLineValue(LineValue.OLD_YANG), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + CastRound.fromLineValue(LineValue.YOUNG_YIN), + ), + ), + metadata = CastMetadata( + contentVersion = "hexagram-names-v1", + createdAt = "2026-08-08T00:00:00Z", + ), + ) + + @Test + fun entityRoundTripRecomputesAndPreservesTheCast() { + val entity = HistoryRecordCodec.toEntity( + id = "fixture-session", + result = result, + questionText = "我该先验证哪个假设?", + saveQuestionText = true, + ) + + val restored = HistoryRecordCodec.toDomain(entity) + + assertEquals(result, restored) + assertEquals("我该先验证哪个假设?", entity.questionText) + } + + @Test + fun questionIsNotCopiedWhenItsPolicyIsOff() { + val entity = HistoryRecordCodec.toEntity( + id = "fixture-session", + result = result, + questionText = "敏感问题夹具", + saveQuestionText = false, + ) + + assertNull(entity.questionText) + assertEquals(false, entity.questionSaved) + } + + @Test + fun missingQuestionIsNeverMarkedAsSaved() { + val entity = HistoryRecordCodec.toEntity( + id = "fixture-session", + result = result, + questionText = null, + saveQuestionText = true, + ) + + assertNull(entity.questionText) + assertEquals(false, entity.questionSaved) + } + + @Test + fun tamperedDerivedValuesAreRejectedOnRead() { + val entity = HistoryRecordCodec.toEntity( + id = "fixture-session", + result = result, + questionText = null, + saveQuestionText = false, + ).copy(primaryHexagramId = 1) + + assertThrows(IllegalArgumentException::class.java) { + HistoryRecordCodec.toDomain(entity) + } + } +} diff --git a/app/src/test/java/net/opcapp/flash/feature/question/QuestionValidationTest.kt b/app/src/test/java/net/opcapp/flash/feature/question/QuestionValidationTest.kt new file mode 100644 index 0000000..43d4bb3 --- /dev/null +++ b/app/src/test/java/net/opcapp/flash/feature/question/QuestionValidationTest.kt @@ -0,0 +1,29 @@ +package net.opcapp.flash.feature.question + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuestionValidationTest { + @Test + fun blankQuestionIsRejected() { + assertFalse(QuestionValidation.isValid(" ")) + } + + @Test + fun oneToTwoHundredCharactersAreAccepted() { + assertTrue(QuestionValidation.isValid("我该先验证哪个假设?")) + assertTrue(QuestionValidation.isValid("问".repeat(200))) + } + + @Test + fun moreThanTwoHundredCharactersAreRejected() { + assertFalse(QuestionValidation.isValid("问".repeat(201))) + } + + @Test + fun supplementaryUnicodeCodePointCountsAsOneCharacter() { + assertEquals(1, QuestionValidation.characterCount("𠀀")) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 02162b6..deffadd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -61,6 +61,12 @@ val verifyDependencyLicenseManifest = registerNodeVerificationTask( script = "scripts/verify-dependency-licenses.mjs", ) +val verifyLocalDataBoundary = registerNodeVerificationTask( + name = "verifyLocalDataBoundary", + description = "Locks down offline permissions and excludes local records from backup and transfer.", + script = "scripts/verify-local-data-boundary.mjs", +) + tasks.register("verifyLocal") { group = LifecycleBasePlugin.VERIFICATION_GROUP description = "Runs every environment-independent local quality gate." @@ -76,6 +82,7 @@ tasks.register("verifyLocal") { verifyContentContract, spotlessCheck, verifyDependencyLicenseManifest, + verifyLocalDataBoundary, ) } diff --git a/config/dependency-licenses.json b/config/dependency-licenses.json index 7966779..ed00fbb 100644 --- a/config/dependency-licenses.json +++ b/config/dependency-licenses.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "reviewedAt": "2026-08-07", - "scopeNote": "Android debug runtime and debug unit-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.", + "reviewedAt": "2026-08-19", + "scopeNote": "Android debug runtime, debug unit-test runtime, and debug instrumentation-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.", "components": [ { "id": "gradle-wrapper", @@ -130,6 +130,38 @@ "scope": "test-transitive", "license": "BSD-3-Clause", "source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt" + }, + { + "id": "hamcrest-library", + "coordinates": "org.hamcrest:hamcrest-library:1.3", + "version": "1.3", + "scope": "androidTest-transitive", + "license": "BSD-3-Clause", + "source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt" + }, + { + "id": "hamcrest-integration", + "coordinates": "org.hamcrest:hamcrest-integration:1.3", + "version": "1.3", + "scope": "androidTest-transitive", + "license": "BSD-3-Clause", + "source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt" + }, + { + "id": "gson", + "coordinates": "com.google.code.gson:gson:2.9.0", + "version": "2.9.0", + "scope": "androidTest-transitive", + "license": "Apache-2.0", + "source": "https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE" + }, + { + "id": "javawriter", + "coordinates": "com.squareup:javawriter:2.1.1", + "version": "2.1.1", + "scope": "androidTest-transitive", + "license": "Apache-2.0", + "source": "https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt" } ], "groupPolicies": [ diff --git a/docs/README.md b/docs/README.md index 6f950f6..0ce4d96 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,8 @@ # Brainwave 项目文档 > 文档状态:方案基线 -> 最后核验:2026-08-07 -> 当前阶段:正式产品名“灵机”;P0 Android 壳与本地门禁已建立,真机闭环正在执行;P-1 v0.3 待品牌文案刷新和视觉复核;P1 已完成;P2 授权内容未开始 +> 最后核验:2026-08-19 +> 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 授权内容未开始;P-1 v0.3 待视觉复核;P5 未开始 本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。 @@ -90,4 +90,4 @@ - 测试命令、构建方式或发布门禁改变; - 出现可能再次发生的缺陷。 -文档链接、需求编号和验证命令将随工程骨架一起接入 CI 检查。当前尚无 Gradle 工程,不能声称任何构建或测试已经通过。 +文档链接、需求编号和验证命令由 `verifyLocal` 机械检查。授权真机存在时另跑设备仪器测试;未运行的门禁不得写成已经通过。 diff --git a/docs/architecture.md b/docs/architecture.md index 4da011c..cfbed78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,8 @@ app/src/main/java/net/opcapp/flash/ 测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。 +当前已落地的包:`app/`、`core/model`、`core/designsystem`、`domain/casting`、`data/content`(接口与测试 fake)、`data/history`、`data/settings`、`feature/onboarding`、`feature/home`、`feature/question`、`feature/casting`、`feature/result`、`feature/settings`。尚未创建 `feature/history`、`feature/explanation`、`domain/explanation` 和 `data/ai`。首页不为问卦簿或「解」生成空占位。 + ## 4. 依赖方向 允许: diff --git a/docs/data-content.md b/docs/data-content.md index 6ce4c17..57a5f05 100644 --- a/docs/data-content.md +++ b/docs/data-content.md @@ -58,7 +58,7 @@ 示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。 -机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。 +机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。应用当前只使用文王卦名查找表展示结果,不把未授权卦辞打入 APK。 ## 3. 内容完整性门禁 @@ -88,6 +88,8 @@ ## 5. Room 数据模型提案 +当前实现只有 `CastingSessionEntity` / `casting_sessions`(schema version 1)。`ExplanationEntity` 与 `ActionNoteEntity` 仍是提案,不得在 UI 中假装已经保存解读或行动。 + 历史记录不是起卦事实源,但应保存可复核的快照: ```text diff --git a/docs/dependency-licenses.md b/docs/dependency-licenses.md index b9e013a..977d99c 100644 --- a/docs/dependency-licenses.md +++ b/docs/dependency-licenses.md @@ -1,9 +1,9 @@ # 依赖与许可证清单 -> 状态:Android debug runtime 与 debug unit-test runtime 依赖图已建立自动覆盖门禁 -> 适用范围:实际解析的 Android 调试运行时、单元测试运行时,以及直接使用的构建入口 +> 状态:Android debug runtime、debug unit-test runtime 与 debug instrumentation-test runtime 依赖图已建立自动覆盖门禁 +> 适用范围:实际解析的 Android 调试运行时、单元测试/仪器测试运行时,以及直接使用的构建入口 -本清单不是上架包的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `\.\gradlew.bat verifyLocal`;发布前仍须对 release runtime 生成完整报告,复核许可证文本与随包义务。 +本清单不是上架包的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal`;发布前仍须对 release runtime 生成完整报告,复核许可证文本与随包义务。 ## 直接条目 @@ -25,6 +25,10 @@ | `javax-inject` | 1 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.pom) | | `junit4` | 4.13.2 | 单元测试 | EPL-1.0 | [JUnit license](https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt) | | `hamcrest-core` | 1.3 | 单元测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) | +| `hamcrest-library` | 1.3 | 仪器测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) | +| `hamcrest-integration` | 1.3 | 仪器测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) | +| `gson` | 2.9.0 | 仪器测试传递依赖 | Apache-2.0 | [Gson license](https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE) | +| `javawriter` | 2.1.1 | 仪器测试传递依赖 | Apache-2.0 | [JavaWriter license](https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt) | ## 传递依赖组策略 diff --git a/docs/environment.md b/docs/environment.md index 9c172f2..2f40334 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -1,7 +1,7 @@ # 本地开发环境基线 > 状态:已从当前主机实测 -> 最后核验:2026-08-08 +> 最后核验:2026-08-19 > 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证 本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。 @@ -157,7 +157,7 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh - `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。 - 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。 -已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI、正式 Android 身份和 application 壳均已建立;`assembleDebug`、13 个 debug 单元测试、`lintDebug` 与测试 APK 编译已在 JDK 17 上通过。2026-08-08,`app-debug.apk` 已安装并冷启动到 `MainActivity`,Compose 仪器测试 `MainActivityTest` 以 `OK (1 test)` 通过,首页与结果页语义树和截图人工复核通过,未发现应用崩溃日志。 +已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI、正式 Android 身份和 application 壳均已建立。2026-08-19,`verifyLocal` 通过(含 22 个 debug 单元测试、`lintDebug` 与 debug APK);便携 ADB 下 Samsung API 31 真机 `run-connected-tests.ps1` 得到 `OK (4 tests)`,覆盖方法说明、保存设置、夹具「复 24 → 坤 2、初爻动」本机保存/当次删除,以及 Room 会话读写。未发现应用崩溃日志。飞行模式人工走查、无障碍抽测和授权内容包仍未做。 这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 6bbec54..8b21b29 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,6 +1,6 @@ # 分阶段实施计划 -> 状态:P0 Android 壳、本地门禁与三星 API 31 真机闭环已完成;P-1 v0.3 待视觉复核;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始 +> 状态:P0/P1 已完成;P3 离线主流程已落地但未达到 UX 全表验收;P4 会话保存已落地,问卦簿/解读未开始;P2 授权内容未开始;P-1 v0.3 待视觉复核 > 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI ## 1. 依赖图 @@ -111,13 +111,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原 任务: -- 实现颜色、字体、间距、形状和动画令牌。 -- 实现卦象 `Canvas`、动爻标记和读屏语义。 -- 实现首次说明、起念、投币和结果页面。 -- 实现 `CastingSessionViewModel` 状态机及 SavedState 恢复。 -- 支持前五轮返回修改、第六轮封印和明确重新起卦。 -- 接入本地内容并区分原文/本地白话。 -- 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配。 +- [x] 实现颜色、字体、间距、形状和动画令牌:当前为 `LingjiTheme` 纸墨朱砂配色与系统衬线/无衬线配对;独立 token 文件、指定字体包和动效仍未做。 +- [x] 实现卦象 `Canvas`、动爻标记和读屏语义:结果页绘制本卦/之卦,动爻同时使用颜色、圆点和文字。 +- [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。 +- [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。 +- [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。 +- [ ] 接入本地内容并区分原文/本地白话:结果页只展示卦名、卦号、六爻事实,并明示授权内容包未接入。 +- [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。 退出条件: @@ -126,20 +126,22 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原 - AI/网络代码尚未存在也不影响流程。 - [UX 与东方视觉](ux-design.md)检查表通过。 +当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为授权卦辞、「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。 + ## 7. P4:本地解释与历史 目标:形成不依赖 AI 的完整 MVP。 任务: -- 实现版本化本地解释模板。 -- 实现“可以试的一小步”的非裁决式结构。 -- 实现 Room 历史:第六爻锁定后按策略自动保存快照,问题原文、当次解读和行动记录默认保存到同一会话。 -- 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;支持总开关关闭后的“保存本次”和全局设置不变的“本次不保存/删除本次记录”。 -- 实现按时间倒序的问卦簿、派生“最近一次”、空态、详情、解释版本、单条删除和清空全部确认。 -- 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。 -- 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件。 -- 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试。 +- [ ] 实现版本化本地解释模板。 +- [ ] 实现“可以试的一小步”的非裁决式结构。 +- [x] 实现 Room 历史会话:第六爻锁定后按 `autoSaveHistory` 写入 `casting_sessions` 快照;问题原文受内容开关控制。解读与行动表尚未建立。 +- [x] 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;总开关关闭后结果页提供“保存本次”,已保存时可“本次不保存/删除本次记录”。解读/行动开关目前只保留偏好,不写入任何正文。 +- [ ] 实现按时间倒序的问卦簿、空态、详情、解释版本、单条删除和清空全部确认。首页仅展示条数与最近一次派生摘要,不进入列表。 +- [x] 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。 +- [x] 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件;`verifyLocalDataBoundary` 锁定 `allowBackup=false` 且无 INTERNET 权限。 +- [ ] 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试:schema 1 无需迁移;编解码、Room 仪器测试和首页设置仪器测试已有,策略组合与级联删除未覆盖。 退出条件: diff --git a/docs/product-spec.md b/docs/product-spec.md index 23adea7..4db41f1 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -1,7 +1,7 @@ # 产品规格 > 状态:MVP 方案基线 -> 产品代号:Brainwave(正式名称 TBD) +> 产品代号:Brainwave(正式名称:灵机) > 原始输入:[原始需求.txt](原始需求.txt) ## 1. 产品定义 diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 5a1326a..144964e 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -26,7 +26,7 @@ .\gradlew.bat verifyLocal ``` -`verifyLocal` 聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、Android lint、debug APK、已解析 Android 调试/单测依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。首次解析 Android 依赖时不能强制 `--offline`;缓存完成后可用离线模式复核。 +`verifyLocal` 聚合无依赖格式检查、领域穷举测试、内容 repository 测试、历史编解码测试、问题校验测试、Android lint、debug APK、已解析 Android 调试/单测/仪器测试依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan、原型 JavaScript 语法检查,以及本机备份排除/无 INTERNET 权限检查。CI 执行同一个聚合任务。首次解析 Android 依赖时不能强制 `--offline`;缓存完成后可用离线模式复核。 授权真机存在时,Windows 环境还必须执行: diff --git a/docs/ux-design.md b/docs/ux-design.md index 606f320..97381ce 100644 --- a/docs/ux-design.md +++ b/docs/ux-design.md @@ -2,6 +2,7 @@ > 状态:MVP 设计基线 > 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置 +> 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果和保存设置;问卦簿列表、「解」与授权卦辞仍未进入应用,不能把本文件尚未实现的页面当成已上线功能 ## 1. 体验定位 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd700d2..7dbfccd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ androidxTestRunner = "1.5.2" androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } @@ -30,6 +31,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } diff --git a/scripts/verify-local-data-boundary.mjs b/scripts/verify-local-data-boundary.mjs new file mode 100644 index 0000000..a5acb2a --- /dev/null +++ b/scripts/verify-local-data-boundary.mjs @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { repositoryRoot } from "./repository-files.mjs"; + +const manifestPath = path.join(repositoryRoot, "app", "src", "main", "AndroidManifest.xml"); +const backupRulesPath = path.join( + repositoryRoot, + "app", + "src", + "main", + "res", + "xml", + "backup_rules.xml", +); +const extractionRulesPath = path.join( + repositoryRoot, + "app", + "src", + "main", + "res", + "xml", + "data_extraction_rules.xml", +); + +const [manifest, backupRules, extractionRules] = await Promise.all([ + readFile(manifestPath, "utf8"), + readFile(backupRulesPath, "utf8"), + readFile(extractionRulesPath, "utf8"), +]); + +const failures = []; +if (!manifest.includes('android:allowBackup="false"')) { + failures.push("Android backup must remain disabled"); +} +if (!manifest.includes('android:usesCleartextTraffic="false"')) { + failures.push("cleartext traffic must remain disabled"); +} +if (/android\.permission\.INTERNET/u.test(manifest)) { + failures.push("the offline application must not request INTERNET permission"); +} + +for (const domain of ["root", "file", "database", "sharedpref", "external"]) { + const exclusion = ``; + if (!backupRules.includes(exclusion)) { + failures.push(`legacy backup rules do not exclude ${domain}`); + } + const extractionOccurrences = extractionRules.split(exclusion).length - 1; + if (extractionOccurrences !== 2) { + failures.push( + `data extraction rules must exclude ${domain} from cloud backup and device transfer`, + ); + } +} + +if (failures.length > 0) { + console.error("Local-data boundary verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log("Local-data boundary verification passed."); +}