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
+10 -1
View File
@@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(composeBom)
implementation(libs.androidx.compose.ui)
@@ -99,6 +100,7 @@ dependencies {
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -106,6 +108,9 @@ dependencies {
tasks.withType<Test>().configureEach {
useJUnit()
maxParallelForks = 1
maxHeapSize = "192m"
jvmArgs("-XX:MaxMetaspaceSize=128m", "-XX:+UseSerialGC")
testLogging {
events("failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
@@ -124,7 +129,11 @@ tasks.register("verifyDependencyLicenses") {
@Suppress("UNCHECKED_CAST")
val groupPolicies = manifest.getValue("groupPolicies") as List<Map<String, Any>>
val reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet()
val resolvedCoordinates = listOf("debugRuntimeClasspath", "debugUnitTestRuntimeClasspath")
val resolvedCoordinates = listOf(
"debugRuntimeClasspath",
"debugUnitTestRuntimeClasspath",
"debugAndroidTestRuntimeClasspath",
)
.flatMap { configurationName ->
configurations.getByName(configurationName)
.resolvedConfiguration
@@ -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
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.printToString
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.runBlocking
import net.opcapp.flash.data.di.DebugPersistenceEntryPoint
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -14,11 +27,96 @@ class MainActivityTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Test
fun domainFixtureCanBeVerifiedFromHome() {
composeRule.onNodeWithText("灵机").assertIsDisplayed()
composeRule.onNodeWithText("验证起卦核心").performClick()
composeRule.onNodeWithText("复 24 → 坤 2").assertIsDisplayed()
composeRule.onNodeWithText("初爻动").assertIsDisplayed()
@Before
fun resetLocalData() = runBlocking {
val dependencies = testDependencies()
dependencies.database().clearAllTables()
dependencies.settings().setHasSeenMethodIntro(true)
dependencies.settings().setAutoSaveHistory(true)
dependencies.settings().setSaveQuestionText(true)
dependencies.settings().setSaveExplanationContent(true)
dependencies.settings().setSaveActionNote(true)
}
@Test
fun completeCastingIsCalculatedAndSavedLocally() {
openHome()
composeRule.onNodeWithText("灵机").assertIsDisplayed()
composeRule.onNodeWithText("开始一问").performClick()
composeRule.onNodeWithTag("question_input").performTextInput("我该先验证哪个假设?")
composeRule.onNodeWithText("开始录入铜币").performClick()
selectRound(characterIndices = emptySet())
repeat(5) { selectRound(characterIndices = setOf(0)) }
composeRule.onNodeWithText("复 24 → 坤 2").assertIsDisplayed()
composeRule.onNodeWithText("动爻:初爻").assertIsDisplayed()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("已自动保存完整记录到本机")
.fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("do_not_save_this_time").performScrollTo().performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("confirm_delete_record")
.fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("confirm_delete_record").performScrollTo().performClick()
try {
composeRule.waitUntil(timeoutMillis = 15_000) {
composeRule.onAllNodesWithText("本次尚未保存").fetchSemanticsNodes().isNotEmpty() ||
composeRule.onAllNodesWithText("删除失败,请重试").fetchSemanticsNodes().isNotEmpty()
}
} catch (error: Throwable) {
throw AssertionError(composeRule.onRoot().printToString(), error)
}
composeRule.onNodeWithText("本次尚未保存").assertIsDisplayed()
}
@Test
fun autoSaveSettingIsRealAndReflectedOnHome() {
openHome()
composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.onNodeWithTag("auto_save_history").assertIsOn().performClick()
composeRule.onNodeWithTag("auto_save_history").assertIsOff()
composeRule.onNodeWithText("返回").performClick()
composeRule.onNodeWithText("自动保存完整记录:已关闭").assertIsDisplayed()
composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.onNodeWithTag("auto_save_history").performClick()
composeRule.onNodeWithTag("auto_save_history").assertIsOn()
}
@Test
fun methodIntroRemainsAvailableFromHome() {
openHome()
composeRule.onNodeWithText("方法说明").performClick()
composeRule.onNodeWithText("你投币,应用记录;AI 不参与起卦。").assertIsDisplayed()
composeRule.onNodeWithText("字为 2,背为 3;第一次是初爻,由下而上。").assertIsDisplayed()
composeRule.onNodeWithTag("method_intro_continue").performClick()
composeRule.onNodeWithText("开始一问").assertIsDisplayed()
}
private fun openHome() {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("开始一问").fetchSemanticsNodes().isNotEmpty() ||
composeRule.onAllNodesWithTag("method_intro_continue").fetchSemanticsNodes().isNotEmpty()
}
if (composeRule.onAllNodesWithTag("method_intro_continue").fetchSemanticsNodes().isNotEmpty()) {
composeRule.onNodeWithTag("method_intro_continue").performClick()
}
composeRule.onNodeWithText("开始一问").assertIsDisplayed()
}
private fun selectRound(characterIndices: Set<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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import net.opcapp.flash.feature.home.DevelopmentHomeScreen
import net.opcapp.flash.feature.result.DomainVerificationScreen
import net.opcapp.flash.feature.casting.CastingScreen
import net.opcapp.flash.feature.casting.CastingViewModel
import net.opcapp.flash.feature.home.HomeScreen
import net.opcapp.flash.feature.home.HomeViewModel
import net.opcapp.flash.feature.onboarding.MethodIntroScreen
import net.opcapp.flash.feature.question.QuestionScreen
import net.opcapp.flash.feature.result.CastResultScreen
import net.opcapp.flash.feature.settings.HistorySettingsScreen
import net.opcapp.flash.feature.settings.HistorySettingsViewModel
private object Destination {
const val Welcome = "welcome"
const val Home = "home"
const val DomainResult = "domain-result"
const val Question = "question"
const val Casting = "casting"
const val Result = "result"
const val Settings = "settings"
}
@Composable
fun LingjiApp() {
val navController = rememberNavController()
fun LingjiApp(
castingViewModel: CastingViewModel,
homeViewModel: HomeViewModel,
settingsViewModel: HistorySettingsViewModel,
navController: NavHostController = rememberNavController(),
) {
val castingState = castingViewModel.uiState.collectAsStateWithLifecycle().value
val homeState = homeViewModel.uiState.collectAsStateWithLifecycle().value
val savePolicy = settingsViewModel.policy.collectAsStateWithLifecycle().value
if (!homeState.isReady) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {}
return
}
NavHost(
navController = navController,
startDestination = Destination.Home,
startDestination = if (homeState.hasSeenMethodIntro) {
Destination.Home
} else {
Destination.Welcome
},
) {
composable(Destination.Home) {
DevelopmentHomeScreen(
onVerifyDomain = { navController.navigate(Destination.DomainResult) },
composable(Destination.Welcome) {
MethodIntroScreen(
showBack = navController.previousBackStackEntry != null,
onContinue = {
homeViewModel.markMethodIntroSeen()
navController.navigate(Destination.Home) {
popUpTo(Destination.Welcome) { inclusive = true }
launchSingleTop = true
}
},
onBack = { navController.popBackStack() },
)
}
composable(Destination.DomainResult) {
DomainVerificationScreen(
composable(Destination.Home) {
HomeScreen(
uiState = homeState,
onStartCasting = {
castingViewModel.startNewSession()
navController.navigate(Destination.Question)
},
onOpenSettings = { navController.navigate(Destination.Settings) },
onOpenMethodIntro = { navController.navigate(Destination.Welcome) },
)
}
composable(Destination.Question) {
QuestionScreen(
question = castingState.question,
onQuestionChanged = castingViewModel::updateQuestion,
onContinue = { navController.navigate(Destination.Casting) },
onBack = navController::popBackStack,
)
}
composable(Destination.Casting) {
CastingScreen(
uiState = castingState,
onSelectCoin = castingViewModel::selectCoin,
onConfirmRound = {
if (castingViewModel.confirmCurrentRound()) {
navController.navigate(Destination.Result)
}
},
onEditRound = castingViewModel::editFrom,
onBack = navController::popBackStack,
)
}
composable(Destination.Result) {
CastResultScreen(
uiState = castingState,
onBackHome = { navController.popBackStack(Destination.Home, inclusive = false) },
onStartAgain = {
castingViewModel.startNewSession()
navController.navigate(Destination.Question) {
popUpTo(Destination.Home)
launchSingleTop = true
}
},
onSave = castingViewModel::saveManually,
onDelete = castingViewModel::deleteSavedSession,
onRetry = castingViewModel::retryLastPersistenceOperation,
)
}
composable(Destination.Settings) {
HistorySettingsScreen(
policy = savePolicy,
onAutoSaveChanged = settingsViewModel::setAutoSaveHistory,
onSaveQuestionChanged = settingsViewModel::setSaveQuestionText,
onSaveExplanationChanged = settingsViewModel::setSaveExplanationContent,
onSaveActionChanged = settingsViewModel::setSaveActionNote,
onBack = navController::popBackStack,
)
}
@@ -3,16 +3,28 @@ package net.opcapp.flash.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint
import net.opcapp.flash.core.designsystem.LingjiTheme
import net.opcapp.flash.feature.casting.CastingViewModel
import net.opcapp.flash.feature.home.HomeViewModel
import net.opcapp.flash.feature.settings.HistorySettingsViewModel
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val castingViewModel: CastingViewModel by viewModels()
private val homeViewModel: HomeViewModel by viewModels()
private val settingsViewModel: HistorySettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LingjiTheme {
LingjiApp()
LingjiApp(
castingViewModel = castingViewModel,
homeViewModel = homeViewModel,
settingsViewModel = settingsViewModel,
)
}
}
}
@@ -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"?>
<resources>
<string name="app_name">灵机</string>
<string name="development_build">内部构建 · 开发验证页</string>
<string name="home_tagline">本机起卦 · 离线可用</string>
<string name="home_kicker">三枚铜币 · 六爻由下而上</string>
<string name="home_lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</string>
<string name="seal_character">易</string>
<string name="core_check_title">领域核心已接入</string>
<string name="core_check_body">用固定六爻夹具验证卦象、变卦与动爻计算链路。</string>
<string name="verify_casting_core">验证起卦核心</string>
<string name="local_data_notice">当前验证页不写入记录。正式功能将默认保存在本机,只有明确发起 AI 解读时才会联网。</string>
<string name="start_casting">开始一问</string>
<string name="local_storage_title">你的数据留在本机</string>
<string name="local_storage_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="core_check_passed">计算通过</string>
<string name="fixture_result">复 %1$d → 坤 %2$d</string>
<string name="moving_line_result">初爻动</string>
<string name="fixture_explanation">固定输入为初爻老阳、其余五爻少阴。结果来自纯 Kotlin 起卦核心,不是界面写死的演示值。</string>
<string name="primary_hexagram_description">本卦复,第二十四卦</string>
<string name="transformed_hexagram_description">变卦坤,第二卦</string>
<string name="back_home">返回首页</string>
<string name="back_to_question">返回修改问题</string>
<string name="question_step">起念 · 第一步</string>
<string name="question_title">此刻,想慢下来看看什么?</string>
<string name="question_guidance">尽量写成一个具体、可由自己采取行动的问题。这里不是预测,也不替你做决定。</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>
@@ -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("𠀀"))
}
}