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:
+10
-1
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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("𠀀"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user