feat: add offline casting flow and local history sessions

Replace the development fixture shell with method intro, question, coin entry, and result screens. Persist locked casts in private Room storage under the save policy, and document remaining P2 content and P4 history-list gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
QiuSW
2026-08-19 10:58:09 +08:00
co-authored by Cursor
parent d82ab836a2
commit 4235cf9011
47 changed files with 3075 additions and 355 deletions
+2
View File
@@ -4,6 +4,8 @@
*.iml *.iml
local.properties local.properties
captures/ captures/
hs_err_pid*.log
replay_pid*.log
# Signing material and local credentials must never enter the repository. # Signing material and local credentials must never enter the repository.
*.jks *.jks
+10 -1
View File
@@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(composeBom) implementation(composeBom)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
@@ -99,6 +100,7 @@ dependencies {
androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -106,6 +108,9 @@ dependencies {
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnit() useJUnit()
maxParallelForks = 1
maxHeapSize = "192m"
jvmArgs("-XX:MaxMetaspaceSize=128m", "-XX:+UseSerialGC")
testLogging { testLogging {
events("failed", "skipped") events("failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
@@ -124,7 +129,11 @@ tasks.register("verifyDependencyLicenses") {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val groupPolicies = manifest.getValue("groupPolicies") as List<Map<String, Any>> val groupPolicies = manifest.getValue("groupPolicies") as List<Map<String, Any>>
val reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet() val reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet()
val resolvedCoordinates = listOf("debugRuntimeClasspath", "debugUnitTestRuntimeClasspath") val resolvedCoordinates = listOf(
"debugRuntimeClasspath",
"debugUnitTestRuntimeClasspath",
"debugAndroidTestRuntimeClasspath",
)
.flatMap { configurationName -> .flatMap { configurationName ->
configurations.getByName(configurationName) configurations.getByName(configurationName)
.resolvedConfiguration .resolvedConfiguration
@@ -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')"
]
}
}
@@ -1,10 +1,23 @@
package net.opcapp.flash.app package net.opcapp.flash.app
import androidx.compose.ui.test.assertIsDisplayed 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.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.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick 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 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.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -14,11 +27,96 @@ class MainActivityTest {
@get:Rule @get:Rule
val composeRule = createAndroidComposeRule<MainActivity>() val composeRule = createAndroidComposeRule<MainActivity>()
@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 @Test
fun domainFixtureCanBeVerifiedFromHome() { fun completeCastingIsCalculatedAndSavedLocally() {
openHome()
composeRule.onNodeWithText("灵机").assertIsDisplayed() composeRule.onNodeWithText("灵机").assertIsDisplayed()
composeRule.onNodeWithText("验证起卦核心").performClick() 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("复 24 → 坤 2").assertIsDisplayed()
composeRule.onNodeWithText("初爻动").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<Int>) {
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,
)
} }
@@ -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<Context>()
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",
),
)
}
@@ -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
}
@@ -1,32 +1,129 @@
package net.opcapp.flash.app 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.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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import net.opcapp.flash.feature.home.DevelopmentHomeScreen import net.opcapp.flash.feature.casting.CastingScreen
import net.opcapp.flash.feature.result.DomainVerificationScreen 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 { private object Destination {
const val Welcome = "welcome"
const val Home = "home" 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 @Composable
fun LingjiApp() { fun LingjiApp(
val navController = rememberNavController() 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( NavHost(
navController = navController, navController = navController,
startDestination = Destination.Home, startDestination = if (homeState.hasSeenMethodIntro) {
Destination.Home
} else {
Destination.Welcome
},
) { ) {
composable(Destination.Home) { composable(Destination.Welcome) {
DevelopmentHomeScreen( MethodIntroScreen(
onVerifyDomain = { navController.navigate(Destination.DomainResult) }, showBack = navController.previousBackStackEntry != null,
onContinue = {
homeViewModel.markMethodIntroSeen()
navController.navigate(Destination.Home) {
popUpTo(Destination.Welcome) { inclusive = true }
launchSingleTop = true
}
},
onBack = { navController.popBackStack() },
) )
} }
composable(Destination.DomainResult) { composable(Destination.Home) {
DomainVerificationScreen( 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, onBack = navController::popBackStack,
) )
} }
@@ -3,16 +3,28 @@ package net.opcapp.flash.app
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import net.opcapp.flash.core.designsystem.LingjiTheme 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 @AndroidEntryPoint
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
LingjiTheme { LingjiTheme {
LingjiApp() LingjiApp(
castingViewModel = castingViewModel,
homeViewModel = homeViewModel,
settingsViewModel = settingsViewModel,
)
} }
} }
} }
@@ -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))
}
@@ -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()
}
@@ -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<Int>
@Query("SELECT * FROM casting_sessions ORDER BY created_at_epoch_millis DESC LIMIT 1")
fun observeLatest(): Flow<CastingSessionEntity?>
@Query("DELETE FROM casting_sessions WHERE id = :id")
suspend fun deleteById(id: String): Int
}
@@ -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,
)
@@ -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<List<Int>>): String =
rounds.joinToString(separator = ",", prefix = "[", postfix = "]") { round ->
round.joinToString(separator = ",", prefix = "[", postfix = "]")
}
private fun decodeRounds(value: String): List<List<Int>> {
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<Int> =
if (value.isEmpty()) emptyList() else value.split(",").map(String::toInt)
}
@@ -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<Int>
fun observeLatest(): Flow<SavedSessionSummary?>
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
}
@@ -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"
}
}
@@ -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<Int> = dao.observeCount()
override fun observeLatest(): Flow<SavedSessionSummary?> = 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
}
@@ -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,
)
@@ -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<Preferences> by preferencesDataStore(
name = DATASTORE_NAME,
)
@Singleton
class HistorySettingsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val preferencesData: Flow<Preferences> = context.historySettingsDataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
val policy: Flow<HistorySavePolicy> = 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<Boolean> = 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<Boolean>, 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")
}
}
@@ -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<CoinSide?>,
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<CastRound>,
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)
}
@@ -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<CastRound> = emptyList(),
val currentCoins: List<CoinSide?> = 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<String>(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<CastingUiState> = _uiState.asStateFlow()
private var persistenceJob: Job? = null
init {
if (restoredResult != null) reconcileRestoredResult()
}
fun startNewSession() {
persistenceJob?.cancel()
savedStateHandle.remove<String>(Keys.CreatedAt)
savedStateHandle.remove<String>(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<CastRound>) {
savedStateHandle[Keys.Rounds] = ArrayList(
rounds.flatMap { round -> round.coins.map(CoinSide::score) },
)
}
private fun saveCurrentCoins(coins: List<CoinSide?>) {
savedStateHandle[Keys.CurrentCoins] = ArrayList(coins.map { it?.score ?: 0 })
}
private fun createResult(rounds: List<CastRound>, createdAt: String): CastResult =
CastResult.record(
computation = CastEngine.cast(rounds),
metadata = CastMetadata(
contentVersion = CONTENT_VERSION,
createdAt = createdAt,
),
)
private fun decodeRounds(scores: ArrayList<Int>?): List<CastRound> =
scores.orEmpty().chunked(CastRound.COINS_PER_ROUND).map(CastRound::fromScores)
private fun decodeCurrentCoins(scores: ArrayList<Int>?): List<CoinSide?> {
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"
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
}
@@ -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<HomeUiState> = 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)
}
}
}
@@ -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,
)
}
@@ -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))
}
}
}
}
}
@@ -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
}
}
@@ -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<LineValue>) {
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<Int>): 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<Polarity>,
movingPositions: List<Int>,
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),
)
}
}
}
}
@@ -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<Polarity>,
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,
)
}
}
}
}
@@ -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,
)
}
}
@@ -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<HistorySavePolicy> = 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() }
}
}
+107 -12
View File
@@ -1,18 +1,113 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">灵机</string> <string name="app_name">灵机</string>
<string name="development_build">内部构建 · 开发验证页</string> <string name="home_kicker">三枚铜币 · 六爻由下而上</string>
<string name="home_tagline">本机起卦 · 离线可用</string> <string name="home_lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</string>
<string name="seal_character">易</string> <string name="seal_character">易</string>
<string name="core_check_title">领域核心已接入</string> <string name="start_casting">开始一问</string>
<string name="core_check_body">用固定六爻夹具验证卦象、变卦与动爻计算链路。</string> <string name="local_storage_title">你的数据留在本机</string>
<string name="verify_casting_core">验证起卦核心</string> <string name="local_storage_notice">起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。</string>
<string name="local_data_notice">当前验证页不写入记录。正式功能将默认保存在本机,只有明确发起 AI 解读时才会联网。</string> <string name="auto_save_on">自动保存完整记录:已开启</string>
<string name="auto_save_off">自动保存完整记录:已关闭</string>
<string name="open_save_settings">管理保存设置</string>
<string name="open_method_intro">方法说明</string>
<string name="local_records_title">你的观照</string>
<string name="no_saved_records">还没有已完成的记录。未完成的问题和投币不会进入长期历史。</string>
<string name="saved_record_count">已保存 %1$d 次起卦</string>
<string name="latest_record_date">最近一次 · %1$s</string>
<string name="latest_hexagram_summary">%1$s %2$d → %3$s %4$d</string>
<string name="latest_static_hexagram_summary">%1$s %2$d</string>
<string name="latest_moving_line_count">%1$d 个动爻</string>
<string name="latest_no_moving_lines">无动爻</string>
<string name="latest_explanation_pending">尚未解读</string>
<string name="method_intro_kicker">第一次起卦前</string>
<string name="method_intro_title">把一个犹豫,安静地放在这里。</string>
<string name="method_intro_lede">应用只记录你亲手投出的铜币,并在本机算出本卦、之卦和动爻。</string>
<string name="method_intro_you_cast">你投币,应用记录;AI 不参与起卦。</string>
<string name="method_intro_coin_rule">字为 2,背为 3;第一次是初爻,由下而上。</string>
<string name="method_intro_not_decision">结果用于整理想法,不替你做决定。</string>
<string name="method_intro_start">开始</string>
<string name="back">返回</string> <string name="back">返回</string>
<string name="core_check_passed">计算通过</string> <string name="back_home">返回首页</string>
<string name="fixture_result">复 %1$d → 坤 %2$d</string> <string name="back_to_question">返回修改问题</string>
<string name="moving_line_result">初爻动</string> <string name="question_step">起念 · 第一步</string>
<string name="fixture_explanation">固定输入为初爻老阳、其余五爻少阴。结果来自纯 Kotlin 起卦核心,不是界面写死的演示值。</string> <string name="question_title">此刻,想慢下来看看什么?</string>
<string name="primary_hexagram_description">本卦复,第二十四卦</string> <string name="question_guidance">尽量写成一个具体、可由自己采取行动的问题。这里不是预测,也不替你做决定。</string>
<string name="transformed_hexagram_description">变卦坤,第二卦</string> <string name="question_label">此刻想问的事</string>
<string name="question_privacy_helper">1–200 字。完成后按保存设置留在本机,不会因为保存而上传。</string>
<string name="question_too_long">超过 200 字,请缩短后继续。</string>
<string name="character_count">%1$d / %2$d 字</string>
<string name="start_coin_entry">开始录入铜币</string>
<string name="casting_step">投币 · 第 %1$d 爻</string>
<string name="casting_title">依次录入三枚铜币</string>
<string name="coin_convention_visible">固定计值:字 2,背 3。请真实投掷后逐枚选择,应用不代投。</string>
<string name="round_progress">已确认 %1$d / %2$d 爻</string>
<string name="current_line_title">正在录入:%1$s</string>
<string name="coin_index">第 %1$d 枚</string>
<string name="coin_character">字 2</string>
<string name="coin_reverse">背 3</string>
<string name="current_line_preview">和值 %1$d · %2$s · %3$s</string>
<string name="select_all_three_coins">选完三枚后,将在这里显示本爻的和值与动静。</string>
<string name="confirm_this_line">确认本爻</string>
<string name="confirm_and_finish">确认上爻并成卦</string>
<string name="confirmed_lines_title">已确认(由下而上)</string>
<string name="confirmed_line_summary">%1$s · %2$d %3$s</string>
<string name="rerecord_from_line">从此重录</string>
<string name="moving_line">动爻</string>
<string name="static_line">静爻</string>
<string name="line_first">初爻</string>
<string name="line_second">二爻</string>
<string name="line_third">三爻</string>
<string name="line_fourth">四爻</string>
<string name="line_fifth">五爻</string>
<string name="line_top">上爻</string>
<string name="old_yin">老阴</string>
<string name="young_yang">少阳</string>
<string name="young_yin">少阴</string>
<string name="old_yang">老阳</string>
<string name="save_settings_title">本机保存设置</string>
<string name="save_settings_description">这些开关只控制应用私有存储,不构成上传或 AI 授权。</string>
<string name="auto_save_complete_record">自动保存完整记录</string>
<string name="auto_save_complete_record_description">第六爻确认后立即创建本机会话。</string>
<string name="save_question_text">保存问题原文</string>
<string name="save_question_text_description">关闭后仍保存可复核卦象,但不保存你写的问题。</string>
<string name="save_explanation_content">保存解读全文</string>
<string name="save_explanation_content_description">解读功能接入后,按此选择关联到同一会话。</string>
<string name="save_action_note">保存行动记录</string>
<string name="save_action_note_description">行动记录功能接入后,按此选择保存在本机。</string>
<string name="disabled_settings_explanation">总开关关闭时,三个内容选择保留但暂不参与自动保存;重新开启后恢复。</string>
<string name="no_cloud_backup_notice">历史数据库已排除系统自动备份和设备迁移;当前版本不提供账号、导出或云同步。</string>
<string name="result_kicker">本地计算完成</string>
<string name="moving_result_title">%1$s %2$d → %3$s %4$d</string>
<string name="static_result_title">%1$s %2$d</string>
<string name="moving_lines_summary">动爻:%1$s</string>
<string name="no_moving_lines">无动爻,本卦不变</string>
<string name="primary_hexagram_accessibility">本卦 %1$s,第 %2$d 卦</string>
<string name="transformed_hexagram_accessibility">之卦 %1$s,第 %2$d 卦</string>
<string name="raw_lines_title">六爻复核(由下而上)</string>
<string name="raw_line_summary">%1$s · %2$d %3$s · %4$s</string>
<string name="save_status_privacy">保存仅发生在应用私有本机数据库中,不会触发网络请求。</string>
<string name="save_status_preparing">正在准备记录</string>
<string name="save_status_saving">正在保存到本机</string>
<string name="save_status_deleting">正在删除本机记录</string>
<string name="save_status_not_saved">本次尚未保存</string>
<string name="save_status_saved_complete">已自动保存完整记录到本机</string>
<string name="save_status_saved_without_question">卦象已保存到本机,问题原文未保存</string>
<string name="save_status_failed">保存失败,原始结果仍可查看</string>
<string name="delete_status_failed">删除失败,请重试</string>
<string name="save_this_time">保存本次</string>
<string name="do_not_save_this_time">本次不保存</string>
<string name="retry">重试</string>
<string name="content_package_pending">当前显示可复核的卦号、卦名和六爻事实。经典原文与现代白话将在来源和授权审核完成后接入。</string>
<string name="start_again">再起一卦</string>
<string name="result_missing">结果状态已丢失,请返回首页重新开始。</string>
<string name="delete_current_record_title">删除本次本机记录?</string>
<string name="delete_current_record_message">将删除已经自动保存的问题和卦象快照,不改变全局保存设置。</string>
<string name="delete_record">删除记录</string>
<string name="cancel">取消</string>
</resources> </resources>
@@ -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))
}
}
@@ -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)
}
}
}
@@ -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("𠀀"))
}
}
+7
View File
@@ -61,6 +61,12 @@ val verifyDependencyLicenseManifest = registerNodeVerificationTask(
script = "scripts/verify-dependency-licenses.mjs", 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") { tasks.register("verifyLocal") {
group = LifecycleBasePlugin.VERIFICATION_GROUP group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs every environment-independent local quality gate." description = "Runs every environment-independent local quality gate."
@@ -76,6 +82,7 @@ tasks.register("verifyLocal") {
verifyContentContract, verifyContentContract,
spotlessCheck, spotlessCheck,
verifyDependencyLicenseManifest, verifyDependencyLicenseManifest,
verifyLocalDataBoundary,
) )
} }
+34 -2
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"reviewedAt": "2026-08-07", "reviewedAt": "2026-08-19",
"scopeNote": "Android debug runtime and debug unit-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.", "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": [ "components": [
{ {
"id": "gradle-wrapper", "id": "gradle-wrapper",
@@ -130,6 +130,38 @@
"scope": "test-transitive", "scope": "test-transitive",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt" "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": [ "groupPolicies": [
+3 -3
View File
@@ -1,8 +1,8 @@
# Brainwave 项目文档 # Brainwave 项目文档
> 文档状态:方案基线 > 文档状态:方案基线
> 最后核验:2026-08-07 > 最后核验:2026-08-19
> 当前阶段:正式产品名“灵机”;P0 Android 壳与本地门禁已建立,真机闭环正在执行;P-1 v0.3 待品牌文案刷新和视觉复核;P1 已完成;P2 授权内容未开始 > 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 授权内容未开始;P-1 v0.3 待视觉复核;P5 未开始
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。 本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
@@ -90,4 +90,4 @@
- 测试命令、构建方式或发布门禁改变; - 测试命令、构建方式或发布门禁改变;
- 出现可能再次发生的缺陷。 - 出现可能再次发生的缺陷。
文档链接、需求编号和验证命令将随工程骨架一起接入 CI 检查。当前尚无 Gradle 工程,不能声称任何构建或测试已经通过。 文档链接、需求编号和验证命令由 `verifyLocal` 机械检查。授权真机存在时另跑设备仪器测试;未运行的门禁不得写成已经通过。
+2
View File
@@ -69,6 +69,8 @@ app/src/main/java/net/opcapp/flash/
测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。 测试按相同包结构镜像放入 `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. 依赖方向 ## 4. 依赖方向
允许: 允许:
+3 -1
View File
@@ -58,7 +58,7 @@
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。 示例中的省略号不是可发布内容。禁止由 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. 内容完整性门禁 ## 3. 内容完整性门禁
@@ -88,6 +88,8 @@
## 5. Room 数据模型提案 ## 5. Room 数据模型提案
当前实现只有 `CastingSessionEntity` / `casting_sessions`(schema version 1)。`ExplanationEntity` 与 `ActionNoteEntity` 仍是提案,不得在 UI 中假装已经保存解读或行动。
历史记录不是起卦事实源,但应保存可复核的快照: 历史记录不是起卦事实源,但应保存可复核的快照:
```text ```text
+7 -3
View File
@@ -1,9 +1,9 @@
# 依赖与许可证清单 # 依赖与许可证清单
> 状态:Android debug runtime 与 debug unit-test runtime 依赖图已建立自动覆盖门禁 > 状态:Android debug runtime、debug unit-test runtime 与 debug instrumentation-test runtime 依赖图已建立自动覆盖门禁
> 适用范围:实际解析的 Android 调试运行时、单元测试运行时,以及直接使用的构建入口 > 适用范围:实际解析的 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) | | `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) | | `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-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) |
## 传递依赖组策略 ## 传递依赖组策略
+2 -2
View File
@@ -1,7 +1,7 @@
# 本地开发环境基线 # 本地开发环境基线
> 状态:已从当前主机实测 > 状态:已从当前主机实测
> 最后核验:2026-08-08 > 最后核验:2026-08-19
> 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证 > 适用范围:`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。 - `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。
- 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。 - 代理已配置;本轮 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)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。 这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
+18 -16
View File
@@ -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 > 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
## 1. 依赖图 ## 1. 依赖图
@@ -111,13 +111,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务: 任务:
- 实现颜色、字体、间距、形状和动画令牌。 - [x] 实现颜色、字体、间距、形状和动画令牌:当前为 `LingjiTheme` 纸墨朱砂配色与系统衬线/无衬线配对;独立 token 文件、指定字体包和动效仍未做。
- 实现卦象 `Canvas`、动爻标记和读屏语义。 - [x] 实现卦象 `Canvas`、动爻标记和读屏语义:结果页绘制本卦/之卦,动爻同时使用颜色、圆点和文字。
- 实现首次说明、起念、投币和结果页面。 - [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。
- 实现 `CastingSessionViewModel` 状态机及 SavedState 恢复。 - [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。
- 支持前五轮返回修改、第六轮封印和明确重新起卦。 - [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。
- 接入本地内容并区分原文/本地白话。 - [ ] 接入本地内容并区分原文/本地白话:结果页只展示卦名、卦号、六爻事实,并明示授权内容包未接入。
- 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配。 - [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。
退出条件: 退出条件:
@@ -126,20 +126,22 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- AI/网络代码尚未存在也不影响流程。 - AI/网络代码尚未存在也不影响流程。
- [UX 与东方视觉](ux-design.md)检查表通过。 - [UX 与东方视觉](ux-design.md)检查表通过。
当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为授权卦辞、「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。
## 7. P4:本地解释与历史 ## 7. P4:本地解释与历史
目标:形成不依赖 AI 的完整 MVP。 目标:形成不依赖 AI 的完整 MVP。
任务: 任务:
- 实现版本化本地解释模板。 - [ ] 实现版本化本地解释模板。
- 实现“可以试的一小步”的非裁决式结构。 - [ ] 实现“可以试的一小步”的非裁决式结构。
- 实现 Room 历史:第六爻锁定后按策略自动保存快照,问题原文、当次解读和行动记录默认保存到同一会话。 - [x] 实现 Room 历史会话:第六爻锁定后按 `autoSaveHistory` 写入 `casting_sessions` 快照;问题原文受内容开关控制。解读与行动表尚未建立。
- 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;支持总开关关闭后的“保存本次”和全局设置不变的“本次不保存/删除本次记录”。 - [x] 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;总开关关闭后结果页提供“保存本次”,已保存时可“本次不保存/删除本次记录”。解读/行动开关目前只保留偏好,不写入任何正文。
- 实现按时间倒序的问卦簿、派生“最近一次”、空态、详情、解释版本、单条删除和清空全部确认。 - [ ] 实现按时间倒序的问卦簿、空态、详情、解释版本、单条删除和清空全部确认。首页仅展示条数与最近一次派生摘要,不进入列表。
- 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。 - [x] 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
- 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件。 - [x] 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件;`verifyLocalDataBoundary` 锁定 `allowBackup=false` 且无 INTERNET 权限。
- 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试。 - [ ] 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试:schema 1 无需迁移;编解码、Room 仪器测试和首页设置仪器测试已有,策略组合与级联删除未覆盖。
退出条件: 退出条件:
+1 -1
View File
@@ -1,7 +1,7 @@
# 产品规格 # 产品规格
> 状态:MVP 方案基线 > 状态:MVP 方案基线
> 产品代号:Brainwave(正式名称 TBD) > 产品代号:Brainwave(正式名称:灵机)
> 原始输入:[原始需求.txt](原始需求.txt) > 原始输入:[原始需求.txt](原始需求.txt)
## 1. 产品定义 ## 1. 产品定义
+1 -1
View File
@@ -26,7 +26,7 @@
.\gradlew.bat verifyLocal .\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 环境还必须执行: 授权真机存在时,Windows 环境还必须执行:
+1
View File
@@ -2,6 +2,7 @@
> 状态:MVP 设计基线 > 状态:MVP 设计基线
> 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置 > 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置
> 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果和保存设置;问卦簿列表、「解」与授权卦辞仍未进入应用,不能把本文件尚未实现的页面当成已上线功能
## 1. 体验定位 ## 1. 体验定位
+2
View File
@@ -18,6 +18,7 @@ androidxTestRunner = "1.5.2"
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } 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-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-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-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" } 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-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", 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-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" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
+61
View File
@@ -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 = `<exclude domain="${domain}" path="." />`;
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.");
}