Compare commits

..
7 Commits
Author SHA1 Message Date
QiuSWandCursor 534c88993e feat: add local hexagram content for offline reading
Load a versioned Wikisource jing plus project-authored plain drafts so results can show labeled original and vernacular texts without unauthorized modern translations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:48:16 +08:00
QiuSWandCursor 4235cf9011 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>
2026-08-19 10:58:09 +08:00
QiuSW d82ab836a2 test: add reproducible device verification 2026-08-08 09:19:40 +08:00
QiuSW 730bc528be feat: add installable Lingji Android shell 2026-08-07 18:00:38 +08:00
QiuSW de480ee9bb chore: verify dependency licenses
verify / harness (push) Has been cancelled
2026-08-04 23:32:44 +08:00
QiuSW bad67fa5a8 feat: add domain core and verification harness 2026-08-04 23:28:10 +08:00
QiuSW a82af6939f feat: add local autosave history prototype 2026-08-04 23:00:05 +08:00
120 changed files with 13272 additions and 161 deletions
+14
View File
@@ -0,0 +1,14 @@
* text=auto
/.gitattributes text eol=lf
/.gitignore text eol=lf
/gradlew text eol=lf
*.bat text eol=crlf
*.json text eol=lf
*.sh text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.mjs text eol=lf
*.md text eol=lf
*.properties text eol=lf
*.toml text eol=lf
*.yml text eol=lf
+33
View File
@@ -0,0 +1,33 @@
name: verify
on:
push:
pull_request:
permissions:
contents: read
jobs:
harness:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "17"
- name: Set up Node.js 22
uses: actions/setup-node@v7
with:
node-version: "22"
package-manager-cache: false
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install pinned Android SDK components
run: sdkmanager "platforms;android-34" "build-tools;34.0.0"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Run local quality gates
run: ./gradlew verifyLocal --no-daemon
+16
View File
@@ -0,0 +1,16 @@
.gradle/
**/build/
.idea/
*.iml
local.properties
captures/
hs_err_pid*.log
replay_pid*.log
# Signing material and local credentials must never enter the repository.
*.jks
*.keystore
keystore.properties
secrets.properties
.env
.env.*
+48
View File
@@ -0,0 +1,48 @@
# Brainwave agent guide
This file is the short repository entry point. The detailed, authoritative map is
[`docs/README.md`](docs/README.md).
## Start here
1. Read `docs/agent-playbook.md` before changing files.
2. Read `docs/environment.md` before build, SDK, device, or dependency work.
3. Read the relevant product/domain/architecture document from `docs/README.md`.
4. Follow `docs/implementation-plan.md`; never silently turn an item in
`docs/decisions.md#未决问题` into a product fact.
## Discovery
Prefer the configured codebase knowledge-graph tools in this order:
`search_graph`, `trace_path`, `get_code_snippet`, `query_graph`, then
`get_architecture`. Fall back to `rg` for literals, non-code files, or when those
tools are unavailable or insufficient. State the fallback in the handoff.
## Non-negotiable domain rules
- The three physical coins are entered manually; do not add random casting.
- `字 = 2`, `背 = 3`; record six lines from bottom to top.
- Sums `6` and `9` move; sums `7` and `8` remain static.
- King Wen numbering is a lookup table, never `binary + 1`.
- Casting code remains pure Kotlin with no Android, Compose, database, or network
dependency.
- AI is opt-in explanation only. Do not place provider secrets in the app or repo.
## Current build boundary
The repository keeps `brainwave` only as its code name. The accepted Android
identity is product name `灵机`, namespace/application ID `net.opcapp.flash`, and
`minSdk` 26. The `app` project is an installable Android application; the app icon
and store assets remain product decisions and must not be silently finalized.
## Verification
Run from the repository root:
```powershell
.\gradlew.bat verifyLocal
```
`verifyLocal` includes Android lint, debug unit tests, APK assembly, repository
checks, and dependency-license coverage. Run `connectedDebugAndroidTest` separately
when an authorized device is present, and report skipped device checks explicitly.
+171
View File
@@ -0,0 +1,171 @@
import groovy.json.JsonSlurper
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt)
}
android {
namespace = "net.opcapp.flash"
compileSdk = 34
defaultConfig {
applicationId = "net.opcapp.flash"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
compose = true
buildConfig = false
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
sourceSets {
getByName("main").assets.srcDir(rootProject.file("content/packages"))
getByName("test").resources.srcDir(rootProject.file("content/packages"))
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
kotlin {
jvmToolchain(17)
}
kapt {
correctErrorTypes = true
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.jvmTarget = "17"
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
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)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
kapt(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
testImplementation(libs.junit)
androidTestImplementation(composeBom)
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)
}
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
}
}
tasks.register("verifyDependencyLicenses") {
group = "verification"
description = "Ensures every resolved debug runtime/test artifact has a reviewed license entry."
doLast {
@Suppress("UNCHECKED_CAST")
val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any>
@Suppress("UNCHECKED_CAST")
val components = manifest.getValue("components") as List<Map<String, Any>>
@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",
"debugAndroidTestRuntimeClasspath",
)
.flatMap { configurationName ->
configurations.getByName(configurationName)
.resolvedConfiguration
.resolvedArtifacts
.mapNotNull { artifact ->
if (artifact.id.componentIdentifier !is org.gradle.api.artifacts.component.ModuleComponentIdentifier) {
return@mapNotNull null
}
val id = artifact.moduleVersion.id
"${id.group}:${id.name}:${id.version}"
}
}
.toSet()
val missing = resolvedCoordinates.filterNot { coordinates ->
if (coordinates in reviewedCoordinates) return@filterNot true
val group = coordinates.substringBefore(":")
groupPolicies.any { policy ->
val prefix = policy.getValue("groupPrefix") as String
group == prefix || group.startsWith("$prefix.")
}
}
check(missing.isEmpty()) {
"Dependencies missing reviewed license entries: ${missing.sorted().joinToString()}"
}
logger.lifecycle(
"Dependency license coverage passed for ${resolvedCoordinates.size} artifacts " +
"with ${groupPolicies.size} reviewed group policies.",
)
}
}
+1
View File
@@ -0,0 +1 @@
# Project-specific R8 rules will be added when release minification is enabled.
@@ -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')"
]
}
}
@@ -0,0 +1,141 @@
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
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Before
fun resetLocalData() = runBlocking {
val dependencies = testDependencies()
dependencies.database().clearAllTables()
dependencies.settings().setHasSeenMethodIntro(true)
dependencies.settings().setAutoSaveHistory(true)
dependencies.settings().setSaveQuestionText(true)
dependencies.settings().setSaveExplanationContent(true)
dependencies.settings().setSaveActionNote(true)
}
@Test
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.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("auto_save_history").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("auto_save_history").assertIsOn().performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOff()
true
}.getOrDefault(false)
}
composeRule.onNodeWithText("返回").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("自动保存完整记录:已关闭").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithText("自动保存完整记录:已关闭").assertIsDisplayed()
composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("auto_save_history").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("auto_save_history").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOn()
true
}.getOrDefault(false)
}
}
@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
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".app.LingjiApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Lingji"
android:usesCleartextTraffic="false">
<activity
android:name=".app.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,140 @@
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.casting.CastingScreen
import net.opcapp.flash.feature.casting.CastingViewModel
import net.opcapp.flash.feature.content.ContentSourcesScreen
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 Question = "question"
const val Casting = "casting"
const val Result = "result"
const val Settings = "settings"
const val Sources = "sources"
}
@Composable
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 = if (homeState.hasSeenMethodIntro) {
Destination.Home
} else {
Destination.Welcome
},
) {
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.Home) {
HomeScreen(
uiState = homeState,
onStartCasting = {
castingViewModel.startNewSession()
navController.navigate(Destination.Question)
},
onOpenSettings = { navController.navigate(Destination.Settings) },
onOpenMethodIntro = { navController.navigate(Destination.Welcome) },
onOpenContentSources = { navController.navigate(Destination.Sources) },
)
}
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,
)
}
composable(Destination.Sources) {
ContentSourcesScreen(
manifest = homeState.contentManifest,
onBack = navController::popBackStack,
)
}
}
}
@@ -0,0 +1,7 @@
package net.opcapp.flash.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class LingjiApplication : Application()
@@ -0,0 +1,31 @@
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(
castingViewModel = castingViewModel,
homeViewModel = homeViewModel,
settingsViewModel = settingsViewModel,
)
}
}
}
}
@@ -0,0 +1,84 @@
package net.opcapp.flash.core.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
private val Paper = Color(0xFFF5F0E6)
private val RaisedPaper = Color(0xFFFFFBF3)
private val Ink = Color(0xFF26231F)
private val MutedInk = Color(0xFF665F55)
private val Cinnabar = Color(0xFFA33A2B)
private val DarkPaper = Color(0xFF1E1B18)
private val DarkRaisedPaper = Color(0xFF292520)
private val PaleInk = Color(0xFFEAE2D5)
private val PaleCinnabar = Color(0xFFFFB4A5)
private val LightColors = lightColorScheme(
primary = Cinnabar,
onPrimary = Color.White,
primaryContainer = Color(0xFFF4DAD3),
onPrimaryContainer = Color(0xFF3D0902),
background = Paper,
onBackground = Ink,
surface = RaisedPaper,
onSurface = Ink,
surfaceVariant = Color(0xFFE8E0D3),
onSurfaceVariant = MutedInk,
outline = Color(0xFF81786C),
)
private val DarkColors = darkColorScheme(
primary = PaleCinnabar,
onPrimary = Color(0xFF5F160C),
primaryContainer = Color(0xFF7F261A),
onPrimaryContainer = Color(0xFFFFDAD2),
background = DarkPaper,
onBackground = PaleInk,
surface = DarkRaisedPaper,
onSurface = PaleInk,
surfaceVariant = Color(0xFF4C453D),
onSurfaceVariant = Color(0xFFD1C6B8),
outline = Color(0xFF9C9184),
)
private val LingjiTypography = Typography().let { defaults ->
defaults.copy(
displaySmall = defaults.displaySmall.copy(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.SemiBold,
),
headlineMedium = defaults.headlineMedium.copy(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Medium,
),
titleLarge = defaults.titleLarge.copy(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Medium,
),
bodyLarge = defaults.bodyLarge.copy(fontFamily = FontFamily.SansSerif),
bodyMedium = defaults.bodyMedium.copy(fontFamily = FontFamily.SansSerif),
labelLarge = defaults.labelLarge.copy(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
),
)
}
@Composable
fun LingjiTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = LingjiTypography,
content = content,
)
}
@@ -0,0 +1,34 @@
package net.opcapp.flash.core.model
data class ContentSource(
val id: String,
val title: String,
val edition: String,
val license: String,
val url: String,
) {
init {
require(id.isNotBlank()) { "Source id must not be blank" }
require(title.isNotBlank()) { "Source title must not be blank" }
require(edition.isNotBlank()) { "Source edition must not be blank" }
require(license.isNotBlank()) { "Source license must not be blank" }
require(url.isNotBlank()) { "Source url must not be blank" }
}
}
data class ContentManifest(
val contentVersion: String,
val digest: String,
val sources: List<ContentSource>,
val loadError: String? = null,
) {
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(loadError == null || loadError.isNotBlank()) {
"Content load error must be non-blank or null"
}
require(sources.map(ContentSource::id).distinct().size == sources.size) {
"Content sources must have unique ids"
}
}
}
@@ -0,0 +1,36 @@
package net.opcapp.flash.core.model
data class HexagramContent(
val kingWenNumber: Int,
val name: String,
val symbol: String,
val judgmentOriginal: String,
val judgmentPlain: String,
val lineTextsBottomUp: List<String>,
val linePlainBottomUp: List<String>,
val specialUsageText: String?,
val sourceRefs: List<String>,
) {
init {
require(kingWenNumber in 1..64) { "King Wen number must be between 1 and 64" }
require(name.isNotBlank()) { "Hexagram name must not be blank" }
require(symbol.isNotBlank()) { "Hexagram symbol must not be blank" }
require(judgmentOriginal.isNotBlank()) { "Original judgment must not be blank" }
require(judgmentPlain.isNotBlank()) { "Plain judgment must not be blank" }
require(lineTextsBottomUp.size == 6 && lineTextsBottomUp.all(String::isNotBlank)) {
"Original line texts must contain six non-blank bottom-up entries"
}
require(linePlainBottomUp.size == 6 && linePlainBottomUp.all(String::isNotBlank)) {
"Plain line texts must contain six non-blank bottom-up entries"
}
require(specialUsageText == null || specialUsageText.isNotBlank()) {
"Special usage text must be non-blank or null"
}
require(sourceRefs.isNotEmpty() && sourceRefs.all(String::isNotBlank)) {
"Source references must contain at least one non-blank id"
}
require(sourceRefs.distinct().size == sourceRefs.size) {
"Source references must be unique"
}
}
}
@@ -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,55 @@
package net.opcapp.flash.data.content
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.HexagramContent
@Singleton
class AssetsHexagramContentRepository @Inject constructor(
@ApplicationContext context: Context,
) : HexagramContentRepository {
private val parsed: Result<ParsedHexagramContentPackage> by lazy {
runCatching {
context.assets.open(ASSET_NAME).bufferedReader(Charsets.UTF_8).use { reader ->
HexagramContentParser.parse(reader.readText())
}
}
}
override val contentVersion: String
get() = parsed.getOrNull()?.manifest?.contentVersion ?: UNAVAILABLE_VERSION
override val manifest: ContentManifest
get() = parsed.getOrNull()?.manifest ?: ContentManifest(
contentVersion = UNAVAILABLE_VERSION,
digest = "unavailable",
sources = emptyList(),
loadError = parsed.exceptionOrNull()?.message ?: "Content package could not be loaded",
)
override fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String,
): HexagramContent {
val pack = parsed.getOrElse { error ->
throw ContentIntegrityException(
error.message ?: "Content package failed integrity checks",
)
}
if (requestedContentVersion != pack.manifest.contentVersion) {
throw ContentIntegrityException(
"Requested content version '$requestedContentVersion' is unavailable; loaded '${pack.manifest.contentVersion}'",
)
}
return pack.hexagrams[kingWenNumber]
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
}
companion object {
const val ASSET_NAME = "hexagram-content.json"
const val UNAVAILABLE_VERSION = "unavailable"
}
}
@@ -0,0 +1,340 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.ContentSource
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.core.model.HexagramNames
import net.opcapp.flash.domain.casting.HexagramCatalog
import net.opcapp.flash.domain.casting.HexagramPattern
import net.opcapp.flash.domain.casting.Polarity
data class ParsedHexagramContentPackage(
val manifest: ContentManifest,
val hexagrams: Map<Int, HexagramContent>,
)
object HexagramContentParser {
private val unsafeText =
Regex("(?:<\\s*script\\b|javascript\\s*:|\\bon\\w+\\s*=|[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f])")
fun parse(json: String): ParsedHexagramContentPackage {
val root = try {
JsonReader(json).readValue() as? JsonValue.Obj
?: throw ContentIntegrityException("Content package must be a JSON object")
} catch (error: ContentIntegrityException) {
throw error
} catch (error: IllegalStateException) {
throw ContentIntegrityException(error.message ?: "Content package JSON is invalid")
}
val errors = mutableListOf<String>()
val schemaVersion = root.int("schemaVersion", errors)
if (schemaVersion != 1) errors += "schemaVersion must be 1"
val contentVersion = root.text("contentVersion", errors)
val usage = root.obj("specialUsageTexts", errors)
val qianUsage = usage?.bool("qian", errors)
val kunUsage = usage?.bool("kun", errors)
val sourceNodes = root.arr("sources", errors)
val sources = mutableListOf<ContentSource>()
val sourceIds = mutableSetOf<String>()
sourceNodes?.forEachIndexed { index, node ->
val prefix = "sources[$index]"
val obj = node as? JsonValue.Obj
if (obj == null) {
errors += "$prefix must be an object"
return@forEachIndexed
}
val id = obj.text("id", errors, prefix)
val title = obj.text("title", errors, prefix)
val edition = obj.text("edition", errors, prefix)
val license = obj.text("license", errors, prefix)
val url = obj.text("url", errors, prefix)
if (id != null && !sourceIds.add(id)) errors += "$prefix.id must be unique"
if (url != null && !isHttpUrl(url)) errors += "$prefix.url must be an absolute HTTP(S) URL"
if (id != null && title != null && edition != null && license != null && url != null) {
sources += ContentSource(id, title, edition, license, url)
}
}
if (sourceNodes != null && sourceNodes.isEmpty()) {
errors += "sources must contain at least one licensed source"
}
val hexagramNodes = root.arr("hexagrams", errors)
if (hexagramNodes != null && hexagramNodes.size != 64) {
errors += "hexagrams must contain exactly 64 entries"
}
val hexagrams = mutableMapOf<Int, HexagramContent>()
val seenPatterns = mutableSetOf<String>()
hexagramNodes?.forEachIndexed { index, node ->
val prefix = "hexagrams[$index]"
val obj = node as? JsonValue.Obj
if (obj == null) {
errors += "$prefix must be an object"
return@forEachIndexed
}
val kingWenNumber = obj.int("kingWenNumber", errors, prefix)
val name = obj.text("name", errors, prefix)
val symbol = obj.text("symbol", errors, prefix)
val judgmentOriginal = obj.text("judgmentOriginal", errors, prefix)
val judgmentPlain = obj.text("judgmentPlain", errors, prefix)
val lineTexts = obj.sixTexts("lineTextsBottomUp", errors, prefix)
val linePlain = obj.sixTexts("linePlainBottomUp", errors, prefix)
val sourceRefs = obj.stringList("sourceRefs", errors, prefix)
val specialUsage = obj.optionalText("specialUsageText", errors, prefix)
val lower = obj.text("lowerTrigram", errors, prefix)
val upper = obj.text("upperTrigram", errors, prefix)
val patternTokens = obj.stringList("patternBottomUp", errors, prefix)
if (kingWenNumber != null && (kingWenNumber < 1 || kingWenNumber > 64)) {
errors += "$prefix.kingWenNumber must be an integer from 1 through 64"
}
if (kingWenNumber != null && hexagrams.containsKey(kingWenNumber)) {
errors += "$prefix.kingWenNumber must be unique"
}
if (kingWenNumber != null && name != null && name != HexagramNames.nameFor(kingWenNumber)) {
errors += "$prefix.name does not match the King Wen name table"
}
if (kingWenNumber != null && symbol != null && symbol != hexagramSymbol(kingWenNumber)) {
errors += "$prefix.symbol must be the King Wen unicode hexagram"
}
if (sourceRefs != null) {
if (sourceRefs.isEmpty()) errors += "$prefix.sourceRefs must not be empty"
if (sourceRefs.size != sourceRefs.distinct().size) {
errors += "$prefix.sourceRefs must be unique"
}
for (sourceRef in sourceRefs) {
if (sourceRef !in sourceIds) {
errors += "$prefix.sourceRefs contains unknown source '$sourceRef'"
}
}
}
if (patternTokens != null) {
if (patternTokens.size != 6 || patternTokens.any { it != "YIN" && it != "YANG" }) {
errors += "$prefix.patternBottomUp must contain exactly six YIN/YANG values"
} else {
val encoded = patternTokens.joinToString("/")
if (!seenPatterns.add(encoded)) errors += "$prefix.patternBottomUp must be unique"
val polarities = patternTokens.map { token ->
if (token == "YANG") Polarity.YANG else Polarity.YIN
}
val expectedId = HexagramCatalog.idFor(HexagramPattern.of(polarities)).value
if (kingWenNumber != null && kingWenNumber != expectedId) {
errors += "$prefix.kingWenNumber does not match its King Wen trigram pair"
}
val expectedPattern = expectedBottomUp(lower, upper)
if (expectedPattern != null && expectedPattern != patternTokens) {
errors += "$prefix.patternBottomUp does not match lower/upper trigrams in bottom-up order"
}
}
}
val hasSpecial = specialUsage != null
when (kingWenNumber) {
1 -> if (qianUsage != null && hasSpecial != qianUsage) {
errors += "$prefix.specialUsageText must match specialUsageTexts.qian"
}
2 -> if (kunUsage != null && hasSpecial != kunUsage) {
errors += "$prefix.specialUsageText must match specialUsageTexts.kun"
}
else -> if (hasSpecial) {
errors += "$prefix.specialUsageText is only valid for Qian or Kun"
}
}
if (
errors.none { it.startsWith(prefix) } &&
kingWenNumber != null &&
name != null &&
symbol != null &&
judgmentOriginal != null &&
judgmentPlain != null &&
lineTexts != null &&
linePlain != null &&
sourceRefs != null
) {
hexagrams[kingWenNumber] = HexagramContent(
kingWenNumber = kingWenNumber,
name = name,
symbol = symbol,
judgmentOriginal = judgmentOriginal,
judgmentPlain = judgmentPlain,
lineTextsBottomUp = lineTexts,
linePlainBottomUp = linePlain,
specialUsageText = specialUsage,
sourceRefs = sourceRefs,
)
}
}
if (hexagrams.size != 64 && errors.none { it.contains("exactly 64") }) {
errors += "King Wen numbers 1 through 64 must each occur exactly once"
}
if (errors.isNotEmpty()) {
throw ContentIntegrityException(errors.joinToString(separator = "\n"))
}
return ParsedHexagramContentPackage(
manifest = ContentManifest(
contentVersion = checkNotNull(contentVersion),
digest = jsonDigest(root),
sources = sources,
),
hexagrams = hexagrams,
)
}
private fun hexagramSymbol(kingWenNumber: Int): String =
(0x4DC0 + kingWenNumber - 1).toChar().toString()
private fun expectedBottomUp(lower: String?, upper: String?): List<String>? {
val lowerPattern = TRIGRAMS[lower] ?: return null
val upperPattern = TRIGRAMS[upper] ?: return null
return lowerPattern + upperPattern
}
private fun isHttpUrl(value: String): Boolean =
value.startsWith("https://") || value.startsWith("http://")
private fun JsonValue.Obj.int(key: String, errors: MutableList<String>, prefix: String = ""): Int? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
is JsonValue.Num -> value.value
null -> {
errors += "$path is missing"
null
}
else -> {
errors += "$path must be an integer"
null
}
}
}
private fun JsonValue.Obj.bool(key: String, errors: MutableList<String>, prefix: String = ""): Boolean? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
is JsonValue.Bool -> value.value
null -> {
errors += "$path is missing"
null
}
else -> {
errors += "$path must be a boolean"
null
}
}
}
private fun JsonValue.Obj.text(key: String, errors: MutableList<String>, prefix: String = ""): String? {
val path = fieldPath(prefix, key)
val value = optionalText(key, errors, prefix)
if (value == null && fields[key] !is JsonValue.Null) {
if (!fields.containsKey(key)) errors += "$path must be non-blank text"
} else if (value == null) {
errors += "$path must be non-blank text"
}
return value
}
private fun JsonValue.Obj.optionalText(
key: String,
errors: MutableList<String>,
prefix: String,
): String? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
JsonValue.Null, null -> null
is JsonValue.Str -> {
when {
value.value.isBlank() -> {
errors += "$path must be non-blank text or null"
null
}
unsafeText.containsMatchIn(value.value) -> {
errors += "$path contains script-like markup or an invisible control character"
null
}
else -> value.value
}
}
else -> {
errors += "$path must be non-blank text or null"
null
}
}
}
private fun JsonValue.Obj.obj(key: String, errors: MutableList<String>): JsonValue.Obj? {
return when (val value = fields[key]) {
is JsonValue.Obj -> value
else -> {
errors += "$key must be an object"
null
}
}
}
private fun JsonValue.Obj.arr(key: String, errors: MutableList<String>): List<JsonValue>? {
return when (val value = fields[key]) {
is JsonValue.Arr -> value.items
else -> {
errors += "$key must be an array"
null
}
}
}
private fun JsonValue.Obj.sixTexts(
key: String,
errors: MutableList<String>,
prefix: String,
): List<String>? {
val values = stringList(key, errors, prefix) ?: return null
if (values.size != 6) {
errors += "${fieldPath(prefix, key)} must contain exactly six bottom-up entries"
return null
}
return values
}
private fun JsonValue.Obj.stringList(
key: String,
errors: MutableList<String>,
prefix: String,
): List<String>? {
val path = fieldPath(prefix, key)
val array = fields[key] as? JsonValue.Arr
if (array == null) {
errors += "$path must be an array"
return null
}
val values = mutableListOf<String>()
array.items.forEachIndexed { index, item ->
val itemPath = "$path[$index]"
val text = (item as? JsonValue.Str)?.value
when {
text == null || text.isBlank() -> errors += "$itemPath must be non-blank text"
unsafeText.containsMatchIn(text) ->
errors += "$itemPath contains script-like markup or an invisible control character"
else -> values += text
}
}
return values.takeIf { it.size == array.items.size }
}
private fun fieldPath(prefix: String, key: String): String =
if (prefix.isEmpty()) key else "$prefix.$key"
private val TRIGRAMS = mapOf(
"QIAN" to listOf("YANG", "YANG", "YANG"),
"DUI" to listOf("YANG", "YANG", "YIN"),
"LI" to listOf("YANG", "YIN", "YANG"),
"ZHEN" to listOf("YANG", "YIN", "YIN"),
"XUN" to listOf("YIN", "YANG", "YANG"),
"KAN" to listOf("YIN", "YANG", "YIN"),
"GEN" to listOf("YIN", "YIN", "YANG"),
"KUN" to listOf("YIN", "YIN", "YIN"),
)
}
@@ -0,0 +1,16 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.HexagramContent
interface HexagramContentRepository {
val contentVersion: String
val manifest: ContentManifest
fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String = contentVersion,
): HexagramContent
}
class ContentIntegrityException(message: String) : IllegalStateException(message)
@@ -0,0 +1,202 @@
package net.opcapp.flash.data.content
import java.security.MessageDigest
internal sealed interface JsonValue {
data class Obj(val fields: Map<String, JsonValue>) : JsonValue
data class Arr(val items: List<JsonValue>) : JsonValue
data class Str(val value: String) : JsonValue
data class Num(val value: Int) : JsonValue
data class Bool(val value: Boolean) : JsonValue
data object Null : JsonValue
}
internal class JsonReader(private val text: String) {
private var index = 0
fun readValue(): JsonValue {
skipWhitespace()
val value = parseValue()
skipWhitespace()
if (index != text.length) {
error("Unexpected trailing content at index $index")
}
return value
}
private fun parseValue(): JsonValue {
skipWhitespace()
return when (val char = peek()) {
'{' -> parseObject()
'[' -> parseArray()
'"' -> JsonValue.Str(parseString())
't' -> parseLiteral("true", JsonValue.Bool(true))
'f' -> parseLiteral("false", JsonValue.Bool(false))
'n' -> parseLiteral("null", JsonValue.Null)
'-', in '0'..'9' -> parseNumber()
else -> error("Unexpected character '$char' at index $index")
}
}
private fun parseObject(): JsonValue.Obj {
expect('{')
skipWhitespace()
val fields = linkedMapOf<String, JsonValue>()
if (peek() == '}') {
index += 1
return JsonValue.Obj(fields)
}
while (true) {
skipWhitespace()
val key = parseString()
skipWhitespace()
expect(':')
skipWhitespace()
if (fields.containsKey(key)) error("Duplicate JSON key '$key'")
fields[key] = parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
'}' -> {
index += 1
return JsonValue.Obj(fields)
}
else -> error("Expected comma or object end at index $index")
}
}
}
private fun parseArray(): JsonValue.Arr {
expect('[')
skipWhitespace()
val items = mutableListOf<JsonValue>()
if (peek() == ']') {
index += 1
return JsonValue.Arr(items)
}
while (true) {
items += parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
']' -> {
index += 1
return JsonValue.Arr(items)
}
else -> error("Expected comma or array end at index $index")
}
}
}
private fun parseString(): String {
expect('"')
val builder = StringBuilder()
while (true) {
val char = next()
when (char) {
'"' -> return builder.toString()
'\\' -> builder.append(parseEscape())
in '\u0000'..'\u001F' -> error("Unescaped control character in string")
else -> builder.append(char)
}
}
}
private fun parseEscape(): Char {
return when (val char = next()) {
'"', '\\', '/' -> char
'b' -> '\b'
'f' -> '\u000C'
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
'u' -> {
val hex = CharArray(4) { next() }.concatToString()
hex.toIntOrNull(16)?.toChar() ?: error("Invalid unicode escape \\u$hex")
}
else -> error("Invalid string escape \\$char")
}
}
private fun parseNumber(): JsonValue.Num {
val start = index
if (peek() == '-') index += 1
if (peek() == '0') {
index += 1
} else {
if (peek() !in '1'..'9') error("Invalid number at index $start")
while (peek() in '0'..'9') index += 1
}
if (peek() == '.' || peek() == 'e' || peek() == 'E') {
error("Content JSON numbers must be integers")
}
return JsonValue.Num(text.substring(start, index).toInt())
}
private fun parseLiteral(literal: String, value: JsonValue): JsonValue {
if (!text.startsWith(literal, index)) error("Expected '$literal' at index $index")
index += literal.length
return value
}
private fun skipWhitespace() {
while (index < text.length && text[index] in WHITESPACE) index += 1
}
private fun peek(): Char {
if (index >= text.length) error("Unexpected end of JSON")
return text[index]
}
private fun next(): Char {
val char = peek()
index += 1
return char
}
private fun expect(char: Char) {
if (next() != char) error("Expected '$char' at index ${index - 1}")
}
companion object {
private val WHITESPACE = charArrayOf(' ', '\n', '\r', '\t')
}
}
internal fun jsonCanonicalize(value: JsonValue): String = when (value) {
JsonValue.Null -> "null"
is JsonValue.Bool -> value.value.toString()
is JsonValue.Num -> value.value.toString()
is JsonValue.Str -> jsonQuote(value.value)
is JsonValue.Arr -> value.items.joinToString(",", "[", "]") { jsonCanonicalize(it) }
is JsonValue.Obj -> value.fields.keys.sorted().joinToString(",", "{", "}") { key ->
"${jsonQuote(key)}:${jsonCanonicalize(value.fields.getValue(key))}"
}
}
internal fun jsonDigest(value: JsonValue): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(jsonCanonicalize(value).toByteArray(Charsets.UTF_8))
return digest.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
private fun jsonQuote(value: String): String {
val builder = StringBuilder("\"")
for (char in value) {
when (char) {
'"' -> builder.append("\\\"")
'\\' -> builder.append("\\\\")
'\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f")
'\n' -> builder.append("\\n")
'\r' -> builder.append("\\r")
'\t' -> builder.append("\\t")
else -> if (char.code < 0x20) {
builder.append("\\u%04x".format(char.code))
} else {
builder.append(char)
}
}
}
return builder.append('"').toString()
}
@@ -0,0 +1,19 @@
package net.opcapp.flash.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import net.opcapp.flash.data.content.AssetsHexagramContentRepository
import net.opcapp.flash.data.content.HexagramContentRepository
@Module
@InstallIn(SingletonComponent::class)
abstract class ContentBindingsModule {
@Binds
@Singleton
abstract fun bindHexagramContentRepository(
implementation: AssetsHexagramContentRepository,
): HexagramContentRepository
}
@@ -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,80 @@
package net.opcapp.flash.domain.casting
data class CastComputation internal constructor(
val roundsBottomUp: List<CastRound>,
val lineValuesBottomUp: List<LineValue>,
val primaryPatternBottomUp: HexagramPattern,
val movingLinePositions: List<Int>,
val transformedPatternBottomUp: HexagramPattern,
val primaryHexagramId: HexagramId,
val transformedHexagramId: HexagramId,
)
object CastEngine {
const val METHOD_VERSION = "coin-v1"
const val COIN_CONVENTION = "character=2;reverse=3;bottom-up"
fun cast(roundsBottomUp: List<CastRound>): CastComputation {
require(roundsBottomUp.size == HexagramPattern.LINE_COUNT) {
"A complete cast must contain exactly six rounds, but contained ${roundsBottomUp.size}"
}
val rounds = roundsBottomUp.toList()
val lineValues = rounds.map(CastRound::lineValue)
val primaryPattern = HexagramPattern.of(lineValues.map(LineValue::polarity))
val movingPositions = lineValues.mapIndexedNotNull { index, lineValue ->
if (lineValue.isMoving) index + 1 else null
}
val transformedPattern = HexagramPattern.of(lineValues.map(LineValue::transformedPolarity))
return CastComputation(
roundsBottomUp = rounds,
lineValuesBottomUp = lineValues,
primaryPatternBottomUp = primaryPattern,
movingLinePositions = movingPositions,
transformedPatternBottomUp = transformedPattern,
primaryHexagramId = HexagramCatalog.idFor(primaryPattern),
transformedHexagramId = HexagramCatalog.idFor(transformedPattern),
)
}
}
data class CastMetadata(
val contentVersion: String,
val createdAt: String,
) {
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(createdAt.isNotBlank()) { "Created-at metadata must not be blank" }
}
}
data class CastResult private constructor(
val methodVersion: String,
val coinConvention: String,
val roundsBottomUp: List<CastRound>,
val lineValuesBottomUp: List<LineValue>,
val primaryPatternBottomUp: HexagramPattern,
val movingLinePositions: List<Int>,
val transformedPatternBottomUp: HexagramPattern,
val primaryHexagramId: HexagramId,
val transformedHexagramId: HexagramId,
val contentVersion: String,
val createdAt: String,
) {
companion object {
fun record(computation: CastComputation, metadata: CastMetadata): CastResult =
CastResult(
methodVersion = CastEngine.METHOD_VERSION,
coinConvention = CastEngine.COIN_CONVENTION,
roundsBottomUp = computation.roundsBottomUp.toList(),
lineValuesBottomUp = computation.lineValuesBottomUp.toList(),
primaryPatternBottomUp = computation.primaryPatternBottomUp,
movingLinePositions = computation.movingLinePositions.toList(),
transformedPatternBottomUp = computation.transformedPatternBottomUp,
primaryHexagramId = computation.primaryHexagramId,
transformedHexagramId = computation.transformedHexagramId,
contentVersion = metadata.contentVersion,
createdAt = metadata.createdAt,
)
}
}
@@ -0,0 +1,75 @@
package net.opcapp.flash.domain.casting
data class CastRecordDto(
val schemaVersion: Int,
val methodVersion: String,
val coinConvention: String,
val roundsBottomUp: List<List<Int>>,
val lineValuesBottomUp: List<Int>,
val primaryPatternBottomUp: String,
val movingLinePositions: List<Int>,
val transformedPatternBottomUp: String,
val primaryHexagramId: Int,
val transformedHexagramId: Int,
val contentVersion: String,
val createdAt: String,
) {
fun toDomain(): CastResult {
require(schemaVersion == CURRENT_SCHEMA_VERSION) {
"Unsupported cast record schema version: $schemaVersion"
}
require(methodVersion == CastEngine.METHOD_VERSION) {
"Unsupported casting method version: $methodVersion"
}
require(coinConvention == CastEngine.COIN_CONVENTION) {
"Unsupported coin convention: $coinConvention"
}
val rounds = roundsBottomUp.map(CastRound::fromScores)
val result = CastResult.record(
computation = CastEngine.cast(rounds),
metadata = CastMetadata(contentVersion = contentVersion, createdAt = createdAt),
)
require(lineValuesBottomUp == result.lineValuesBottomUp.map(LineValue::score)) {
"Stored line values do not match the preserved coin rounds"
}
require(primaryPatternBottomUp == result.primaryPatternBottomUp.encoded()) {
"Stored primary pattern does not match the preserved coin rounds"
}
require(movingLinePositions == result.movingLinePositions) {
"Stored moving-line positions do not match the preserved coin rounds"
}
require(transformedPatternBottomUp == result.transformedPatternBottomUp.encoded()) {
"Stored transformed pattern does not match the preserved coin rounds"
}
require(primaryHexagramId == result.primaryHexagramId.value) {
"Stored primary hexagram id does not match the King Wen lookup"
}
require(transformedHexagramId == result.transformedHexagramId.value) {
"Stored transformed hexagram id does not match the King Wen lookup"
}
return result
}
companion object {
const val CURRENT_SCHEMA_VERSION = 1
fun fromDomain(result: CastResult): CastRecordDto =
CastRecordDto(
schemaVersion = CURRENT_SCHEMA_VERSION,
methodVersion = result.methodVersion,
coinConvention = result.coinConvention,
roundsBottomUp = result.roundsBottomUp.map { round -> round.coins.map(CoinSide::score) },
lineValuesBottomUp = result.lineValuesBottomUp.map(LineValue::score),
primaryPatternBottomUp = result.primaryPatternBottomUp.encoded(),
movingLinePositions = result.movingLinePositions.toList(),
transformedPatternBottomUp = result.transformedPatternBottomUp.encoded(),
primaryHexagramId = result.primaryHexagramId.value,
transformedHexagramId = result.transformedHexagramId.value,
contentVersion = result.contentVersion,
createdAt = result.createdAt,
)
}
}
@@ -0,0 +1,104 @@
package net.opcapp.flash.domain.casting
enum class CoinSide(val score: Int) {
CHARACTER(2),
REVERSE(3),
;
companion object {
fun fromScore(score: Int): CoinSide =
entries.singleOrNull { it.score == score }
?: throw IllegalArgumentException("Coin score must be 2 or 3, but was $score")
}
}
enum class Polarity {
YIN,
YANG,
;
fun opposite(): Polarity = if (this == YIN) YANG else YIN
}
enum class LineValue(
val score: Int,
val polarity: Polarity,
val isMoving: Boolean,
) {
OLD_YIN(6, Polarity.YIN, true),
YOUNG_YANG(7, Polarity.YANG, false),
YOUNG_YIN(8, Polarity.YIN, false),
OLD_YANG(9, Polarity.YANG, true),
;
val transformedPolarity: Polarity
get() = if (isMoving) polarity.opposite() else polarity
companion object {
fun fromScore(score: Int): LineValue =
entries.singleOrNull { it.score == score }
?: throw IllegalArgumentException("Line score must be between 6 and 9, but was $score")
}
}
data class CastRound private constructor(val coins: List<CoinSide>) {
init {
require(coins.size == COINS_PER_ROUND) {
"Each cast round must contain exactly $COINS_PER_ROUND coins, but contained ${coins.size}"
}
}
val lineValue: LineValue
get() = LineValue.fromScore(coins.sumOf(CoinSide::score))
companion object {
const val COINS_PER_ROUND = 3
fun of(coins: List<CoinSide>): CastRound = CastRound(coins.toList())
fun of(first: CoinSide, second: CoinSide, third: CoinSide): CastRound =
of(listOf(first, second, third))
fun fromScores(scores: List<Int>): CastRound = of(scores.map(CoinSide::fromScore))
fun fromLineValue(lineValue: LineValue): CastRound =
when (lineValue) {
LineValue.OLD_YIN -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.CHARACTER)
LineValue.YOUNG_YANG -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.REVERSE)
LineValue.YOUNG_YIN -> of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.REVERSE)
LineValue.OLD_YANG -> of(CoinSide.REVERSE, CoinSide.REVERSE, CoinSide.REVERSE)
}
}
}
data class HexagramPattern private constructor(val linesBottomUp: List<Polarity>) {
init {
require(linesBottomUp.size == LINE_COUNT) {
"A hexagram must contain exactly $LINE_COUNT lines, but contained ${linesBottomUp.size}"
}
}
fun encoded(): String = linesBottomUp.joinToString(separator = "") {
if (it == Polarity.YANG) "1" else "0"
}
companion object {
const val LINE_COUNT = 6
fun of(linesBottomUp: List<Polarity>): HexagramPattern =
HexagramPattern(linesBottomUp.toList())
fun decode(encoded: String): HexagramPattern {
require(encoded.length == LINE_COUNT && encoded.all { it == '0' || it == '1' }) {
"Encoded hexagram must contain exactly six 0/1 characters"
}
return of(encoded.map { if (it == '1') Polarity.YANG else Polarity.YIN })
}
}
}
@JvmInline
value class HexagramId(val value: Int) {
init {
require(value in 1..64) { "King Wen hexagram id must be between 1 and 64" }
}
}
@@ -0,0 +1,104 @@
package net.opcapp.flash.domain.casting
object HexagramCatalog {
private enum class Trigram(val patternBottomUp: String) {
QIAN("111"),
DUI("110"),
LI("101"),
ZHEN("100"),
XUN("011"),
KAN("010"),
GEN("001"),
KUN("000"),
;
companion object {
fun fromPattern(patternBottomUp: String): Trigram =
entries.singleOrNull { it.patternBottomUp == patternBottomUp }
?: error("Unknown trigram pattern: $patternBottomUp")
}
}
private data class TrigramPair(val upper: Trigram, val lower: Trigram)
private val kingWenIds = mapOf(
TrigramPair(Trigram.QIAN, Trigram.QIAN) to 1,
TrigramPair(Trigram.KUN, Trigram.KUN) to 2,
TrigramPair(Trigram.KAN, Trigram.ZHEN) to 3,
TrigramPair(Trigram.GEN, Trigram.KAN) to 4,
TrigramPair(Trigram.KAN, Trigram.QIAN) to 5,
TrigramPair(Trigram.QIAN, Trigram.KAN) to 6,
TrigramPair(Trigram.KUN, Trigram.KAN) to 7,
TrigramPair(Trigram.KAN, Trigram.KUN) to 8,
TrigramPair(Trigram.XUN, Trigram.QIAN) to 9,
TrigramPair(Trigram.QIAN, Trigram.DUI) to 10,
TrigramPair(Trigram.KUN, Trigram.QIAN) to 11,
TrigramPair(Trigram.QIAN, Trigram.KUN) to 12,
TrigramPair(Trigram.QIAN, Trigram.LI) to 13,
TrigramPair(Trigram.LI, Trigram.QIAN) to 14,
TrigramPair(Trigram.KUN, Trigram.GEN) to 15,
TrigramPair(Trigram.ZHEN, Trigram.KUN) to 16,
TrigramPair(Trigram.DUI, Trigram.ZHEN) to 17,
TrigramPair(Trigram.GEN, Trigram.XUN) to 18,
TrigramPair(Trigram.KUN, Trigram.DUI) to 19,
TrigramPair(Trigram.XUN, Trigram.KUN) to 20,
TrigramPair(Trigram.LI, Trigram.ZHEN) to 21,
TrigramPair(Trigram.GEN, Trigram.LI) to 22,
TrigramPair(Trigram.GEN, Trigram.KUN) to 23,
TrigramPair(Trigram.KUN, Trigram.ZHEN) to 24,
TrigramPair(Trigram.QIAN, Trigram.ZHEN) to 25,
TrigramPair(Trigram.GEN, Trigram.QIAN) to 26,
TrigramPair(Trigram.GEN, Trigram.ZHEN) to 27,
TrigramPair(Trigram.DUI, Trigram.XUN) to 28,
TrigramPair(Trigram.KAN, Trigram.KAN) to 29,
TrigramPair(Trigram.LI, Trigram.LI) to 30,
TrigramPair(Trigram.DUI, Trigram.GEN) to 31,
TrigramPair(Trigram.ZHEN, Trigram.XUN) to 32,
TrigramPair(Trigram.QIAN, Trigram.GEN) to 33,
TrigramPair(Trigram.ZHEN, Trigram.QIAN) to 34,
TrigramPair(Trigram.LI, Trigram.KUN) to 35,
TrigramPair(Trigram.KUN, Trigram.LI) to 36,
TrigramPair(Trigram.XUN, Trigram.LI) to 37,
TrigramPair(Trigram.LI, Trigram.DUI) to 38,
TrigramPair(Trigram.KAN, Trigram.GEN) to 39,
TrigramPair(Trigram.ZHEN, Trigram.KAN) to 40,
TrigramPair(Trigram.GEN, Trigram.DUI) to 41,
TrigramPair(Trigram.XUN, Trigram.ZHEN) to 42,
TrigramPair(Trigram.DUI, Trigram.QIAN) to 43,
TrigramPair(Trigram.QIAN, Trigram.XUN) to 44,
TrigramPair(Trigram.DUI, Trigram.KUN) to 45,
TrigramPair(Trigram.KUN, Trigram.XUN) to 46,
TrigramPair(Trigram.DUI, Trigram.KAN) to 47,
TrigramPair(Trigram.KAN, Trigram.XUN) to 48,
TrigramPair(Trigram.DUI, Trigram.LI) to 49,
TrigramPair(Trigram.LI, Trigram.XUN) to 50,
TrigramPair(Trigram.ZHEN, Trigram.ZHEN) to 51,
TrigramPair(Trigram.GEN, Trigram.GEN) to 52,
TrigramPair(Trigram.XUN, Trigram.GEN) to 53,
TrigramPair(Trigram.ZHEN, Trigram.DUI) to 54,
TrigramPair(Trigram.ZHEN, Trigram.LI) to 55,
TrigramPair(Trigram.LI, Trigram.GEN) to 56,
TrigramPair(Trigram.XUN, Trigram.XUN) to 57,
TrigramPair(Trigram.DUI, Trigram.DUI) to 58,
TrigramPair(Trigram.XUN, Trigram.KAN) to 59,
TrigramPair(Trigram.KAN, Trigram.DUI) to 60,
TrigramPair(Trigram.XUN, Trigram.DUI) to 61,
TrigramPair(Trigram.ZHEN, Trigram.GEN) to 62,
TrigramPair(Trigram.KAN, Trigram.LI) to 63,
TrigramPair(Trigram.LI, Trigram.KAN) to 64,
)
init {
check(kingWenIds.size == 64) { "King Wen table must contain all 64 trigram pairs" }
check(kingWenIds.values.toSet() == (1..64).toSet()) {
"King Wen table must contain every id from 1 through 64 exactly once"
}
}
fun idFor(pattern: HexagramPattern): HexagramId {
val encoded = pattern.encoded()
val lower = Trigram.fromPattern(encoded.substring(0, 3))
val upper = Trigram.fromPattern(encoded.substring(3, 6))
return HexagramId(kingWenIds.getValue(TrigramPair(upper, lower)))
}
}
@@ -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,373 @@
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.core.model.HexagramContent
import net.opcapp.flash.data.content.ContentIntegrityException
import net.opcapp.flash.data.content.HexagramContentRepository
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
import net.opcapp.flash.domain.casting.LineValue
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 primaryContent: HexagramContent? = null,
val transformedContent: HexagramContent? = null,
val contentError: String? = null,
val showSpecialUsage: Boolean = false,
) {
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,
private val contentRepository: HexagramContentRepository,
) : 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 restoredContent = resolveContent(restoredResult)
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
},
primaryContent = restoredContent.primary,
transformedContent = restoredContent.transformed,
contentError = restoredContent.error,
showSpecialUsage = restoredContent.showSpecialUsage,
),
)
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)
val content = resolveContent(result)
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,
primaryContent = content.primary,
transformedContent = content.transformed,
contentError = content.error,
showSpecialUsage = content.showSpecialUsage,
)
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 = contentRepository.contentVersion,
createdAt = createdAt,
),
)
private fun resolveContent(result: CastResult?): ResolvedContent {
if (result == null) return ResolvedContent()
val showSpecialUsage = when (result.primaryHexagramId.value) {
1 -> result.lineValuesBottomUp.all { it == LineValue.OLD_YANG }
2 -> result.lineValuesBottomUp.all { it == LineValue.OLD_YIN }
else -> false
}
return try {
val primary = contentRepository.contentFor(
result.primaryHexagramId.value,
result.contentVersion,
)
val transformed = if (result.transformedHexagramId == result.primaryHexagramId) {
primary
} else {
contentRepository.contentFor(
result.transformedHexagramId.value,
result.contentVersion,
)
}
ResolvedContent(
primary = primary,
transformed = transformed,
showSpecialUsage = showSpecialUsage,
)
} catch (error: ContentIntegrityException) {
ResolvedContent(
error = error.message,
showSpecialUsage = showSpecialUsage,
)
}
}
private data class ResolvedContent(
val primary: HexagramContent? = null,
val transformed: HexagramContent? = null,
val error: String? = null,
val showSpecialUsage: Boolean = false,
)
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"
}
}
@@ -0,0 +1,121 @@
package net.opcapp.flash.feature.content
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.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.res.stringResource
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
import net.opcapp.flash.core.model.ContentManifest
@Composable
fun ContentSourcesScreen(
manifest: ContentManifest,
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.content_sources_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.content_sources_lede),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.content_version_label, manifest.contentVersion),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.content_digest_label, manifest.digest),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (manifest.loadError != null) {
Text(
text = stringResource(R.string.content_integrity_error),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
)
}
manifest.sources.forEach { source ->
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 = source.title, style = MaterialTheme.typography.titleLarge)
Text(
text = stringResource(R.string.content_source_edition, source.edition),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.content_source_license, source.license),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.content_source_url, source.url),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
text = stringResource(R.string.content_review_pending),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,223 @@
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,
onOpenContentSources: () -> 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))
}
TextButton(
onClick = onOpenContentSources,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.open_content_sources))
}
}
}
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,64 @@
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.core.model.ContentManifest
import net.opcapp.flash.data.content.HexagramContentRepository
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,
val contentManifest: ContentManifest = ContentManifest(
contentVersion = "unavailable",
digest = "unavailable",
sources = emptyList(),
),
)
@HiltViewModel
class HomeViewModel @Inject constructor(
historyRepository: HistoryRepository,
private val settingsRepository: HistorySettingsRepository,
contentRepository: HexagramContentRepository,
) : ViewModel() {
private val contentManifest = contentRepository.manifest
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,
contentManifest = contentManifest,
)
}.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,572 @@
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.HexagramContent
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,
primaryContent = uiState.primaryContent,
transformedContent = uiState.transformedContent,
contentError = uiState.contentError,
showSpecialUsage = uiState.showSpecialUsage,
onSave = onSave,
onDelete = onDelete,
onRetry = onRetry,
onStartAgain = onStartAgain,
)
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun ResultContent(
result: CastResult,
saveStatus: HistorySaveStatus,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
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)
HexagramTextsCard(
result = result,
primaryContent = primaryContent,
transformedContent = transformedContent,
contentError = contentError,
showSpecialUsage = showSpecialUsage,
)
SaveStatusCard(
status = saveStatus,
onSave = onSave,
onDelete = onDelete,
onRetry = onRetry,
)
Text(
text = stringResource(R.string.content_plain_draft_notice),
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 HexagramTextsCard(
result: CastResult,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
) {
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),
) {
if (contentError != null || primaryContent == null) {
Text(
text = stringResource(R.string.content_integrity_error),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
)
return@Column
}
LabeledPassage(
title = stringResource(R.string.primary_judgment_title),
original = primaryContent.judgmentOriginal,
plain = primaryContent.judgmentPlain,
)
if (result.movingLinePositions.isNotEmpty()) {
Text(
text = stringResource(R.string.moving_line_texts_title),
style = MaterialTheme.typography.titleLarge,
)
result.movingLinePositions.forEach { position ->
LabeledPassage(
title = linePositionName(position),
original = primaryContent.lineTextsBottomUp[position - 1],
plain = primaryContent.linePlainBottomUp[position - 1],
)
}
}
if (
transformedContent != null &&
result.transformedHexagramId != result.primaryHexagramId
) {
LabeledPassage(
title = stringResource(R.string.transformed_judgment_title),
original = transformedContent.judgmentOriginal,
plain = transformedContent.judgmentPlain,
)
}
if (showSpecialUsage && primaryContent.specialUsageText != null) {
LabeledPassage(
title = stringResource(R.string.special_usage_title),
original = primaryContent.specialUsageText,
plain = null,
)
}
}
}
}
@Composable
private fun LabeledPassage(
title: String,
original: String,
plain: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.content_original_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = original,
style = MaterialTheme.typography.bodyLarge,
)
if (plain != null) {
Text(
text = stringResource(R.string.content_plain_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = plain,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
@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),
)
}
}
}
}
@@ -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() }
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Lingji" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:statusBarColor">#1E1B18</item>
<item name="android:navigationBarColor">#1E1B18</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+129
View File
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">灵机</string>
<string name="home_kicker">三枚铜币 · 六爻由下而上</string>
<string name="home_lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</string>
<string name="seal_character">易</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="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_original_label">原文</string>
<string name="content_plain_label">本地白话</string>
<string name="content_plain_draft_notice">白话为项目自撰草稿,供本机阅读;内容负责人审校前不视为可再分发。</string>
<string name="primary_judgment_title">本卦卦辞</string>
<string name="transformed_judgment_title">之卦卦辞</string>
<string name="moving_line_texts_title">动爻爻辞</string>
<string name="special_usage_title">用九 / 用六</string>
<string name="content_integrity_error">本地内容包未能通过完整性检查,卦辞暂不可读。卦号、卦名和六爻事实仍可复核。</string>
<string name="open_content_sources">内容来源</string>
<string name="content_sources_title">内容来源</string>
<string name="content_sources_lede">应用只使用已写入安装包的本地文本。原文来自可核验公版《易经》;白话由本项目撰写,审校完成前标为草稿。</string>
<string name="content_version_label">内容版本:%1$s</string>
<string name="content_digest_label">校验摘要:%1$s</string>
<string name="content_source_license">许可:%1$s</string>
<string name="content_source_edition">版本:%1$s</string>
<string name="content_source_url">来源:%1$s</string>
<string name="content_review_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>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Lingji" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:statusBarColor">#F5F0E6</item>
<item name="android:navigationBarColor">#F5F0E6</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -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,41 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.ContentSource
import net.opcapp.flash.core.model.HexagramContent
class FakeHexagramContentRepository(
override val contentVersion: String = "fixture-only-v1",
entries: List<HexagramContent>,
sources: List<ContentSource> = emptyList(),
digest: String = "fixture-digest",
) : HexagramContentRepository {
private val entriesById: Map<Int, HexagramContent>
override val manifest: ContentManifest = ContentManifest(
contentVersion = contentVersion,
digest = digest,
sources = sources,
)
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
"Fake content entries must have unique King Wen numbers"
}
entriesById = entries.associateBy(HexagramContent::kingWenNumber)
}
override fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String,
): HexagramContent {
if (requestedContentVersion != contentVersion) {
throw ContentIntegrityException(
"Requested content version '$requestedContentVersion' is unavailable; loaded '$contentVersion'",
)
}
return entriesById[kingWenNumber]
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
}
}
@@ -0,0 +1,39 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.HexagramNames
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class HexagramContentParserTest {
@Test
fun publishedPackageMatchesKingWenNamesAndSpecialUsage() {
val json = checkNotNull(javaClass.getResource("/hexagram-content.json")).readText()
val pack = HexagramContentParser.parse(json)
assertEquals("zh-Hans-2026.1", pack.manifest.contentVersion)
assertEquals(64, pack.hexagrams.size)
assertTrue(pack.manifest.digest.matches(Regex("[a-f0-9]{64}")))
assertEquals("复", pack.hexagrams.getValue(24).name)
assertEquals(HexagramNames.nameFor(24), pack.hexagrams.getValue(24).name)
assertEquals("䷗", pack.hexagrams.getValue(24).symbol)
assertTrue(pack.hexagrams.getValue(1).specialUsageText.orEmpty().contains("用九"))
assertTrue(pack.hexagrams.getValue(2).specialUsageText.orEmpty().contains("用六"))
assertNotNull(pack.manifest.sources.singleOrNull { it.id.contains("wikisource") })
assertNotNull(pack.manifest.sources.singleOrNull { it.id.contains("lingji-plain") })
}
@Test
fun parserRejectsWrongKingWenName() {
val json = checkNotNull(javaClass.getResource("/hexagram-content.json")).readText()
.replace("\"name\": \"复\"", "\"name\": \"錯\"")
try {
HexagramContentParser.parse(json)
fail("Expected content integrity failure")
} catch (error: ContentIntegrityException) {
assertTrue(error.message.orEmpty().contains("name"))
}
}
}
@@ -0,0 +1,59 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.HexagramContent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class HexagramContentRepositoryTest {
@Test
fun `fake returns content only for its exact version and id`() {
val qian = fixture(1)
val repository = FakeHexagramContentRepository(entries = listOf(qian))
assertEquals(qian, repository.contentFor(1))
assertFails("unavailable") { repository.contentFor(1, "another-version") }
assertFails("No validated content") { repository.contentFor(2) }
}
@Test
fun `fake rejects duplicate ids instead of silently overwriting`() {
assertFails("unique King Wen numbers") {
FakeHexagramContentRepository(entries = listOf(fixture(1), fixture(1)))
}
}
@Test
fun `content model enforces six bottom-up texts and sources`() {
assertFails("six non-blank") {
fixture(1).copy(lineTextsBottomUp = List(5) { "fixture" })
}
assertFails("at least one") {
fixture(1).copy(sourceRefs = emptyList())
}
}
private fun fixture(id: Int) = HexagramContent(
kingWenNumber = id,
name = "fixture-$id",
symbol = "fixture-symbol-$id",
judgmentOriginal = "fixture original",
judgmentPlain = "fixture plain",
lineTextsBottomUp = List(6) { index -> "fixture original line ${index + 1}" },
linePlainBottomUp = List(6) { index -> "fixture plain line ${index + 1}" },
specialUsageText = null,
sourceRefs = listOf("fixture-source"),
)
private fun assertFails(messageFragment: String, block: () -> Unit) {
try {
block()
fail("Expected failure containing '$messageFragment'")
} catch (error: IllegalArgumentException) {
assertTrue(error.message.orEmpty().contains(messageFragment))
} catch (error: ContentIntegrityException) {
assertTrue(error.message.orEmpty().contains(messageFragment))
}
}
}
@@ -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,200 @@
package net.opcapp.flash.domain.casting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class CastEngineTest {
@Test
fun `all eight coin combinations map to the correct line values`() {
val counts = mutableMapOf<LineValue, Int>()
for (encoded in 0 until 8) {
val coins = List(3) { index ->
if (encoded and (1 shl index) == 0) CoinSide.CHARACTER else CoinSide.REVERSE
}
val expected = LineValue.fromScore(coins.sumOf(CoinSide::score))
val actual = CastRound.of(coins).lineValue
assertEquals(expected, actual)
counts[actual] = counts.getOrDefault(actual, 0) + 1
}
assertEquals(
mapOf(
LineValue.OLD_YIN to 1,
LineValue.YOUNG_YANG to 3,
LineValue.YOUNG_YIN to 3,
LineValue.OLD_YANG to 1,
),
counts,
)
}
@Test
fun `line values expose correct polarity movement and transformation`() {
assertLine(LineValue.OLD_YIN, Polarity.YIN, moving = true, Polarity.YANG)
assertLine(LineValue.YOUNG_YANG, Polarity.YANG, moving = false, Polarity.YANG)
assertLine(LineValue.YOUNG_YIN, Polarity.YIN, moving = false, Polarity.YIN)
assertLine(LineValue.OLD_YANG, Polarity.YANG, moving = true, Polarity.YIN)
}
@Test
fun `a round must preserve exactly three coins`() {
assertFails("exactly 3 coins") { CastRound.of(emptyList()) }
assertFails("exactly 3 coins") {
CastRound.of(List(4) { CoinSide.CHARACTER })
}
assertEquals(
listOf(2, 3, 2),
CastRound.of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.CHARACTER)
.coins
.map(CoinSide::score),
)
}
@Test
fun `a cast requires exactly six rounds`() {
assertFails("exactly six rounds") {
CastEngine.cast(List(5) { roundFor(LineValue.YOUNG_YANG) })
}
assertFails("exactly six rounds") {
CastEngine.cast(List(7) { roundFor(LineValue.YOUNG_YANG) })
}
}
@Test
fun `documented fixtures preserve bottom-up order and King Wen ids`() {
assertFixture(listOf(7, 7, 7, 7, 7, 7), primary = 1, transformed = 1, moving = emptyList())
assertFixture(listOf(8, 8, 8, 8, 8, 8), primary = 2, transformed = 2, moving = emptyList())
assertFixture(listOf(9, 9, 9, 9, 9, 9), primary = 1, transformed = 2, moving = (1..6).toList())
assertFixture(listOf(6, 6, 6, 6, 6, 6), primary = 2, transformed = 1, moving = (1..6).toList())
assertFixture(listOf(7, 8, 8, 8, 8, 8), primary = 24, transformed = 24, moving = emptyList())
assertFixture(listOf(9, 8, 8, 8, 8, 8), primary = 24, transformed = 2, moving = listOf(1))
}
@Test
fun `all 4096 line sequences compute consistently`() {
val primaryIds = mutableSetOf<Int>()
for (encoded in 0 until 4096) {
val lineValues = List(6) { position ->
LineValue.entries[(encoded shr (position * 2)) and 0b11]
}
val result = CastEngine.cast(lineValues.map(::roundFor))
assertEquals(lineValues, result.lineValuesBottomUp)
assertEquals(lineValues.map(LineValue::polarity), result.primaryPatternBottomUp.linesBottomUp)
assertEquals(
lineValues.map(LineValue::transformedPolarity),
result.transformedPatternBottomUp.linesBottomUp,
)
assertEquals(
lineValues.mapIndexedNotNull { index, line -> if (line.isMoving) index + 1 else null },
result.movingLinePositions,
)
assertTrue(result.primaryHexagramId.value in 1..64)
assertTrue(result.transformedHexagramId.value in 1..64)
primaryIds += result.primaryHexagramId.value
}
assertEquals((1..64).toSet(), primaryIds)
}
@Test
fun `all 64 polarity patterns map one-to-one to King Wen ids`() {
val ids = (0 until 64).map { encoded ->
val pattern = HexagramPattern.of(
List(6) { position ->
if (encoded and (1 shl position) == 0) Polarity.YIN else Polarity.YANG
},
)
HexagramCatalog.idFor(pattern).value
}
assertEquals(64, ids.toSet().size)
assertEquals((1..64).toSet(), ids.toSet())
}
@Test
fun `record metadata is separate from deterministic computation`() {
val rounds = listOf(9, 8, 8, 8, 8, 8).map(::roundFor)
val computation = CastEngine.cast(rounds)
val first = CastResult.record(computation, CastMetadata("content-test", "2026-01-01T00:00:00Z"))
val second = CastResult.record(computation, CastMetadata("content-test", "2026-02-01T00:00:00Z"))
assertNotEquals(first.createdAt, second.createdAt)
assertEquals(first.roundsBottomUp, second.roundsBottomUp)
assertEquals(first.lineValuesBottomUp, second.lineValuesBottomUp)
assertEquals(first.primaryHexagramId, second.primaryHexagramId)
assertEquals(first.transformedHexagramId, second.transformedHexagramId)
}
@Test
fun `versioned dto round-trips all preserved coins and derived values`() {
val computation = CastEngine.cast(listOf(6, 7, 8, 9, 7, 8).map(::roundFor))
val original = CastResult.record(
computation,
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
)
val restored = CastRecordDto.fromDomain(original).toDomain()
assertEquals(original, restored)
assertEquals(18, restored.roundsBottomUp.sumOf { it.coins.size })
}
@Test
fun `versioned dto rejects corrupted derived values`() {
val original = CastResult.record(
CastEngine.cast(List(6) { roundFor(LineValue.YOUNG_YANG) }),
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
)
val corrupted = CastRecordDto.fromDomain(original).copy(primaryHexagramId = 2)
assertFails("does not match") { corrupted.toDomain() }
}
private fun assertFixture(
scoresBottomUp: List<Int>,
primary: Int,
transformed: Int,
moving: List<Int>,
) {
val result = CastEngine.cast(scoresBottomUp.map(::roundFor))
assertEquals(primary, result.primaryHexagramId.value)
assertEquals(transformed, result.transformedHexagramId.value)
assertEquals(moving, result.movingLinePositions)
assertEquals(scoresBottomUp, result.lineValuesBottomUp.map(LineValue::score))
}
private fun assertLine(
lineValue: LineValue,
polarity: Polarity,
moving: Boolean,
transformed: Polarity,
) {
assertEquals(polarity, lineValue.polarity)
assertEquals(moving, lineValue.isMoving)
assertEquals(transformed, lineValue.transformedPolarity)
if (moving) assertNotEquals(polarity, transformed) else assertEquals(polarity, transformed)
}
private fun roundFor(score: Int): CastRound = roundFor(LineValue.fromScore(score))
private fun roundFor(lineValue: LineValue): CastRound = CastRound.fromLineValue(lineValue)
private fun assertFails(messageFragment: String, block: () -> Unit) {
try {
block()
fail("Expected IllegalArgumentException containing '$messageFragment'")
} catch (error: IllegalArgumentException) {
assertTrue(
"Expected '${error.message}' to contain '$messageFragment'",
error.message.orEmpty().contains(messageFragment),
)
}
}
}
@@ -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("𠀀"))
}
}
+91
View File
@@ -0,0 +1,91 @@
import org.gradle.api.tasks.Exec
plugins {
base
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt) apply false
}
fun registerNodeVerificationTask(
name: String,
description: String,
script: String,
) = tasks.register<Exec>(name) {
group = LifecycleBasePlugin.VERIFICATION_GROUP
this.description = description
workingDir(rootDir)
commandLine("node", script)
}
val verifyDocs = registerNodeVerificationTask(
name = "verifyDocs",
description = "Checks local Markdown links and required harness documents.",
script = "scripts/verify-docs.mjs",
)
val scanSecrets = registerNodeVerificationTask(
name = "scanSecrets",
description = "Scans repository text files for high-confidence secret patterns.",
script = "scripts/scan-secrets.mjs",
)
val verifyDomainBoundaries = registerNodeVerificationTask(
name = "verifyDomainBoundaries",
description = "Prevents Android, persistence, and network imports in the domain core.",
script = "scripts/verify-domain-boundaries.mjs",
)
val verifyPrototype = registerNodeVerificationTask(
name = "verifyPrototype",
description = "Runs dependency-free syntax checks for the HTML prototype harness.",
script = "scripts/verify-prototype.mjs",
)
val verifyContentContract = registerNodeVerificationTask(
name = "verifyContentContract",
description = "Validates the versioned local-content contract and its negative fixtures.",
script = "scripts/verify-content-contract.mjs",
)
val spotlessCheck = registerNodeVerificationTask(
name = "spotlessCheck",
description = "Runs the dependency-free repository formatting gate.",
script = "scripts/verify-format.mjs",
)
val verifyDependencyLicenseManifest = registerNodeVerificationTask(
name = "verifyDependencyLicenseManifest",
description = "Checks the reviewed dependency-license manifest against pinned build versions.",
script = "scripts/verify-dependency-licenses.mjs",
)
val verifyLocalDataBoundary = registerNodeVerificationTask(
name = "verifyLocalDataBoundary",
description = "Locks down offline permissions and excludes local records from backup and transfer.",
script = "scripts/verify-local-data-boundary.mjs",
)
tasks.register("verifyLocal") {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs every environment-independent local quality gate."
dependsOn(
":app:lintDebug",
":app:testDebugUnitTest",
":app:assembleDebug",
":app:verifyDependencyLicenses",
verifyDocs,
scanSecrets,
verifyDomainBoundaries,
verifyPrototype,
verifyContentContract,
spotlessCheck,
verifyDependencyLicenseManifest,
verifyLocalDataBoundary,
)
}
tasks.named("check") {
dependsOn("verifyLocal")
}
+197
View File
@@ -0,0 +1,197 @@
{
"schemaVersion": 1,
"reviewedAt": "2026-08-19",
"scopeNote": "Android debug runtime, debug unit-test runtime, and debug instrumentation-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.",
"components": [
{
"id": "gradle-wrapper",
"coordinates": null,
"version": "8.2",
"scope": "build",
"license": "Apache-2.0",
"source": "https://github.com/gradle/gradle/blob/v8.2.0/LICENSE"
},
{
"id": "android-gradle-plugin",
"coordinates": null,
"version": "8.2.0",
"scope": "build",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/tools/base/+/studio-2022.2.1-patch4/LICENSE"
},
{
"id": "kotlin-gradle-plugin",
"coordinates": null,
"version": "1.9.20",
"scope": "build",
"license": "Apache-2.0",
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
},
{
"id": "hilt-gradle-plugin",
"coordinates": null,
"version": "2.48.1",
"scope": "build",
"license": "Apache-2.0",
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
},
{
"id": "compose-bom",
"coordinates": "androidx.compose:compose-bom:2023.10.01",
"version": "2023.10.01",
"scope": "runtime-constraints",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "navigation-compose",
"coordinates": "androidx.navigation:navigation-compose:2.7.5",
"version": "2.7.5",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "room-runtime",
"coordinates": "androidx.room:room-runtime:2.6.1",
"version": "2.6.1",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "datastore-preferences",
"coordinates": "androidx.datastore:datastore-preferences:1.0.0",
"version": "1.0.0",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "hilt-android",
"coordinates": "com.google.dagger:hilt-android:2.48.1",
"version": "2.48.1",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
},
{
"id": "kotlin-stdlib",
"coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20",
"version": "1.9.20",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
},
{
"id": "jetbrains-annotations",
"coordinates": "org.jetbrains:annotations:23.0.0",
"version": "23.0.0",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://repo1.maven.org/maven2/org/jetbrains/annotations/23.0.0/annotations-23.0.0.pom"
},
{
"id": "jsr305",
"coordinates": "com.google.code.findbugs:jsr305:3.0.2",
"version": "3.0.2",
"scope": "runtime-transitive",
"license": "BSD-3-Clause",
"source": "https://repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.pom"
},
{
"id": "guava-listenablefuture",
"coordinates": "com.google.guava:listenablefuture:1.0",
"version": "1.0",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://repo1.maven.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.pom"
},
{
"id": "javax-inject",
"coordinates": "javax.inject:javax.inject:1",
"version": "1",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.pom"
},
{
"id": "junit4",
"coordinates": "junit:junit:4.13.2",
"version": "4.13.2",
"scope": "test",
"license": "EPL-1.0",
"source": "https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt"
},
{
"id": "hamcrest-core",
"coordinates": "org.hamcrest:hamcrest-core:1.3",
"version": "1.3",
"scope": "test-transitive",
"license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
},
{
"id": "hamcrest-library",
"coordinates": "org.hamcrest:hamcrest-library:1.3",
"version": "1.3",
"scope": "androidTest-transitive",
"license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
},
{
"id": "hamcrest-integration",
"coordinates": "org.hamcrest:hamcrest-integration:1.3",
"version": "1.3",
"scope": "androidTest-transitive",
"license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
},
{
"id": "gson",
"coordinates": "com.google.code.gson:gson:2.9.0",
"version": "2.9.0",
"scope": "androidTest-transitive",
"license": "Apache-2.0",
"source": "https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE"
},
{
"id": "javawriter",
"coordinates": "com.squareup:javawriter:2.1.1",
"version": "2.1.1",
"scope": "androidTest-transitive",
"license": "Apache-2.0",
"source": "https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt"
}
],
"groupPolicies": [
{
"id": "androidx-runtime-policy",
"groupPrefix": "androidx",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "dagger-runtime-policy",
"groupPrefix": "com.google.dagger",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
},
{
"id": "kotlin-runtime-policy",
"groupPrefix": "org.jetbrains.kotlin",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
},
{
"id": "kotlinx-runtime-policy",
"groupPrefix": "org.jetbrains.kotlinx",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt"
}
]
}
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
/** 灵机自撰白话草稿。按文王序 1–64;每项为 [卦辞, 初, 二, 三, 四, 五, 上]。 */
export const lingjiPlain = [
["开始即通达,利于守持正道。", "龙还潜藏着,先不要施展。", "龙出现在田野,利于去见有识之人。", "君子整日健进,夜里仍警惕;虽有危难,可以无灾。", "或跳进深渊,可以无灾。", "龙飞在天上,利于去见有识之人。", "龙飞得过高,会有悔恨。"],
["开始即通达,利于像母马那样守持正道。君子有所前往,先会迷路,随后才得到依靠。利于西南得到同类,东北失去同类。安于守正,吉。", "踩到霜,坚冰就要来了。", "正直方正而广大,不必演习,没有不利。", "蕴含文采,可以守正。或跟从王事,不求自己成功,却能有结果。", "扎紧口袋,无灾也无称誉。", "黄色下裳,大为吉祥。", "龙在郊野争斗,血流成青黄色。"],
["开始即通达,利于守正。不要急着前往,利于立诸侯。", "徘徊不进,利于安居守正,利于立诸侯。", "困顿难行,乘马盘旋。不是强盗,是来求婚。女子守正不嫁,十年才许嫁。", "追鹿却没有虞人向导,只是陷入林中。君子察觉危险,不如放弃;再往前会有憾恨。", "乘马盘旋,去求婚,前往吉,没有不利。", "屯积膏泽。小事守正吉,大事守正凶。", "乘马盘旋,血泪涟涟。"],
["亨通。我不是要去求童蒙,是童蒙来求我。初次卜问则告知;再三乱问,就不告知。利于守正。", "启发蒙昧,利于用刑罚纠正人,用桎梏约束;若一再用刑,就有憾恨。", "包容蒙昧,吉。接纳妇人,吉。孩子能自立,家庭就不再受困。", "不要娶这个女子,她见了财礼就失了自身。无利可图。", "困于蒙昧,憾恨。", "童蒙,吉。", "打击蒙昧。不利于做强盗,利于抵御强盗。"],
["有所等待,有诚信。光辉亨通,守正吉。利于涉大河。", "在郊野等待,利于用恒常的方法,无灾。", "在沙滩等待,小有议论,最终吉。", "在泥中等待,招来贼寇。", "在血中等待,从洞穴里出来。", "在酒食中等待,守正吉。", "进入洞穴,有不请自来的三位客人;尊敬他们,最终吉。"],
["有诚信,在窒塞中警惕,中正则吉,最终凶。利于见大人,不利于涉大河。", "不永久纠缠事务,小有议论,最终吉。", "败于讼事,回家逃避。邑人三百户,无灾。", "食用旧德,守正有危,最终吉。或跟从王事,没有成功。", "不能胜诉,仍复归于常理,改变态度,安静守正则吉。", "讼事,元吉。", "或赐给大带,一天之内被剥夺三次。"],
["贞正。大人吉,无灾。兵众,正而不邪才可用。", "出兵要有纪律;否则,凶。", "在队伍中,无灾,王三次赐命。", "师或载尸而还,凶。", "师左次而退,无灾。", "田里有禽,利于执言,无灾。长子帅师,弟子载尸,贞凶。", "大君有命,开国承家。小人不可重用。"],
["吉。原筮,元永贞,无灾。不安宁的人前来,后夫凶。", "有诚信,比之无灾。有诚信充满瓦缶,终来有别的吉。", "比之从内,贞吉。", "比之匪人。", "在外相比,贞吉。", "显比。王用三驱,失前禽。邑人不诫,吉。", "比之无首,凶。"],
["亨通。密云不雨,自我西郊。", "复归于自己的道路,何其咎?吉。", "牵复,吉。", "舆说辐。夫妻反目。", "有孚,血去惕出,无灾。", "有孚挛如,富以其邻。", "既雨既处,尚德载。妇贞厉。月几望,君子征凶。"],
["踩踏老虎尾巴,不咬人,亨通。", "素履往,无灾。", "履道坦坦,幽人贞吉。", "眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。", "履虎尾,愬愬,终吉。", "夬履,贞厉。", "视履考祥,其旋元吉。"],
["小往大来,吉,亨通。", "拔茅连根,以其类,征吉。", "包荒,用冯河,不遐遗。朋亡,得尚于中行。", "无平不陂,无往不复。艰贞无灾。勿恤其孚,于食有福。", "翩翩,不富以其邻,不戒以孚。", "帝乙归妹,以祉元吉。", "城复于隍,勿用师。自邑告命,贞吝。"],
["否之匪人,不利于君子守正。大的往,小的来。", "拔茅连根,以其类,守正吉,亨通。", "包容奉承。小人吉,大人否塞。亨通。", "包藏羞辱。", "有天命,无灾。同类依附而得福。", "休止否塞,大人吉。心中念着将亡将亡,才能系于苞桑。", "倾覆否塞。先否塞,后喜悦。"],
["同人于野,亨通。利于涉大河,利于君子守正。", "同人于门,无灾。", "同人于宗,吝。", "伏戎于莽,升其高陵,三年不兴。", "乘其墉,弗克攻,吉。", "同人,先号咷而后笑。大师克相遇。", "同人于郊,无悔。"],
["元亨。", "无交害,匪咎,艰则无灾。", "大车以载,有攸往,无灾。", "公用亨于天子。小人弗克。", "匪其彭,无灾。", "厥孚交如,威如,吉。", "自天佑之,吉无不利。"],
["亨通。君子有终。", "谦谦君子,用涉大川,吉。", "鸣谦,贞吉。", "劳谦,君子有终,吉。", "无不利,撝谦。", "不富以其邻,利用侵伐,无不利。", "鸣谦,利用行师,征邑国。"],
["利于建侯行师。", "鸣豫,凶。", "介于石,不终日,贞吉。", "盱豫,悔。迟则有悔。", "由豫,大有得。勿疑,朋盍簪。", "贞疾,恒不死。", "冥豫,成有渝,无灾。"],
["元亨,利贞,无灾。", "官有渝,贞吉。出门交有功。", "系小子,失丈夫。", "系丈夫,失小子。随有求得,利居贞。", "随有获,贞凶。有孚在道,以明,何咎。", "孚于嘉,吉。", "拘系之,乃从维之。王用亨于西山。"],
["元亨,利涉大川。先甲三日,后甲三日。", "干父之蛊,有子,考无咎。厉,终吉。", "干母之蛊,不可贞。", "干父之蛊,小有悔,无大咎。", "裕父之蛊,往见吝。", "干父之蛊,用誉。", "不事王侯,高尚其事。"],
["元亨,利贞。至于八月有凶。", "咸临,贞吉。", "咸临,吉,无不利。", "甘临,无攸利。既忧之,无灾。", "至临,无灾。", "知临,大君之宜,吉。", "敦临,吉,无灾。"],
["盥而不荐,有孚颙若。", "童观,小人无咎,君子吝。", "窥观,利女贞。", "观我生,进退。", "观国之光,利用宾于王。", "观我生,君子无咎。", "观其生,君子无咎。"],
["亨通。利用狱。", "屦校灭趾,无灾。", "噬肤灭鼻,无灾。", "噬腊肉,遇毒。小吝,无灾。", "噬干胏,得金矢。利艰贞,吉。", "噬干肉,得黄金。贞厉,无灾。", "何校灭耳,凶。"],
["亨通。小利有攸往。", "贲其趾,舍车而徒。", "贲其须。", "贲如濡如,永贞吉。", "贲如皤如,白马翰如。匪寇婚媾。", "贲于丘园,束帛戋戋。吝,终吉。", "白贲,无灾。"],
["不利于有所前往。", "剥床以足,蔑贞,凶。", "剥床以辨,蔑贞,凶。", "剥之,无灾。", "剥床以肤,凶。", "贯鱼,以宫人宠,无不利。", "硕果不食。君子得舆,小人剥庐。"],
["亨通。出入没有疾病,朋友来也无灾。一来一回按那条路走,七天会回复。利于有所前往。", "回复得还不远,没有大的悔恨,大为吉祥。", "美好地回复,吉。", "频繁回复,有危难,无灾。", "走在行列中间,独自回复。", "敦厚地回复,没有悔恨。", "迷路却不回复,凶,有灾祸。用此出兵,终有大败,连累国君,凶;到十年还不能出征。"],
["元亨,利贞。其匪正有眚,不利有攸往。", "无妄,往吉。", "不耕获,不菑畲,则利有攸往。", "无妄之灾。或系之牛,行人之得,邑人之灾。", "可贞,无灾。", "无妄之疾,勿药有喜。", "无妄,行有眚,无攸利。"],
["利贞。不家食,吉。利涉大川。", "有厉,利已。", "舆说輹。", "良马逐,利艰贞。日闲舆卫,利有攸往。", "童牛之牿,元吉。", "豮豕之牙,吉。", "何天之衢,亨。"],
["贞吉。观颐,自求口实。", "舍尔灵龟,观我朵颐,凶。", "颠颐,拂经,于丘颐,征凶。", "拂颐,贞凶。十年勿用,无攸利。", "颠颐,吉。虎视眈眈,其欲逐逐,无灾。", "拂经,居贞吉,不可涉大川。", "由颐,厉吉,利涉大川。"],
["栋桡。利有攸往,亨。", "藉用白茅,无灾。", "枯杨生稊,老夫得其女妻,无不利。", "栋桡,凶。", "栋隆,吉。有它吝。", "枯杨生华,老妇得其士夫,无咎无誉。", "过涉灭顶,凶,无灾。"],
["有孚,维心亨,行有尚。", "习坎,入于坎窞,凶。", "坎有险,求小得。", "来之坎坎,险且枕,入于坎窞,勿用。", "樽酒簋贰,用缶,纳约自牖,终无灾。", "坎不盈,祗既平,无灾。", "系用徽纆,置于丛棘,三岁不得,凶。"],
["利贞,亨。畜牝牛,吉。", "履错然,敬之,无灾。", "黄离,元吉。", "日昃之离,不鼓缶而歌,则大耋之嗟,凶。", "突如其来如,焚如,死如,弃如。", "出涕沱若,戚嗟若,吉。", "王用出征,有嘉折首,获匪其丑,无灾。"],
["亨通。利贞。取女吉。", "咸其拇。", "咸其腓,凶。居吉。", "咸其股,执其随,往吝。", "贞吉,悔亡。憧憧往来,朋从尔思。", "咸其脢,无悔。", "咸其辅颊舌。"],
["亨通。无灾。利于守正。利于有所前往。", "深求恒久,守正则凶,无所利。", "悔恨消失。", "不能恒久其德,或许会承受羞辱,守正有憾。", "田里没有禽兽。", "恒久其德,守正。妇人吉,男子凶。", "振动不安于恒,凶。"],
["亨通。小利于守正。", "遁走只剩尾巴,有危,不要有所前往。", "用黄牛皮捆住,没有人能解脱。", "被牵绊着退避,有疾病和危难。畜养臣妾,吉。", "喜好退避,君子吉,小人否塞。", "嘉美地退避,守正吉。", "充裕地退避,没有不利。"],
["利于守正。", "壮在脚趾,出征凶,有诚信。", "贞吉。", "小人用壮,君子用罔,贞厉。公羊触藩,缠住了角。", "贞吉,悔恨消失。藩篱没被破坏,壮在大车的辐上。", "在田里丧失羊,无悔。", "公羊触藩,不能退不能进,无攸利。艰难则吉。"],
["康侯用锡马蕃庶,白天三次接见。", "晋如摧如,贞吉。罔孚,裕无灾。", "晋如愁如,贞吉。受兹介福,于其王母。", "众允,悔亡。", "晋如鼫鼠,贞厉。", "悔亡,失得勿恤。往吉,无不利。", "晋其角,维用伐邑,厉吉,无灾,贞吝。"],
["利于艰难中守正。", "明夷于飞,垂其翼。君子于行,三日不食。有攸往,主人有言。", "明夷,夷于左股,用拯马壮,吉。", "明夷于南狩,得其大首,不可疾贞。", "入于左腹,获明夷之心,于出门庭。", "箕子之明夷,利贞。", "不明晦。初登于天,后入于地。"],
["利于女子守正。", "闲有家,悔亡。", "无攸遂,在中馈,贞吉。", "家人嗃嗃,悔厉吉;妇子嘻嘻,终吝。", "富家,大吉。", "王假有家,勿恤,吉。", "有孚威如,终吉。"],
["小事吉。", "悔亡。丧马勿逐,自复。见恶人,无灾。", "遇主于巷,无灾。", "见舆曳,其牛掣,其人天且劓。无初有终。", "睽孤,遇元夫,交孚,厉无灾。", "悔亡。厥宗噬肤,往何咎。", "睽孤,见豕负涂,载鬼一车。先张之弧,后说之弧。匪寇婚媾。往遇雨则吉。"],
["利西南,不利东北。利见大人。贞吉。", "往蹇,来誉。", "王臣蹇蹇,匪躬之故。", "往蹇,来反。", "往蹇,来连。", "大蹇,朋来。", "往蹇,来硕,吉。利见大人。"],
["利西南。无所往,其来复吉。有攸往,夙吉。", "无灾。", "田获三狐,得黄矢,贞吉。", "负且乘,致寇至,贞吝。", "解而拇,朋至斯孚。", "君子维有解,吉。有孚于小人。", "公用射隼于高墉之上,获之,无不利。"],
["有孚,元吉,无灾,可贞,利有攸往。曷之用?二簋可用享。", "已事遄往,无灾。酌损之。", "利贞。征凶。弗损,益之。", "三人行则损一人,一人行则得其友。", "损其疾,使遄有喜,无灾。", "或益之十朋之龟,弗克违,元吉。", "弗损,益之,无灾。贞吉。利有攸往,得臣无家。"],
["利有攸往。利涉大川。", "利用为大作,元吉,无灾。", "或益之十朋之龟,弗克违。永贞吉。王用享于帝,吉。", "益之用凶事,无灾。有孚中行,告公用圭。", "中行,告公从,利用为依迁国。", "有孚惠心,勿问元吉。有孚惠我德。", "莫益之,或击之,立心勿恒,凶。"],
["扬于王庭。孚号有厉。告自邑,不利即戎,利有攸往。", "壮于前趾,往不胜为咎。", "惕号,莫夜有戎,勿恤。", "壮于頄,有凶。君子夬夬,独行遇雨,若濡有愠,无灾。", "臀无肤,其行次且。牵羊悔亡,闻言不信。", "苋陆夬夬,中行无灾。", "无号,终有凶。"],
["女壮,勿用取女。", "系于金柅,贞吉。有攸往,见凶。羸豕孚蹢躅。", "包有鱼,无灾,不利宾。", "臀无肤,其行次且,厉,无大咎。", "包无鱼,起凶。", "以杞包瓜,含章,有陨自天。", "姤其角,吝,无灾。"],
["亨通。王假有庙。利见大人,亨,利贞。用大牲吉。利有攸往。", "有孚不终,乃乱乃萃。若号,一握为笑,勿恤,往无灾。", "引吉,无灾。孚乃利用禴。", "萃如嗟如,无攸利。往无灾,小吝。", "大吉,无灾。", "萃有位,无灾。匪孚,元永贞,悔亡。", "赍咨涕洟,无灾。"],
["元亨。用见大人,勿恤。南征吉。", "允升,大吉。", "孚乃利用禴,无灾。", "升虚邑。", "王用亨于岐山,吉,无灾。", "贞吉,升阶。", "冥升,利于不息之贞。"],
["亨通。贞,大人吉,无灾。有言不信。", "臀困于株木,入于幽谷,三岁不觌。", "困于酒食,朱绂方来,利用享祀。征凶,无灾。", "困于石,据于蒺藜。入于其宫,不见其妻,凶。", "来徐徐,困于金车,吝,有终。", "劓刖,困于赤绂。乃徐有说,利用祭祀。", "困于葛藟,于臲卼。曰动悔有悔,征吉。"],
["改邑不改井,无丧无得。往来井井。汔至,亦未繘井,羸其瓶,凶。", "井泥不食,旧井无禽。", "井谷射鲋,瓮敝漏。", "井渫不食,为我心恻。可用汲,王明,并受其福。", "井甃,无灾。", "井冽,寒泉食。", "井收勿幕,有孚元吉。"],
["己日乃孚。元亨,利贞,悔亡。", "巩用黄牛之革。", "己日乃革之,征吉,无灾。", "征凶,贞厉。革言三就,有孚。", "悔亡。有孚改命,吉。", "大人虎变,未占有孚。", "君子豹变,小人革面。征凶,居贞吉。"],
["元吉,亨通。", "鼎颠趾,利出否。得妾以其子,无灾。", "鼎有实。我仇有疾,不我能即,吉。", "鼎耳革,其行塞,雉膏不食。方雨亏悔,终吉。", "鼎折足,覆公餗,其形渥,凶。", "鼎黄耳金铉,利贞。", "鼎玉铉,大吉,无不利。"],
["亨通。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。", "震来虩虩,后笑言哑哑,吉。", "震来厉,亿丧贝。跻于九陵,勿逐,七日得。", "震苏苏,震行无眚。", "震遂泥。", "震往来厉,亿无丧,有事。", "震索索,视矍矍,征凶。震不于其躬,于其邻,无灾。婚媾有言。"],
["艮其背,不获其身;行其庭,不见其人,无灾。", "艮其趾,无灾,利永贞。", "艮其腓,不拯其随,其心不快。", "艮其限,列其夤,厉薰心。", "艮其身,无灾。", "艮其辅,言有序,悔亡。", "敦艮,吉。"],
["女归吉。利贞。", "鸿渐于干,小子厉,有言,无灾。", "鸿渐于磐,饮食衎衎,吉。", "鸿渐于陆,夫征不复,妇孕不育,凶。利御寇。", "鸿渐于木,或得其桷,无灾。", "鸿渐于陵,妇三岁不孕,终莫之胜,吉。", "鸿渐于陆,其羽可用为仪,吉。"],
["征凶,无攸利。", "归妹以娣,跛能履,征吉。", "眇能视,利幽人之贞。", "归妹以须,反归以娣。", "归妹愆期,迟归有时。", "帝乙归妹,其君之袂不如其娣之袂良。月几望,吉。", "女承筐无实,士刲羊无血,无攸利。"],
["亨通。王假之。勿忧,宜日中。", "遇其配主,虽旬无灾,往有尚。", "丰其蔀,日中见斗。往得疑疾,有孚发若,吉。", "丰其沛,日中见沫。折其右肱,无灾。", "丰其蔀,日中见斗。遇其夷主,吉。", "来章,有庆誉,吉。", "丰其屋,蔀其家,窥其户,阒其无人,三岁不觌,凶。"],
["小亨。旅贞吉。", "旅琐琐,斯其所取灾。", "旅即次,怀其资,得童仆贞。", "旅焚其次,丧其童仆,贞厉。", "旅于处,得其资斧,我心不快。", "射雉,一矢亡,终以誉命。", "鸟焚其巢,旅人先笑后号咷。丧牛于易,凶。"],
["小亨。利有攸往。利见大人。", "进退,利武人之贞。", "巽在床下,用史巫纷若,吉,无灾。", "频巽,吝。", "悔亡,田获三品。", "贞吉,悔亡,无不利。无初有终。先庚三日,后庚三日,吉。", "巽在床下,丧其资斧,贞凶。"],
["亨通。利贞。", "和兑,吉。", "孚兑,吉,悔亡。", "来兑,凶。", "商兑未宁,介疾有喜。", "孚于剥,有厉。", "引兑。"],
["亨通。王假有庙。利涉大川,利贞。", "用拯马壮,吉。", "涣奔其机,悔亡。", "涣其躬,无悔。", "涣其群,元吉。涣有丘,匪夷所思。", "涣汗其大号,涣王居,无灾。", "涣其血,去逖出,无灾。"],
["亨通。苦节不可贞。", "不出户庭,无灾。", "不出门庭,凶。", "不节若,则嗟若,无灾。", "安节,亨。", "甘节,吉。往有尚。", "苦节,贞凶,悔亡。"],
["豚鱼吉。利涉大川,利贞。", "虞吉,有它不燕。", "鸣鹤在阴,其子和之。我有好爵,吾与尔靡之。", "得敌,或鼓或罢,或泣或歌。", "月几望,马匹亡,无灾。", "有孚挛如,无灾。", "翰音登于天,贞凶。"],
["亨通。利贞。可小事,不可大事。飞鸟遗之音,不宜上,宜下,大吉。", "飞鸟以凶。", "过其祖,遇其妣。不及其君,遇其臣,无灾。", "弗过防之,从或戕之,凶。", "无灾。弗过遇之。往厉必戒,勿用永贞。", "密云不雨,自我西郊。公弋取彼在穴。", "弗遇过之,飞鸟离之,凶。是谓灾眚。"],
["亨通。小利贞。初吉终乱。", "曳其轮,濡其尾,无灾。", "妇丧其茀,勿逐,七日得。", "高宗伐鬼方,三年克之,小人勿用。", "繻有衣袽,终日戒。", "东邻杀牛,不如西邻之禴祭,实受其福。", "濡其首,厉。"],
["亨通。小狐汔济,濡其尾,无攸利。", "濡其尾,吝。", "曳其轮,贞吉。", "未济,征凶,利涉大川。", "贞吉,悔亡。震用伐鬼方,三年有赏于大国。", "贞吉,无悔。君子之光,有孚,吉。", "有孚于饮酒,无灾。濡其首,有孚失是。"],
];
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://brainwave.invalid/schema/hexagram-content-v1.json",
"title": "Brainwave local hexagram content package",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"contentVersion",
"specialUsageTexts",
"sources",
"hexagrams"
],
"properties": {
"schemaVersion": { "const": 1 },
"contentVersion": { "type": "string", "minLength": 1 },
"specialUsageTexts": {
"type": "object",
"additionalProperties": false,
"required": ["qian", "kun"],
"properties": {
"qian": { "type": "boolean" },
"kun": { "type": "boolean" }
}
},
"sources": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/source" }
},
"hexagrams": {
"type": "array",
"minItems": 64,
"maxItems": 64,
"items": { "$ref": "#/$defs/hexagram" }
}
},
"$defs": {
"nonBlankText": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"trigram": {
"enum": ["QIAN", "DUI", "LI", "ZHEN", "XUN", "KAN", "GEN", "KUN"]
},
"polarity": {
"enum": ["YIN", "YANG"]
},
"source": {
"type": "object",
"additionalProperties": false,
"required": ["id", "title", "edition", "license", "url"],
"properties": {
"id": { "$ref": "#/$defs/nonBlankText" },
"title": { "$ref": "#/$defs/nonBlankText" },
"edition": { "$ref": "#/$defs/nonBlankText" },
"license": { "$ref": "#/$defs/nonBlankText" },
"url": { "type": "string", "format": "uri" }
}
},
"sixPolarities": {
"type": "array",
"minItems": 6,
"maxItems": 6,
"items": { "$ref": "#/$defs/polarity" }
},
"sixTexts": {
"type": "array",
"minItems": 6,
"maxItems": 6,
"items": { "$ref": "#/$defs/nonBlankText" }
},
"hexagram": {
"type": "object",
"additionalProperties": false,
"required": [
"kingWenNumber",
"name",
"symbol",
"lowerTrigram",
"upperTrigram",
"patternBottomUp",
"judgmentOriginal",
"judgmentPlain",
"lineTextsBottomUp",
"linePlainBottomUp",
"specialUsageText",
"sourceRefs"
],
"properties": {
"kingWenNumber": { "type": "integer", "minimum": 1, "maximum": 64 },
"name": { "$ref": "#/$defs/nonBlankText" },
"symbol": { "$ref": "#/$defs/nonBlankText" },
"lowerTrigram": { "$ref": "#/$defs/trigram" },
"upperTrigram": { "$ref": "#/$defs/trigram" },
"patternBottomUp": { "$ref": "#/$defs/sixPolarities" },
"judgmentOriginal": { "$ref": "#/$defs/nonBlankText" },
"judgmentPlain": { "$ref": "#/$defs/nonBlankText" },
"lineTextsBottomUp": { "$ref": "#/$defs/sixTexts" },
"linePlainBottomUp": { "$ref": "#/$defs/sixTexts" },
"specialUsageText": {
"oneOf": [
{ "$ref": "#/$defs/nonBlankText" },
{ "type": "null" }
]
},
"sourceRefs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/nonBlankText" }
}
}
}
}
}
+9 -4
View File
@@ -1,14 +1,14 @@
# Brainwave 项目文档
> 文档状态:方案基线
> 最后核验:2026-08-04
> 当前阶段:P-1 高保真交互原型已产出、待视觉确认;Android 工程尚未初始化
> 最后核验:2026-08-19
> 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 已接入维基文库《易经》原文与项目自撰白话草稿,待内容负责人审校;P-1 v0.3 待视觉复核;P5 未开始
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
## 项目一句话
Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
灵机(仓库代号 Brainwave)是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
## 文档地图
@@ -22,8 +22,10 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
| [内容审校](content-review.md) | 审校卦辞来源或准备发布时 | 来源、异文和签核状态 |
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
| [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 |
| [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 |
| [实施计划](implementation-plan.md) | 领取任务或判断下一步时 | 阶段、依赖、交付物和退出条件 |
| [决策记录](decisions.md) | 遇到架构分歧或未决问题时 | 已接受决定、默认假设和 TBD |
| [代理工作手册](agent-playbook.md) | 任何编码代理开始工作前 | 检索、修改、验证和交付流程 |
@@ -68,11 +70,14 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
已确认:
- 目标平台为原生 Android。
- 正式产品名为“灵机”,namespace/application ID 为 `net.opcapp.flash`,`minSdk=26`。
- 使用 Kotlin、Jetpack Compose 和单 Activity 架构。
- 起卦完全在本地完成,AI 不得参与或更改起卦结果。
- 用户真实投币并录入;MVP 不提供随机起卦按钮。
- 东方文化表达采用“纸、墨、朱砂、留白”的内容优先风格。
- AI 仅在用户主动点击「解」之后调用。
- 完整起卦、问题、解读与行动记录默认保存在 App 私有本机存储,首页明确告知,用户可在设置全局关闭或对当次退出。
- 本地历史默认排除系统/设备迁移/云备份;自动保存与 AI 数据发送授权完全独立。
仍需产品确认的事项记录在[决策记录](decisions.md#未决问题)。任何代理不得把 `TBD` 悄悄变成产品事实。
@@ -86,4 +91,4 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
- 测试命令、构建方式或发布门禁改变;
- 出现可能再次发生的缺陷。
文档链接、需求编号和验证命令将随工程骨架一起接入 CI 检查。当前尚无 Gradle 工程,不能声称任何构建或测试已经通过。
文档链接、需求编号和验证命令由 `verifyLocal` 机械检查。授权真机存在时另跑设备仪器测试;未运行的门禁不得写成已经通过。
+3
View File
@@ -15,6 +15,8 @@
禁止后台预取、自动重试到另一模型、首次进入结果页即调用、以及把 AI 回复用于改写 `CastResult`。
本地历史自动保存、读取问卦簿、切换保存设置或已有 AI 解读记录,都不构成网络调用授权。`autoSaveHistory` 与 AI 同意版本必须是独立状态;任何保存路径均不得顺带调用 AI。
## 2. AI 输入契约
客户端提交给后端的业务载荷应是明确 DTO,不发送整个数据库实体或 UI 状态:
@@ -143,6 +145,7 @@ Android 端密钥管理参考:[Android 安全清单](https://developer.android
- 同一 `CastResult` 在 AI 请求前后字节级确定字段不变。
- 没有 `CastResult`、未点击、未同意或结果尚未显示时,网络 mock 收到 0 次调用。
- 自动保存完成、历史详情打开、保存设置切换和“保存本次”执行时,网络 mock 均收到 0 次调用;这些操作不得改变 AI 同意状态。
- 提示注入样例不能让模型重算卦、泄露系统提示或执行用户文本中的指令。
- 高风险测试集不产生确定的医疗、法律、投资或人生决定。
- 所有合法输出都有可撤销行动;所有非法输出都被客户端拒绝且可改用本地解释。
+19 -8
View File
@@ -39,11 +39,11 @@ Android 官方建议新应用采用清晰的 UI/数据分层、单向数据流
工程初始化后建议采用:
```text
app/src/main/java/<package>/
app/src/main/java/net/opcapp/flash/
├── app/
│ ├── BrainwaveApplication.kt
│ ├── LingjiApplication.kt
│ ├── MainActivity.kt
│ └── BrainwaveNavHost.kt
│ └── LingjiApp.kt
├── core/
│ ├── model/ # 跨层不可变领域模型
│ ├── designsystem/ # 主题、字体、间距、卦象 Canvas
@@ -58,6 +58,7 @@ app/src/main/java/<package>/
│ └── ai/ # 网络 DTO、gateway、响应校验
└── feature/
├── onboarding/
├── home/ # 回访首页、最近一次、已上线次级入口
├── question/
├── casting/
├── result/
@@ -68,6 +69,8 @@ app/src/main/java/<package>/
测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。
当前已落地的包:`app/`、`core/model`、`core/designsystem`、`domain/casting`、`data/content`(assets 解析器与测试 fake)、`data/history`、`data/settings`、`feature/onboarding`、`feature/home`、`feature/question`、`feature/casting`、`feature/result`、`feature/settings`、`feature/content`。尚未创建 `feature/history`、`feature/explanation`、`domain/explanation` 和 `data/ai`。首页不为问卦簿或「解」生成空占位。
## 4. 依赖方向
允许:
@@ -95,7 +98,7 @@ core/designsystem → Compose/Material + core model(仅绘制需要)
### `CastEngine`
纯 Kotlin、无副作用。输入六轮铜币和方法版本,输出不可变 `CastResult`。所有规则来自[领域规则](domain-rules.md)。
纯 Kotlin、无副作用。输入六轮铜币,使用 API 固定的 `coin-v1` 约定输出不可变 `CastComputation`;随后由记录工厂附加 `contentVersion` 与 `createdAt`,组成不可变 `CastResult`。方法版本不是调用方可随意传入的自由字符串,记录元数据也不能进入计算。所有规则来自[领域规则](domain-rules.md)。
### `HexagramContentRepository`
@@ -107,7 +110,13 @@ core/designsystem → Compose/Material + core model(仅绘制需要)
### `HistoryRepository`
在用户明确保存后持久化会话。Room 模型不得泄露到 UI;数据库迁移必须有测试。
接收第六爻锁定事件与当次 `HistorySavePolicy` 快照,在 `autoSaveHistory=true` 时持久化会话;总开关关闭时仍支持结果页明确发出的“保存本次”命令。策略包含 `saveQuestion`、`saveExplanation` 和 `saveActionNote`,生产默认值均为 `true`,由 DataStore 提供但不得泄露到领域起卦逻辑。
Repository 提供倒序历史与最新一条的 `Flow` 查询,并把随后产生的本地或 AI 解读事务性关联到同一 session。重新解读新增版本或执行经过明确确认的替换命令,禁止普通 upsert 静默覆盖。DAO 事务负责 session、explanation 和 action 的一致写入与级联删除;首页直接订阅最新记录的投影,不维护第二份敏感正文缓存。Room 模型不得泄露到 UI;数据库迁移必须有测试。
### `HomeViewModel` / `HistoryViewModel`
`HomeViewModel` 组合 onboarding 状态、最新历史投影、保存策略摘要和已启用功能入口,只输出真实可进入的入口;不为未来功能生成禁用占位。首页固定输出本机保存/AI 发送边界文案和设置入口。`HistoryViewModel` 维护列表、空态、详情、单条删除和清空全部确认状态,通过 `HistoryRepository` 完成操作。导航使用一次性 UI 事件或 NavHost 回调,返回后恢复列表位置。
### `ExplanationRepository`
@@ -136,10 +145,12 @@ ResultUiState(
## 7. 数据和存储选择
- **assets 或预置 Room**:版本化的 64 卦及爻辞。首版如果仅按编号读取,JSON assets 更简单;需要全文检索或内容迁移时再采用预置 Room。
- **Room**:用户主动保存的会话、解释和行动记录。Android 官方推荐 Room 管理非平凡结构化数据。[Room 文档](https://developer.android.com/training/data-storage/room)
- **DataStore**:方法说明是否已读、主题、AI 同意版本等少量设置;不保存大型历史或完整卦库。[DataStore 文档](https://developer.android.com/topic/libraries/architecture/datastore)
- **Room**:按保存策略自动写入或由用户当次明确保存的会话、解释和行动记录。Android 官方推荐 Room 管理非平凡结构化数据。[Room 文档](https://developer.android.com/training/data-storage/room)
- **DataStore**:方法说明是否已读、主题、AI 同意版本和历史保存策略等少量设置;不保存大型历史或完整卦库。[DataStore 文档](https://developer.android.com/topic/libraries/architecture/datastore)
- **SavedStateHandle**:恢复当前未完成流程;不是长期历史数据库。
历史 Room 数据库及其辅助文件必须通过 Android backup/data-extraction 配置排除系统自动备份和设备迁移。此边界由构建配置测试机械验证,不能只依赖界面文案;引入导出、账号或同步前必须新增 ADR。
详细契约见[数据与内容](data-content.md)。
## 8. 网络与 AI 边界
@@ -171,7 +182,7 @@ App → HTTPS backend → model provider
- 禁止直接引入完整“算命 SDK”或无法审计的卦象计算库。
- Compose 依赖使用 BOM 对齐版本。
- `compileSdk`/`targetSdk` 使用实现时最新稳定且满足商店要求的版本,不在方案文档硬编码会迅速过期的数值。
- `minSdk` 默认提案为 26,最终值见[决策记录](decisions.md#未决问题)。
- `minSdk=26` 已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认;提高最低版本必须以依赖要求或覆盖率数据另立决策。
## 11. 多模块化触发条件
+34
View File
@@ -0,0 +1,34 @@
# 内容审校记录
> 状态:P2 数据包已生成,待内容负责人确认可再分发
> 内容版本:`zh-Hans-2026.1`
## 已接受来源
| 来源 ID | 文本 | 许可 | 说明 |
|---|---|---|---|
| `wikisource-zhouyi-jing-zh-Hans` | 卦辞、爻辞、乾用九、坤用六 | 公版(Wikimedia PD-old) | 维基文库《周易》仅《易经》;不含彖、象、文言等十翼。导入脚本:`scripts/import-zhouyi-wikisource.mjs` |
| `lingji-plain-zh-Hans-2026.1` | 本地白话 | 项目自撰草稿 | `content/plain/lingji-plain.mjs`;审校完成前不得当作已授权现代译本 |
未采用南怀瑾、傅佩荣或其他仍受版权保护的现代译注。
## 工程入口
- 原始导入:`content/raw/zhouyi-wikisource-jing.json`
- 发布包:`content/packages/hexagram-content.json`
- 构建:`node scripts/build-hexagram-package.mjs`
- 再导入需本机 HTTP 代理(默认 `http://127.0.0.1:1080`)访问 `zh.wikisource.org`
## 已知异文
维基文库复卦初九作「不复远,无袛悔」,与常见通行本「不远复,无祇悔」不同。本包按维基文库底本收录,不在导入时改字。审校时可决定是否改从通行本并提升 `contentVersion`。
## 审校清单
- [ ] 64 卦卦辞、384 爻与通行《易经》对读,记录有意保留的异文
- [ ] 确认不含十翼
- [ ] 白话无占断口吻、无未授权现代译注抄袭
- [ ] 乾用九、坤用六原文无误
- [ ] 内容负责人签署可再分发
签署前,P2 不得标记完成,商店发布不得进行。
+32 -11
View File
@@ -1,17 +1,17 @@
# 数据、内容与隐私契约
> 状态:结构已定义,内容来源与授权仍为发布阻塞项
> 状态:`zh-Hans-2026.1` 已接入应用;白话为草稿,发布仍待审校
> 适用范围:卦库 assets、Room、DataStore、导入脚本和内容审核
## 1. 数据分类
| 数据 | 来源 | 默认位置 | 敏感性 | 是否参与起卦 |
|---|---|---|---|---|
| 三轮币面/六爻结果 | 用户输入/本地计算 | 会话状态,可选 Room | 低至中 | 是 |
| 用户问题 | 用户输入 | 会话状态,可选 Room | 高 | 否 |
| 十八枚币面/六爻结果 | 用户输入/本地计算 | 会话状态;结果锁定后默认 Room | 低至中 | 是 |
| 用户问题 | 用户输入 | 会话状态;按默认开启的设置进入 Room | 高 | 否 |
| 64 卦与爻辞 | 经审核的内容包 | assets 或预置 Room | 低,关注版权 | 仅用于查表 |
| 本地解释 | 编辑内容 | assets | 低,关注版权 | 否 |
| AI 请求与回复 | 用户主动请求 | 内存,可选 Room | 高 | 否 |
| AI 请求与回复 | 用户主动请求 | 请求在内存;回复按默认开启的设置进入 Room | 高 | 否 |
| 偏好与同意版本 | 用户设置 | DataStore | 中 | 否 |
任何“否”的数据都不能成为 `CastEngine` 输入。
@@ -24,6 +24,10 @@
{
"schemaVersion": 1,
"contentVersion": "zh-Hans-2026.1",
"specialUsageTexts": {
"qian": true,
"kun": true
},
"sources": [
{
"id": "source-id",
@@ -54,6 +58,8 @@
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。`scripts/verify-content-contract.mjs` 校验夹具与 `content/packages/hexagram-content.json`;Kotlin `HexagramContentParser` 用同一套规则加载 assets。`HexagramContentRepository` 在缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。来源与审校状态见 [内容审校](content-review.md)。
## 3. 内容完整性门禁
内容包进入应用前必须自动验证:
@@ -82,6 +88,8 @@
## 5. Room 数据模型提案
当前实现只有 `CastingSessionEntity` / `casting_sessions`(schema version 1)。`ExplanationEntity` 与 `ActionNoteEntity` 仍是提案,不得在 UI 中假装已经保存解读或行动。
历史记录不是起卦事实源,但应保存可复核的快照:
```text
@@ -106,6 +114,7 @@ ExplanationEntity
- contractVersion: string
- content: validated structured JSON
- createdAt
- supersedesExplanationId: nullable foreign key
ActionNoteEntity
- sessionId: foreign key
@@ -118,6 +127,9 @@ ActionNoteEntity
- 数据库存储原始 18 枚币面和确定结果,读取时可重新计算并进行一致性检查。
- `questionText` 默认可为空;用户不保存问题时不能偷偷复制到其他表。
- 第六爻锁定时读取一次 `HistorySavePolicy` 快照;总开关开启时,在单个事务中创建 `CastingSessionEntity`,并按默认开启的内容开关写入 `questionText`。关闭总开关或选择“本次不保存”时不得产生长期记录。
- 同一流程随后生成的本地或 AI 解读按策略写入关联的 `ExplanationEntity`;重新解读新增实体并通过 `supersedesExplanationId` 保留版本关系,不得静默覆写旧正文。
- 行动记录按策略关联到同一 session;没有实际行动内容时不创建空 `ActionNoteEntity`。
- 删除 session 应级联删除解释和行动记录。
- enum 使用稳定字符串或显式转换,不依赖 Kotlin ordinal。
- 每次 schema 迁移必须有 migration test;禁止发布构建使用 destructive migration。
@@ -131,7 +143,12 @@ DataStore 仅保存少量偏好:
- 减少应用内非必要动画;
- AI 同意文本版本和同意时间;
- 默认解释方式;
- 是否允许保存问题原文。
- `autoSaveHistory`:默认 `true`;
- `saveQuestionText`:默认 `true`;
- `saveExplanationContent`:默认 `true`;
- `saveActionNote`:默认 `true`。
总开关关闭时,后三项不参与自动写入,但保留用户上次选择;再次开启后恢复。DataStore 中的本地保存偏好与 AI 同意版本是独立字段,不得相互推导。
不要把六爻历史、完整内容包或 AI 长文本塞入 DataStore。
@@ -143,22 +160,26 @@ DataStore 仅保存少量偏好:
- AI 请求前展示准确的数据清单和服务方类别。
- 日志、分析、崩溃报告、截图测试夹具不得使用真实用户问题。
- 调试日志使用固定脱敏样例;发布构建关闭网络 body logging。
- 历史导出、云备份和 Android Auto Backup 策略在实现前必须单独决策。
- `CastingSessionEntity`、`ExplanationEntity`、`ActionNoteEntity` 所在的 Room 数据库及其 `-wal`、`-shm` 等辅助文件必须通过 Android backup/data-extraction 规则排除,不参与 Auto Backup、设备到设备迁移或厂商云备份。
- MVP 不提供历史导出、账号或云同步。未来任何导出、备份或同步都必须新增决策、展示准确范围并取得单独的明确选择。
## 8. 数据生命周期
```text
草稿问题/未完成投币:SavedStateHandle + 内存
↓ 用户完成
↓ 第六爻确认并锁定
不可变 CastResult:结果页内存
↓ 用户明确保存
Room 历史快照
├─ autoSaveHistory=true → Room 会话快照 + 按设置保存问题
└─ autoSaveHistory=false → 不写 Room,可由用户选择“保存本次”
AI 请求:请求期间内存 → 服务端按已披露策略处理
AI 回复:结果页内存 → 用户保存后可进入 Room
本地/AI 回复:结果页内存 → 按设置关联到同一 Room 会话
└─ 会话不存在或解释保存关闭 → 不长期保存,可由用户选择保存本次
```
会话中止后是否保留草稿由产品设置决定;无论如何都不能把草稿作为遥测发送。
“最近一次”不是独立复制的数据,而是按 `createdAt` 从 Room 查询得到的最新已保存会话。首页不得缓存另一份问题或解释正文;删除该会话后,最近记录区域必须随查询结果一起更新为空态或下一条记录。
中止的未完成草稿只按 SavedState 恢复策略存在,不进入问卦簿;无论如何都不能把草稿作为遥测发送。用户选择“本次不保存/删除本次记录”后,应删除已经自动创建的 session 及其关联内容,但不改写全局设置。
## 9. 内容变更规则
+69 -11
View File
@@ -76,11 +76,13 @@
## ADR-009:历史记录采用明确保存、本机优先
- 状态:`Proposed`
- 状态:`Superseded by ADR-012`
- 日期:2026-08-04
- 决定:用户完成后明确保存才进入 Room;保存时可不保留问题原文;默认无账号和云同步。
- 关联:FR-H-001~006、原 TBD-007、2026-08-04 用户确认
- 决定:用户完成后明确点击保存才进入 Room;固定保存可复核的起卦快照,问题原文和当前解释分别使用默认关闭的独立选项;默认无账号和云同步。
- 原因:问题可能高度敏感,自动永久保存不是安全默认值。
- 后果:历史功能不能成为完成起卦的前置条件。
- 后果:首页在存在记录时显示“最近一次 / 问卦簿”次级入口,但“开始一问”仍是唯一主操作;没有记录时提供说明和开始入口,不显示空白列表。历史功能不能成为完成起卦的前置条件。
- 取代原因:用户进一步确认“用户写的内容与解读默认完整保存在 App 内”,并要求在首页显式说明、在设置中可关闭;现行规则见 ADR-012。
## ADR-010:多动爻全部透明展示
@@ -101,22 +103,78 @@
- 后果:原型是体验契约与评审证据,但不是生产领域代码;P1 必须独立实现和穷举验证算法,P3 复用已确认的设计令牌与状态关系。
- 复审条件:用户否定当前流程或目标平台发生变化。
## ADR-012:完整记录默认在本机自动保存,可配置关闭
- 状态:`Accepted`
- 日期:2026-08-04
- 关联:FR-H-001~008、取代 ADR-009、解决原 TBD-008、2026-08-04 用户确认
- 决定:
1. 第六爻确认并锁定 `CastResult` 后,只要“自动保存完整记录”开启,就立即在 Room 创建可复核的会话快照;未完成的问题与投币草稿仍只用于会话恢复,不进入长期历史。
2. `autoSaveHistory`、`saveQuestionText`、`saveExplanationContent` 和 `saveActionNote` 默认均为开启。关闭总开关后不再自动写入历史,三个内容开关同步停用;用户仍可在当次结果页选择“保存本次”。
3. 当次随后生成的本地或 AI 解读,在对应设置开启时关联到同一会话;重新解读不得静默覆盖已保存内容,必须新增版本或由用户明确确认替换。
4. 首页固定显示“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”,并提供设置入口。结果页提供明确的自动保存状态和“本次不保存/删除本次记录”。
5. 问卦簿支持单条删除和清空全部;删除会级联移除会话、解读和行动记录。
6. 历史数据库及其问题、解读和行动数据默认排除 Android Auto Backup、设备到设备迁移的数据提取规则和任何云备份;MVP 不提供账号或云同步。未来备份、导出或同步必须另立决策并取得单独、明确的用户选择。
7. 本地自动保存与 AI 数据发送授权完全独立。自动保存开启、历史中已有内容或用户曾同意旧请求,都不能触发网络调用;每次 AI 调用仍须满足 AI 调用门。
- 原因:完整的起卦、问题、解读与行动记录共同构成可回顾的个人反思日志;默认保存能避免用户遗漏,但必须通过首页透明告知、设置和单次退出能力保留控制权。
- 未采用:继续要求每次手动保存会频繁丢失预期记录;默认只保存卦象快照会让问卦簿缺少上下文,削弱回顾价值。
- 后果:P4 必须实现保存策略、事务关联、设置、单次退出、删除、迁移和备份排除测试;ADR-012 是生产事实源,决策时尚未同步的 v0.2 原型只能作为旧流程评审材料,现行 v0.3 已完成同步。
- 复审触发:引入账号、导出、云同步、系统备份、跨设备迁移或新的隐私/合规要求。
## ADR-013:仓库门禁采用无外部依赖脚本并由 Gradle 聚合
- 状态:`Accepted`
- 日期:2026-08-04
- 关联:解决 TBD-012、P0/P1/P2/P6
- 决定:在 Android application 壳建立前,使用仓库内 Node 脚本执行格式、文档链接、高置信 secret scan、domain 依赖边界、原型语法和内容完整性检查;Gradle 8.2 的 `verifyLocal` 聚合这些检查与 JVM 测试,CI 调用同一入口。
- 原因:当前系统已实测 Node 22、JDK 17 和 Gradle 8.2,且无需新增全局工具或把个人路径写进工程;门禁错误能够给出可修复的文件位置。
- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。
- 复审触发:Android 工具链启用、现有脚本无法表达新边界,或专用工具能以可接受成本提供明显更强的检查。
## ADR-014:正式 Android 身份与最低版本
- 状态:`Accepted`
- 日期:2026-08-07
- 关联:解决 TBD-001 中的产品名、TBD-002、TBD-003;2026-08-07 用户确认
- 决定:正式产品名为“灵机”;用户提供的组织域名 `flash.opcapp.net` 按 Android 反向域名规则映射为 namespace/application ID `net.opcapp.flash`;`minSdk=26`。仓库根名 `brainwave` 继续只作内部代码代号,不进入 Android 发布身份。
- 原因:稳定的正式身份是创建可安装应用、生成类路径、配置清单和建立设备测试的前置条件;API 26 与已接受架构及当前依赖兼容。
- 备选:使用临时 application ID 会形成迁移、数据目录与签名身份风险;继续等待会阻塞 P0。`flash.opcapp.net` 不能直接作为 Android application ID,因为发布标识采用反向域名。
- 后果:主代码迁移到 `net.opcapp.flash`;Android 壳使用“灵机”和 `minSdk=26`。应用图标、商店文案和签名仍未由本决策确认。当前 `compileSdk/targetSdk=34` 是已安装工具链基线,不代表永久商店目标;发布前须按当时商店要求复核升级。
- 复审触发:组织域名所有权变化、发布账号要求更换 ID,或依赖/覆盖率数据要求提高最低系统版本。application ID 一旦发布不得轻率更换。
## ADR-015:自有白话 + 维基文库公版《易经》原文
- 状态:`Accepted`
- 日期:2026-08-19
- 关联:TBD-005、FR-R-005、P2
- 决定:安装包内的经典原文取自维基文库《周易》的《易经》部分(卦辞、爻辞,以及乾用九、坤用六),不含十翼;现代白话由本项目自撰,标为草稿,待内容负责人审校后才视为可再分发。不把南怀瑾、傅佩荣等现代译注入 APK。
- 原因:需要可核验、可再分发的公版原文,同时避免把仍受版权保护的现代译文误当成古籍公版。
- 备选:等待商业授权会继续阻塞离线阅读;使用来源不明的网络译文无法通过内容门禁。
- 后果:原文事实源为 `content/raw/zhouyi-wikisource-jing.json`,经 `scripts/import-zhouyi-wikisource.mjs` 导入并由 Wikimedia `zh-hans` 转换;白话事实源为 `content/plain/lingji-plain.mjs`;发布包为 `content/packages/hexagram-content.json`,构建时拷入 assets。应用内必须区分“原文”与“本地白话”,并提供内容来源页。P2 在审校签核前不得标为完成。
- 复审条件:维基文库页面结构变化、发现与通行本有影响阅读的异文、或内容负责人改用另一公版底本。
## ADR-016:乾用九、坤用六在内容具备时展示
- 状态:`Accepted`
- 日期:2026-08-19
- 关联:TBD-006、P2/P3
- 决定:内容包声明并收录乾“用九”、坤“用六”。结果页仅在本卦为乾且六爻皆老阳,或本卦为坤且六爻皆老阴时展示对应特殊文本。
- 原因:用九/用六是《易经》文本的一部分,不是另造的占断;只有全动时才按传统用法出示,避免静卦或单爻动时误读。
- 备选:一律隐藏会丢掉已具备的原文;凡见乾坤都展示会把特殊用法当成普通爻辞。
- 后果:`specialUsageTexts.qian/kun` 必须为 true,且仅乾、坤可有 `specialUsageText`。之卦为乾坤但不满足全九/全六时不展示用九/用六。
- 复审条件:产品决定对之卦或非全动情形也提示该文本。
## 未决问题
| ID | 问题 | 推荐默认 | 阻塞阶段 |
|---|---|---|---|
| TBD-001 | 正式产品名与应用图标 | Brainwave 仅作代码代号 | P0 商店配置 |
| TBD-002 | application ID | 使用组织所有的反向域名 | P0 |
| TBD-003 | minSdk | 26;创建工程时复核覆盖率和依赖要求 | P0 |
| TBD-001 | 应用图标与商店素材 | 产品名已由 ADR-014 确认为“灵机”;图标不使用未确认成稿 | P6 商店配置 |
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
| TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 |
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 |
| TBD-007 | 历史是否默认保存卦象 | 默认不保存,完成后询问 | P4 |
| TBD-008 | Android Auto Backup 是否包含历史 | 默认排除敏感历史,待隐私评审 | P4 |
| TBD-005 | 经典原文、现代白话的版本与授权 | 已由 ADR-015 接受;内容负责人审校仍阻塞发布 | P2,发布阻塞 |
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 已由 ADR-016 接受:全九/全六时展示 | P2/P3 |
| TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 |
| TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 |
| TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 |
| TBD-012 | 架构检查工具 | 选择维护活跃工具或小型自定义测试 | P0/P1 |
## 新增决策模板
+44
View File
@@ -0,0 +1,44 @@
# 依赖与许可证清单
> 状态:Android debug runtime、debug unit-test runtime 与 debug instrumentation-test runtime 依赖图已建立自动覆盖门禁
> 适用范围:实际解析的 Android 调试运行时、单元测试/仪器测试运行时,以及直接使用的构建入口
本清单不是上架包的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal`;发布前仍须对 release runtime 生成完整报告,复核许可证文本与随包义务。
## 直接条目
| ID | 版本 | 当前范围 | SPDX | 权威许可来源 |
|---|---:|---|---|---|
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) |
| `android-gradle-plugin` | 8.2.0 | 构建插件 | Apache-2.0 | [Android tools license](https://android.googlesource.com/platform/tools/base/+/studio-2022.2.1-patch4/LICENSE) |
| `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `hilt-gradle-plugin` | 2.48.1 | 构建插件 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `compose-bom` | 2023.10.01 | 运行时版本约束 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `navigation-compose` | 2.7.5 | 运行时 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `room-runtime` | 2.6.1 | 运行时 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `datastore-preferences` | 1.0.0 | 运行时 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `hilt-android` | 2.48.1 | 运行时 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `kotlin-stdlib` | 1.9.20 | 运行时 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `jetbrains-annotations` | 23.0.0 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/org/jetbrains/annotations/23.0.0/annotations-23.0.0.pom) |
| `jsr305` | 3.0.2 | 运行时传递依赖 | BSD-3-Clause | [Maven artifact metadata](https://repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.pom) |
| `guava-listenablefuture` | 1.0 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.pom) |
| `javax-inject` | 1 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.pom) |
| `junit4` | 4.13.2 | 单元测试 | EPL-1.0 | [JUnit license](https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt) |
| `hamcrest-core` | 1.3 | 单元测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `hamcrest-library` | 1.3 | 仪器测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `hamcrest-integration` | 1.3 | 仪器测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `gson` | 2.9.0 | 仪器测试传递依赖 | Apache-2.0 | [Gson license](https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE) |
| `javawriter` | 2.1.1 | 仪器测试传递依赖 | Apache-2.0 | [JavaWriter license](https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt) |
## 传递依赖组策略
下列策略只允许当前解析图中的 Maven group 命中对应许可证;未知 group 会让 Gradle 门禁失败,工程自身产物不参与第三方检查。
| ID | Maven group 前缀 | 当前范围 | SPDX | 权威许可来源 |
|---|---|---|---|---|
| `androidx-runtime-policy` | `androidx` | 运行时传递依赖 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `dagger-runtime-policy` | `com.google.dagger` | 运行时传递依赖 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `kotlin-runtime-policy` | `org.jetbrains.kotlin` | 运行时传递依赖 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `kotlinx-runtime-policy` | `org.jetbrains.kotlinx` | 运行时传递依赖 | Apache-2.0 | [Kotlinx Coroutines license](https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt) |
当前没有第三方字体、纹理、插画或音效进入工程。应用使用 Android 系统字体、代码绘制的卦象,以及 `content/packages/hexagram-content.json` 中的维基文库公版《易经》与项目自撰白话草稿;这句话只描述本提交时的资源图,不构成内容负责人已签核。
+3 -1
View File
@@ -5,6 +5,8 @@
本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。
实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/net/opcapp/flash/domain/casting/`;`.\gradlew.bat :app:testDebugUnitTest` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。
## 1. 术语和类型
建议使用有语义的封闭类型,避免裸 `Int` 在层间传播:
@@ -102,7 +104,7 @@ createdAt // 只用于记录,不参与计算
3. 从各爻阴阳形成本卦模式并查得本卦编号。
4. 收集值为 6 或 9 的位置作为动爻。
5. 仅翻转动爻阴阳,形成之卦模式并查得之卦编号。
6. 将全部原始输入、版本和确定结果构造成不可变 `CastResult`。
6. 先将全部原始输入和确定结果构造成不可变 `CastComputation`,再附加 `methodVersion`、`coinConvention`、`contentVersion` 与 `createdAt` 记录元数据,组成不可变 `CastResult`;元数据不得反向影响步骤 1~5。
## 6. 读取内容的产品规则
+18 -18
View File
@@ -1,7 +1,7 @@
# 本地开发环境基线
> 状态:已从当前主机实测
> 最后核验:2026-08-04
> 最后核验:2026-08-19
> 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证
本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。
@@ -39,7 +39,7 @@
| Node.js / npm | `22.22.1` / `11.12.1` |
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
环境文件写入前,仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。这些属于 Android 工程初始化阶段的交付物,不应被误认为已存在。
首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些入口和可安装的 Android application 已建立;正式应用名为“灵机”,application ID 为 `net.opcapp.flash`。
Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。
@@ -101,31 +101,31 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
| 已验证 Gradle | `8.2` |
| Gradle 使用的 JVM | Temurin `17.0.13` |
| 缓存的 Android Gradle Plugin | `8.2.0` |
| 缓存的 Kotlin Gradle Plugin | `1.9.20` |
| 缓存的 JUnit | `4.13.2` |
| 用户级 `~/.gradle/gradle.properties` | 不存在 |
项目初始化应提交 `gradlew`、`gradlew.bat`、`gradle/wrapper/gradle-wrapper.jar` 和版本明确的 `gradle-wrapper.properties`。所有项目命令使用:
仓库已提交 `gradlew`、`gradlew.bat`、`gradle/wrapper/gradle-wrapper.jar` 和固定 Gradle 8.2 及 SHA-256 的 `gradle-wrapper.properties`。所有项目命令使用:
```powershell
.\gradlew.bat <task>
```
不要要求用户安装全局 Gradle。当前缓存能证明 Gradle 8.2 本体可运行,但不能证明 Compose、Kotlin、Hilt、Room 等全部 Maven 依赖已离线缓存;第一次构建仍需实际验证。
不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、AGP 8.2.0、Kotlin 1.9.20、Compose、Hilt、Room、DataStore、Navigation 与 JUnit 可完成 Android debug 构建、lint、单元测试和测试 APK 编译;首次解析已经联网完成,后续离线可用性仍以实际命令为准。
## 7. 已连接 Android 真机
检测时存在 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
2026-08-07 本轮曾检测到 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
| 项目 | 实测值 |
|---|---|
| 厂商/型号 | OnePlus `PKG110` |
| Android | 16 |
| API Level | 36 |
| ABI | `arm64-v8a` |
| 物理分辨率 | `1264 × 2780` |
| 当前覆盖分辨率 | `1080 × 2376` |
| 物理/覆盖密度 | `560 / 480 dpi` |
| 厂商/型号 | Samsung `SM-G9700` |
| Android | 12 |
| API Level | 31 |
| 物理分辨率 | `1080 × 2280` |
| 物理密度 | `480 dpi` |
当前设备可用于 `adb` 安装、手工验收和 `connectedDebugAndroidTest`。测试脚本不得硬编码序列号;设备可能随时断开,纯 JVM 门禁必须独立可运行。
该设备满足 `minSdk=26`,可用于 `adb` 安装、手工验收和仪器测试。测试脚本不得硬编码序列号;设备可能随时断开,环境独立门禁必须单独可运行。本机 `PATH` 中的 `D:\Portable\adb\adb.exe` 为 ADB 1.0.32,SDK Platform Tools 37.0.0 提供 ADB 1.0.41;两者会争用 5037 端口并互相重启 server。本轮三星设备只在便携 ADB server 下稳定枚举,SDK ADB 重启后设备接口消失,因此真机门禁通过 `run-connected-tests.ps1 -AdbPath` 显式选择单一 ADB。不要同时启动两个 server,也不要把“Windows 能看到 USB 复合设备”误当作 ADB 已连接。
## 8. 网络与代理状态
@@ -144,20 +144,20 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
2. 使用项目自带 Gradle Wrapper,不依赖全局 `gradle` 或 `kotlinc`。
3. 首次可构建配置只能依赖已安装的 SDK Platform 34 和 Build Tools 34.0.0;若模板要求更高版本,应先记录差异,再决定调整模板或安装 SDK。
4. 命令行构建是必须能力,不假设 Android Studio 存在。
5. 仪器测试优先使用已连接的 API 36、arm64 真机;不假设 Emulator/AVD 存在。
5. 仪器测试优先使用当前可用的 API 31 三星真机;不假设 Emulator/AVD 或固定设备存在。
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
## 10. 已知缺口
- Android 工程和 Gradle Wrapper 尚未创建。
- Android Studio 未安装。
- Android Emulator、system image 和 AVD 不可用。
- 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。
- Maven 依赖能否完整离线解析尚未验证。
- 代理已配置,但外部仓库和 Android CLI 网络连通性尚未验证。
- 没有根 `AGENTS.md` 和可执行的 `verifyLocal` 聚合任务。
- `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。
- 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。
已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI、正式 Android 身份和 application 壳均已建立。2026-08-19,`verifyLocal` 通过(含 debug 单元测试、`lintDebug` 与 debug APK,已校验 `zh-Hans-2026.1` 内容包);便携 ADB 下 Samsung API 31 真机 `run-connected-tests.ps1` 得到 `OK (4 tests)`,覆盖方法说明、保存设置、夹具「复 24 → 坤 2、初爻动」本机保存/当次删除(结果页已展示原文/白话),以及 Room 会话读写。未发现应用崩溃日志。飞行模式人工走查、无障碍抽测和内容负责人签核仍未做。
这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
+55 -43
View File
@@ -1,6 +1,6 @@
# 分阶段实施计划
> 状态:P-1 高保真交互原型已完成,待用户视觉确认;P0 尚未开始
> 状态:P0/P1 已完成;P3 离线主流程已落地但未达到 UX 全表验收;P4 会话保存已落地,问卦簿/解读未开始;P2 已接入公版《易经》与自撰白话草稿,待审校签核;P-1 v0.3 待视觉复核
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
## 1. 依赖图
@@ -25,7 +25,8 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务:
- 建立 360 × 792 基准视口的高保真 HTML 原型和设计令牌。
- 覆盖欢迎、起念、六轮录入、结果、本地/AI 选择、同意、成功与失败状态。
- 覆盖欢迎、回访首页、起念、六轮录入、结果、本地/AI 选择、同意、成功与失败状态。
- 覆盖默认本机自动保存、首页告知、设置开关、当次退出、解读关联、清空全部、问卦簿列表/空态/详情和删除确认,并保持首页单一主操作。
- 使用已知夹具核对本卦、之卦、动爻和无动爻布局。
- 导出关键状态截图,并把评审入口与 Android 映射写入[原型说明](prototype.md)。
- 收集用户对视觉、录入控件、结果层级与解释语气的明确反馈。
@@ -37,7 +38,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- 原型不发起外部请求,不伪装真实 AI,不把工作名当作正式决定。
- 用户确认或提出一轮可执行的修改意见;确认前不把视觉固化为生产 Compose 页面。
当前交付物已完成,视觉确认仍待用户评审。
核心 v0.1 已由用户确认;v0.3 已同步 ADR-012 并通过十八状态浏览器审计,首页、问卦簿和保存设置待用户视觉复核。
## 3. P0:仓库与 Android 骨架
@@ -45,35 +46,37 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务:
- 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
- 基于 Google `android/architecture-templates` 的 `base` 分支初始化。
- 确认正式应用名称、package/application ID、minSdk。
- 配置 Kotlin、Compose、Material 3、Hilt、Room、DataStore、Navigation 和 Version Catalog。
- 配置 Gradle Wrapper、格式化、lint、单元测试和 CI。
- 建立 [系统架构](architecture.md)中的包结构和空 feature 边界。
- 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
- 增加 `verifyLocal` 聚合任务及基础 secret scan。
- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
- [x] 参照 Google `android/architecture-templates` 的 `base` 分支建立单模块 Android 壳和分层边界。
- [x] 确认正式应用名称“灵机”、namespace/application ID `net.opcapp.flash`、`minSdk=26`(ADR-014)。
- [x] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation,并由 Version Catalog 固定兼容版本。
- [x] 配置 Gradle Wrapper、格式、Android lint、单元测试、debug APK、依赖许可证门禁和 CI 同入口验证。
- [x] 建立 [系统架构](architecture.md)中的 `app`、`core/designsystem`、`domain`、`data`、`feature/home` 与 `feature/result` 边界;未实施 feature 不创建空包。
- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。
退出条件:
- Windows 上一条命令可执行格式、lint、单测和 debug 构建。
- CI 使用同一组命令。
- 空应用可在模拟器启动,导航到占位欢迎页。
- 开发验证应用可在授权真机启动,并从首页导航到已知领域夹具结果页。
- 没有生产服务密钥或真实内容。
完成证据(2026-08-08):`verifyLocal`、13 个 debug 单元测试、`lintDebug`、主/测试 APK 构建均通过;调试 APK 在 Samsung API 31 设备冷启动到 `MainActivity`,`MainActivityTest` 真机执行 1/1 通过,并验证首页进入“复 24 → 坤 2、初爻动”结果页。因本机 ADB 1.0.32/1.0.41 server 冲突,设备门禁使用仓库脚本显式选择可枚举该设备的 ADB,未改写 SDK 工具。
## 4. P1:领域核心
目标:在纯 Kotlin 中完成并证明三枚铜币算法。
任务:
- 建立 `CoinSide`、`LineValue`、`Polarity`、`CastRound`、`CastResult`。
- 实现六轮输入校验和 `CastEngine`。
- 建立经过双重校验的 64 卦模式映射表。
- 实现之卦变换和动爻位置。
- 实现版本化序列化 DTO。
- 完成 8 种币面、4,096 种六爻、64 模式和已知夹具测试。
- 增加 domain 无 Android/网络依赖的架构门禁。
- [x] 建立 `CoinSide`、`LineValue`、`Polarity`、`CastRound`、`CastResult`。
- [x] 实现六轮输入校验和纯 `CastEngine`;记录时间与内容版本在计算后附加。
- [x] 建立带完整性自检的 64 卦文王序号映射表。
- [x] 实现之卦变换和 1~6 的 bottom-up 动爻位置。
- [x] 实现 `schemaVersion=1` 的序列化 DTO,并在读取时用保存的十八枚币重新计算校验派生值。
- [x] 完成 8 种币面、4,096 种六爻、64 模式和已知夹具测试。
- [x] 增加 domain 无 Android、数据库和网络依赖的机械架构门禁。
退出条件:
@@ -81,24 +84,26 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- 测试无随机、无网络、无系统时间依赖。
- `CastEngine` API 经评审后冻结为 `coin-v1`。
当前证据:`.\gradlew.bat :app:testDebugUnitTest` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/net/opcapp/flash/domain/casting/`,且领域边界脚本禁止 Android/Compose/数据库/网络依赖。
## 5. P2:内容数据管线
目标:建立可追踪、可校验、可发布的本地内容包。
任务:
- 决定原文版本、现代白话来源和授权。
- 实现 JSON schema、解析器和内容版本。
- 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。
- 建立来源清单、许可证清单和内容审核记录。
- 实现构建期完整性校验与映射交叉校验。
- 实现 `HexagramContentRepository` fake 与 assets 版本。
- [x] 决定原文版本、现代白话来源和授权(TBD-005):ADR-015 接受“自有白话 + 维基文库公版《易经》原文”;发布仍待审校签核。
- [x] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema、Kotlin assets 解析器与 `zh-Hans-2026.1` 包已落地。
- [x] 录入/导入 64 卦、卦辞、384 条爻辞及乾用九、坤用六;白话为项目自撰草稿。
- [x] 建立来源清单、许可证清单和内容审核记录:见 [内容审校](content-review.md);schema 已强制每个来源包含版本、许可证与 URL。
- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。
- [x] 实现 `HexagramContentRepository` fake 与 assets 版本:加载失败时展示完整性错误,不回退到相邻卦。
退出条件:
- 64 卦/384 爻数据完整、唯一且来源可追踪。
- 缺失、重复、非法顺序和错误映射测试均能失败。
- 内容负责人确认可再分发。
- [x] 64 卦/384 爻数据完整、唯一且来源可追踪。
- [x] 缺失、重复、非法顺序和错误映射测试均能失败。
- [ ] 内容负责人确认可再分发。
## 6. P3:核心用户流程与东方设计系统
@@ -106,13 +111,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务:
- 实现颜色、字体、间距、形状和动画令牌。
- 实现卦象 `Canvas`、动爻标记和读屏语义。
- 实现首次说明、起念、投币和结果页面。
- 实现 `CastingSessionViewModel` 状态机及 SavedState 恢复。
- 支持前五轮返回修改、第六轮封印和明确重新起卦。
- 接入本地内容并区分原文/本地白话。
- 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配。
- [x] 实现颜色、字体、间距、形状和动画令牌:当前为 `LingjiTheme` 纸墨朱砂配色与系统衬线/无衬线配对;独立 token 文件、指定字体包和动效仍未做。
- [x] 实现卦象 `Canvas`、动爻标记和读屏语义:结果页绘制本卦/之卦,动爻同时使用颜色、圆点和文字。
- [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。
- [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。
- [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。
- [x] 接入本地内容并区分原文/本地白话:结果页展示本卦卦辞、动爻爻辞和之卦卦辞,并标注原文/白话;乾全九、坤全六时展示用九/用六。白话仍为待审校草稿。
- [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。
退出条件:
@@ -121,23 +126,30 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- AI/网络代码尚未存在也不影响流程。
- [UX 与东方视觉](ux-design.md)检查表通过。
当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。内容包已接入但白话待审校。
## 7. P4:本地解释与历史
目标:形成不依赖 AI 的完整 MVP。
任务:
- 实现版本化本地解释模板。
- 实现“可以试的一小步”的非裁决式结构。
- 实现 Room 历史、可选保存问题、详情和删除。
- 实现 DataStore 设置与同意版本基础设施。
- 完成 migration、删除、隐私和离线测试。
- [ ] 实现版本化本地解释模板。
- [ ] 实现“可以试的一小步”的非裁决式结构。
- [x] 实现 Room 历史会话:第六爻锁定后按 `autoSaveHistory` 写入 `casting_sessions` 快照;问题原文受内容开关控制。解读与行动表尚未建立。
- [x] 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;总开关关闭后结果页提供“保存本次”,已保存时可“本次不保存/删除本次记录”。解读/行动开关目前只保留偏好,不写入任何正文。
- [ ] 实现按时间倒序的问卦簿、空态、详情、解释版本、单条删除和清空全部确认。首页仅展示条数与最近一次派生摘要,不进入列表。
- [x] 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
- [x] 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件;`verifyLocalDataBoundary` 锁定 `allowBackup=false` 且无 INTERNET 权限。
- [ ] 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试:schema 1 无需迁移;编解码、Room 仪器测试和首页设置仪器测试已有,策略组合与级联删除未覆盖。
退出条件:
- 用户可选择不保存问题而保存卦象结果。
- 默认设置下,完成起卦会写入一条包含问题的 Room 会话,随后生成的解读会关联到同一会话且不静默覆盖旧版本。
- 总开关关闭、单个内容开关关闭和“本次不保存”均按契约生效;未完成草稿不进入 Room。
- 首页准确告知本机保存与 AI 发送边界,不存在未上线功能的空占位。
- 本地解释在无网络时可用。
- 删除行为与备份策略一致且经过验证。
- 单条删除、清空全部与备份排除策略一致且经过验证。
- 此阶段已经是可发布的本地版候选。
## 8. P5:AI 解读
@@ -169,7 +181,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
贯穿所有阶段:
- 建立 CI、架构检查、内容校验、secret scan 和依赖许可证报告。
- [x] 建立 CI、架构检查、内容校验、secret scan 和当前 JVM 依赖许可证报告;Android release 依赖启用后扩展报告。
- 建立脱敏崩溃监控和最小匿名指标。
- 增加可复现 screenshot/accessibility 测试环境。
- 准备隐私政策、内容来源、免责声明和应用商店素材。
+10 -4
View File
@@ -1,7 +1,7 @@
# 产品规格
> 状态:MVP 方案基线
> 产品代号:Brainwave(正式名称 TBD)
> 产品代号:Brainwave(正式名称:灵机)
> 原始输入:[原始需求.txt](原始需求.txt)
## 1. 产品定义
@@ -71,7 +71,7 @@ Brainwave 帮助用户把一件正在纠结的事放慢来看。应用保留三
- **FR-Q-001** 应用允许用户输入当前纠结的问题,建议长度 1–200 个 Unicode 字符。
- **FR-Q-002** 问题字段必须有可见标签和隐私说明,不能仅使用占位文字。
- **FR-Q-003** MVP 不要求账号、手机号或身份信息。
- **FR-Q-004** 问题在本地保存与否必须由用户可见的设置或保存动作决定,不得默认上传。
- **FR-Q-004** 问题是否在本地长期保存必须由首页说明、可见设置和当次操作共同控制;任何本地保存状态都不得被解释为上传授权。
### 5.2 投币与成卦
@@ -103,9 +103,13 @@ Brainwave 帮助用户把一件正在纠结的事放慢来看。应用保留三
### 5.5 历史记录
- **FR-H-001** 历史记录属于 MVP 后半段能力;核心起卦不依赖它。
- **FR-H-002** 保存时必须允许用户选择是否保留问题原文。
- **FR-H-002** 设置提供“自动保存完整记录”总开关,以及问题原文、解读全文和行动记录三个内容开关;四项默认开启,总开关关闭时内容开关同步停用。
- **FR-H-003** 删除记录必须可撤销或经过确认。
- **FR-H-004** 未获得单独授权时,历史记录只保存在应用私有存储中。
- **FR-H-004** 历史记录保存在应用私有存储中,并默认排除 Android Auto Backup、设备迁移数据提取和云备份;未来导出、备份或同步必须单独设计并获得明确选择。
- **FR-H-005** 第六爻确认并锁定结果后,若自动保存开启,应用必须立即创建历史会话;未完成草稿不得进入长期历史。自动保存关闭时,结果页仍提供“保存本次”。
- **FR-H-006** 自动保存的完整记录包含可复核起卦快照,并按内容开关保存问题原文、当次解读全文和行动记录;设置默认值均为开启。
- **FR-H-007** 首页必须持续说明“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”并提供设置入口;结果页必须显示当次保存状态,并允许“本次不保存/删除本次记录”。
- **FR-H-008** 当次稍后生成的本地或 AI 解读必须按设置关联到同一会话;重新解读不得静默覆盖既有解读,必须新增版本或由用户明确确认替换。自动保存不能触发或授权 AI 请求。
## 6. 非功能需求
@@ -113,6 +117,7 @@ Brainwave 帮助用户把一件正在纠结的事放慢来看。应用保留三
- **NFR-DET-001** 相同的六次投币输入和同一数据版本必须产生完全相同的本卦、动爻和之卦。
- **NFR-SEC-001** 开发者 AI 密钥不得进入 APK、源码仓库或客户端日志。
- **NFR-PRI-001** 问题文本、解释全文和模型请求不得写入分析埋点或崩溃日志。
- **NFR-PRI-002** Room 历史数据库及其辅助文件不得进入系统自动备份、设备迁移或任何未单独授权的云端副本。
- **NFR-A11Y-001** 正文对比度至少 4.5:1;交互目标至少 48×48dp;支持系统字体缩放和读屏。
- **NFR-PERF-001** 本地起卦为纯内存同步计算,用户确认后应即时完成,不显示伪加载动画。
- **NFR-RES-001** 旋转、切后台和可恢复的进程重建后保留当前流程状态。
@@ -139,5 +144,6 @@ MVP 可以交付的最低条件:
5. 用户能清楚分辨“经典/本地内容”“AI 解释”和“建议行动”。
6. 无障碍检查覆盖投币控件、卦象、动爻和长文阅读。
7. 64 卦及爻辞内容通过完整性、来源和授权门禁。
8. 默认设置下完成起卦会生成一条仅在本机的完整记录;用户可以全局关闭、当次退出、单条删除或清空全部,且这些操作不会触发网络请求。
需求与验证方式的对应关系见[质量门禁](quality-gates.md#4-需求追踪矩阵)。
+67 -14
View File
@@ -1,6 +1,6 @@
# 移动端交互原型
> 状态:高保真候选 v0.1,待用户视觉与流程确认
> 状态:高保真候选 v0.3;核心 v0.1 已确认,首页、问卦簿与默认本机保存流程已实现并待视觉复核
> 基准视口:360 × 792 CSS px(对应当前目标手机的紧凑竖屏体验)
> 实现位置:[`prototype/`](../prototype/)
@@ -8,13 +8,15 @@
在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
本轮使用“一问”作为视觉工作名。它不构成正式命名决定;正式名称、应用图标和商店素材仍是 [TBD-001](decisions.md#未决问题)。
v0.3 截图仍保留当时的视觉工作名“一问”,不得当作当前产品文案。正式产品名已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认为“灵机”;应用图标与商店素材仍见 [TBD-001](decisions.md#未决问题),P3 固化正式页面前须刷新原型品牌文案。
## 2. 交付范围
原型已经覆盖:
- 欢迎与方法说明;
- 首次欢迎与回访首页两种状态;
- 首页唯一主操作、“最近一次”和问卦簿次级入口;
- 开放式问题输入,以及明确的“不写具体内容”入口;
- 三枚硬币逐枚录入、字/背计值、六轮 bottom-up 进度和撤销上一爻;
- 本地确定性计算本卦、之卦和全部动爻;
@@ -22,13 +24,17 @@
- 经典原文、本地白话和动爻的来源标签;
- “解”的本地/AI 两种分支;
- AI 发送范围说明、逐次同意、加载、成功、失败和本地降级;
- 首页本机保存/AI 发送边界说明与保存设置入口;
- 默认开启的完整记录自动保存、总开关与三个内容开关;
- 结果保存状态、当次不保存、自动保存关闭后的“保存本次”和解读关联反馈;
- 问卦簿列表、空状态、记录详情、单条删除和清空全部确认;
- 减少动态效果、键盘焦点、语义标签和最小触控目标。
本轮不包含:
- Android/Compose 生产代码;
- 深色主题、平板和横屏定稿;
- 历史记录与设置页面;
- Room 真实持久化、历史迁移和系统 backup/data-extraction 配置;
- 真实 AI 请求、账户、服务端或持久化;
- 完整 64 卦授权内容包;
- 正式名称、图标、字体授权和商店视觉。
@@ -72,6 +78,16 @@ node prototype\capture.mjs
| AI 解读 | `?view=explanation` | 来源标签、非裁决语言、行动收束 |
| 本地解读 | `?view=local` | 离线分支与来源区分 |
| AI 失败 | `?view=error` | 结果保留、重试和本地降级 |
| 回访首页 | `?view=home` | 单一主操作、最近一次、问卦簿入口 |
| 回访首页空态 | `?view=home-empty` | 无记录时不显示空功能宫格 |
| 问卦簿 | `?view=history` | 倒序列表、问题/解释保存状态 |
| 问卦簿空态 | `?view=history-empty` | 空态说明与开始入口 |
| 历史详情 | `?view=history-detail` | 起卦快照、版本和已保存内容 |
| 保存设置默认态 | `?view=settings` | 四个开关默认开启、本机与备份边界 |
| 保存设置关闭态 | `?view=settings-off` | 总开关关闭、内容开关保留值并停用 |
| 本次未保存结果 | `?view=result-not-saved` | 未保存原因和“保存本次”恢复路径 |
| 删除确认 | `?view=delete` | 级联删除范围和再次确认 |
| 清空全部确认 | `?view=clear-all` | 全量删除范围、设置保持不变 |
这些参数只设置初始预览状态,不属于 Android 正式版路由。
@@ -114,6 +130,19 @@ node prototype\capture.mjs
失败状态不得隐藏或重算卦象,必须同时提供本地解释和重试入口。
### 5.4 首页与历史
- 首次欢迎页继续保持单一“开始”操作;有历史的回访首页使用更紧凑的开场,并在主操作之后展示最近一次。
- 首页不显示空的未来功能卡片。扩展区只渲染已上线入口;至少出现 3 个同级长期功能后才重新评估底部导航。
- 首页显示:“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”并提供“管理保存设置”。
- 第六爻锁定后默认自动创建本机会话;结果页显示保存状态,并支持“本次不保存/删除本次记录”。总开关关闭时显示“本次未保存”和“保存本次”。
- 设置页提供默认开启的总开关、问题原文、解读全文和行动记录开关;总开关关闭时其余控制同步停用。清空全部必须二次确认。
- 本地或 AI 解读按设置加入同一会话;自动保存与 AI 同意完全独立,保存过程不得产生网络调用。
- 问卦簿中的演示记录只存在内存。刷新会恢复预览夹具,不能把原型行为误称为 Room 已实现。
- 删除必须经过确认;正式版级联删除会话、解释和行动记录。
这些状态在原型中使用内存模拟,刷新后恢复预览夹具;它们定义 Compose 的交互契约,但不代表 Room、DataStore 或备份排除已经实现。
## 6. 视觉系统
视觉遵循 [ADR-006](decisions.md#adr-006东方文化采用内容优先的纸墨朱砂设计):
@@ -144,6 +173,18 @@ node prototype\capture.mjs
6. `06-ai-consent.png`
7. `07-ai-explanation.png`
8. `08-ai-error.png`
9. `09-home-returning.png`
10. `10-home-empty.png`
11. `11-history-list.png`
12. `12-history-empty.png`
13. `13-history-detail.png`
14. `14-settings-default.png`
15. `15-delete-confirm.png`
16. `16-settings-off.png`
17. `17-result-not-saved.png`
18. `18-clear-all-confirm.png`
目录中的 `14-save-record.png` 与 `16-save-with-explanation.png` 是 v0.2 手动保存流程的历史评审证据,当前截图脚本不再生成,不能用于 v0.3 验收。
截图是 360 CSS px 宽度的评审证据,不是应用商店素材。
@@ -151,13 +192,15 @@ node prototype\capture.mjs
进入 Android P0/P3 前,需要用户明确反馈或确认:
- [ ] “纸、墨、朱砂、留白”的整体气质是否合适;
- [ ] 工作名只作占位,不把“一问”直接视为正式名称;
- [ ] 三枚硬币用圆形切换控件录入是否容易理解;
- [ ] 结果页先卦象、后原文/白话、再点击“解”的层级是否合适;
- [ ] 本地解读与 AI 解读的选择、同意和来源标签是否足够清楚;
- [ ] “可以试的一小步”的非预测、非裁决语气是否符合定位;
- [ ] 是否需要在下一轮原型加入历史记录、深色主题或更大字号状态。
- [x] “纸、墨、朱砂、留白”的整体气质;
- [x] 工作名只作占位,不把“一问”直接视为正式名称;
- [x] 三枚硬币圆形切换控件与结果阅读层级;
- [x] 本地/AI 选择、同意门和非裁决式行动语气;
- [x] 产品已确认历史默认本机自动保存、可全局关闭和当次退出(ADR-012);
- [ ] 回访首页的“最近一次 / 问卦簿”层级是否保持了主流程焦点;
- [ ] v0.3 首页告知、默认开启设置、结果保存状态、当次退出和清空全部是否足够清楚;
- [ ] 问卦簿列表、空态、详情和删除确认是否适合进入 Compose 实现;
- [ ] 是否在下一轮原型加入深色主题或更大字号状态。
未确认项应继续停留在原型层修改;不应提前固化为 Compose 生产页面。
@@ -167,11 +210,13 @@ node prototype\capture.mjs
|---|---|
| CSS 颜色/间距/圆角令牌 | `MaterialTheme` 扩展与 design token |
| `welcome/question/casting/result` | Navigation destination + 状态驱动页面 |
| `home/history/history-detail` | 顶层首页 + Room `Flow` 驱动的列表/详情 |
| 三枚硬币控件 | 具备明确 semantics 的 48dp+ Compose 控件 |
| 六爻图 | Canvas 绘制 + 完整读屏描述 |
| 底部选择/同意层 | Material 3 modal bottom sheet/dialog |
| 本地/AI/错误来源标签 | sealed UI state,不以颜色作为唯一提示 |
| `prefers-reduced-motion` | 系统动画缩放/无障碍偏好适配 |
| 保存状态/设置/删除确认 | `HistorySavePolicy` 状态驱动 UI + 事务保存/级联删除用例 |
Compose 实现必须从 `CastResult` 渲染,不能在 UI 层复制 King Wen 映射或根据文本猜测卦象。
@@ -179,12 +224,20 @@ Compose 实现必须从 `CastResult` 渲染,不能在 UI 层复制 King Wen
- `node --check prototype/app.js`:通过。
- `node --check prototype/capture.mjs`:通过。
- `node prototype/capture.mjs`:通过;八个状态完成 360px 截图与浏览器审计。
- `node prototype/capture.mjs`:通过;十八个状态完成 360px 截图与浏览器审计。
- `git diff --check`:通过。
- Chromium 360 × 792:欢迎、起念、录入、有/无动爻结果、AI 同意、成功与失败状态已渲染。
- Chromium 360 × 792:核心流程、首页、历史、保存设置、自动保存/未保存状态、删除与清空确认均已渲染。
- 已知夹具 `9,8,8,8,8,8`:显示复 24 → 坤 2,初爻动。
- 静态夹具 `7,7,7,7,7,7`:显示乾 1,无动爻且无之卦。
- 浏览器控制台错误:0;运行时异常:0;外部请求:0;模型调用:0。
- 八个状态的未命名按钮、低于 48px 的按钮触控目标、过小复选框标签:均为 0。
- 首页只有 1 个主流程开始按钮且没有底部导航;问卦簿演示列表为 3 条,删除确认后为 2 条。
- 四个保存开关默认开启;关闭总开关后三个内容开关保留值并停用。
- 结果默认显示自动保存状态;“本次不保存”删除当次记录后可重新“保存本次”,且不会改变全局设置。
- AI 解读生成后显示“解读已加入本次记录”;保存、设置、历史与删除流程产生的外部请求均为 0。
- 清空全部会删除三条演示记录、保留自动保存设置,并返回可操作的设置页。
- 375×812、430×932 与 792×360:无水平溢出,主内容可达;回访首页前进/返回恢复正确。
- `prefers-reduced-motion: reduce`:页面动效压缩至近零时长。
- 新增成功、危险和次级文字组合的对比度均不低于 4.97:1。
- 十八个状态的未命名按钮、低于 48px 的按钮触控目标、过小开关/复选框标签:均为 0。
原型验证不能替代 P1 的 4,096 组合领域测试,也不能替代 Android 真机、TalkBack 与最大字体测试。
原型验证不能替代 P1 的 4,096 组合领域测试,也不能替代 Android 真机、TalkBack 与最大字体测试。v0.3 已满足 ADR-012 的原型门禁,但仍需用户完成视觉复核后才能映射到 Compose。
+48 -21
View File
@@ -1,6 +1,6 @@
# 质量门禁与验证策略
> 状态:测试策略已定义;命令在 Android 骨架初始化后启用
> 状态:Android 本地门禁已启用;设备门禁在授权真机存在时单独执行
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
## 1. 反馈循环
@@ -17,41 +17,53 @@
## 2. 预期本地命令
工程创建后,Windows 环境至少提供以下稳定入口:
```powershell
.\gradlew.bat spotlessCheck
.\gradlew.bat lintDebug
.\gradlew.bat testDebugUnitTest
.\gradlew.bat assembleDebug
.\gradlew.bat connectedDebugAndroidTest
```
若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,应与纯 JVM 快速门禁分开。
建议再提供聚合任务:
当前可重复的 Windows 快速门禁:
```powershell
.\gradlew.bat spotlessCheck --offline
.\gradlew.bat :app:testDebugUnitTest --offline
.\gradlew.bat verifyContentContract --offline
.\gradlew.bat verifyLocal
```
它至少依赖格式、lint、JVM 单元测试和 debug 构建,使代理不必猜测正确验证组合。
`verifyLocal` 聚合无依赖格式检查、领域穷举测试、内容 repository 测试、历史编解码测试、问题校验测试、Android lint、debug APK、已解析 Android 调试/单测/仪器测试依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan、原型 JavaScript 语法检查,以及本机备份排除/无 INTERNET 权限检查。CI 执行同一个聚合任务。首次解析 Android 依赖时不能强制 `--offline`;缓存完成后可用离线模式复核。
当前仓库没有 Gradle Wrapper,所以上述命令尚未运行,也不能报告为通过。
授权真机存在时,Windows 环境还必须执行:
```powershell
.\gradlew.bat spotlessCheck
.\gradlew.bat connectedDebugAndroidTest
```
若本机存在两个不兼容的 ADB server,可在只保留一个 server 的前提下使用等价的可重复入口:
```powershell
.\scripts\run-connected-tests.ps1 -AdbPath <能够稳定识别设备的 adb.exe>
```
该脚本重新构建主/测试 APK、检查授权状态、覆盖安装两者并直接调用同一个 `AndroidJUnitRunner`;失败、零测试或缺少 `OK` 结果都会使脚本失败。若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。设备测试继续与环境独立的 `verifyLocal` 分开;`verifyLocal` 通过证明 APK 已构建,但不等于真机测试已经通过。
## 3. 测试层次
### 3.0 原型验证(P-1 阻塞视觉确认)
当前 v0.3 原型已经同步 ADR-012;以下门禁既覆盖核心起卦流程,也覆盖默认本机自动保存策略。
- `node --check prototype/app.js` 无语法错误。
- `node --check prototype/capture.mjs` 无语法错误。
- `git diff --check` 无空白错误。
- 本地服务运行时,`node prototype\capture.mjs` 退出码为 0。
- Chromium 360 × 792 走通问题输入、六次录入、结果和解释选择。
- Chromium 360 × 792 走通首页、问题输入、六次录入、结果、解释选择和问卦簿。
- `9,8,8,8,8,8` 显示复 24 → 坤 2、初爻动;全 7 显示乾 1、无动爻且无之卦。
- 欢迎、起念、录入、有/无动爻结果、AI 同意、解释和失败状态均有可复现截图。
- 十八个状态均有可复现截图,包括回访首页/空态、历史列表/空态/详情、保存设置开/关、自动保存/本次未保存、删除和清空确认。
- 回访首页只有一个开始主操作,不出现底部导航或未上线功能占位。
- 首页持续显示本机保存/AI 发送边界;四个保存设置默认开启,总开关关闭后内容开关保留值并停用。
- 完成起卦显示自动保存状态;当次不保存、重新保存、解释关联和清空全部均可复现,删除前显示范围并再次确认。
- 375×812、430×932 和 792×360 无水平溢出;减少动态效果媒体偏好生效。
- 浏览器控制台无应用错误;页面不发起外部请求;AI 状态明确标为本地模拟。
自动化审计必须同时断言:以上保存、设置、历史和删除动作产生的外部请求为 0,且 AI 同意门仍须用户单独勾选。
原型通过只表示流程可供评审,不替代纯 Kotlin 领域测试、Compose UI 测试或真机无障碍检查。
### 3.1 纯 JVM 领域测试(最快,阻塞合并)
@@ -72,7 +84,14 @@
- 只在用户点击且同意后调用 AI。
- 并发点击、取消和超时不重复提交。
- Room 保存/读取一致性。
- 删除与撤销/确认行为。
- `autoSaveHistory`、问题、解读和行动四个设置的默认值均为 `true`,且总开关关闭时不自动写入。
- 第六爻锁定只创建一个 session,未完成草稿创建零条;总开关关闭时“保存本次”仍能创建一条。
- 问题、解读和行动内容开关的组合写入准确;当次不保存会删除已自动创建的 session,且不改变全局设置。
- 随后产生的本地/AI 解读关联到同一 session;重新解读不会静默覆盖旧版本。
- 自动保存开启、读取历史或切换保存设置时,AI gateway 收到零次调用;AI 同意状态不被保存设置改变。
- “最近一次”查询随插入/删除更新,且不复制敏感正文。
- 单条删除、清空全部与 session → explanation/action 的级联行为。
- backup/data-extraction 配置测试确认 Room 数据库及 `-wal`、`-shm` 辅助文件不在系统备份或设备迁移范围。
测试优先使用接口的 fake 实现,不依赖真实网络和实时模型。
@@ -82,6 +101,10 @@
- 六轮录入的进度、按钮启用状态和错误恢复。
- 结果页本卦/之卦/动爻语义。
- AI 同意、加载、失败、本地降级。
- 回访首页、最近一次、问卦簿列表/空态/详情和删除确认。
- 首页本机保存/AI 发送说明和设置入口始终可达。
- 保存设置默认开启、总开关联动停用、结果页自动保存状态、当次不保存/保存本次和解释关联反馈。
- 清空全部的危险操作样式、范围说明和二次确认。
- 最大字体下关键操作可到达。
- TalkBack 语义、焦点顺序和触控目标。
- 旋转/横屏/大屏布局。
@@ -93,6 +116,7 @@
- 使用已知六爻夹具人工核对卦象绘制和文本。
- 后端 staging 环境验证同意、超时、限流、非法响应和安全分支。
- 验证安装包不包含生产密钥和未授权内容。
- 从系统备份/设备迁移恢复后验证问卦簿为空,且设置页未暗示历史已经云端备份。
## 4. 需求追踪矩阵
@@ -104,10 +128,10 @@
| FR-C-007 | SavedState/重建测试 | 旋转、切后台、进程恢复 |
| FR-R-001~003 | UI 语义与 screenshot 测试 | TalkBack、色觉与长文阅读 |
| FR-R-004 | 网络失败测试 | 飞行模式 |
| FR-R-005 | 内容 schema/授权清单检查 | 内容负责人签核 |
| FR-R-005 | 内容 schema/授权清单检查 | 自动校验已接入;内容负责人签核仍待完成 |
| FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 |
| FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 |
| FR-H-001~004 | Room、删除、权限边界测试 | 隐私设置与删除体验 |
| FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 |
| NFR-OFF-001 | fake/offline 集成测试 | 飞行模式真机 |
| NFR-DET-001 | 4,096 组合属性测试 | 无 |
| NFR-SEC/PRI | secret scan、日志测试 | APK/代理抓包复核 |
@@ -122,11 +146,14 @@
- `data.ai` 不得依赖或调用 `CastEngine`。
- feature UI 不得引用 DAO 或网络 DTO。
- 生产源码不得出现 API key 形态、真实用户问题测试样例或 HTTP body logger。
- backup/data-extraction 配置不得包含历史 Room 数据库或其辅助文件。
- 内容校验任务验证 64 卦、384 条爻辞、唯一模式、来源和许可证字段。
- 文档链接和需求编号无悬空引用。
可以使用现有静态工具、架构测试库或小型自定义 Gradle 任务;具体选型写入[决策记录](decisions.md)。规则的错误消息应告诉代理如何修复,而不只报告失败。
当前由无外部依赖的 Node 脚本与 Gradle 任务执行上述已落地规则,见 ADR-013。引入 Android 源码后应扩展同一入口,不应建立一套互不相干的新命令。
## 6. 发布门禁
发布候选必须满足:
+64 -7
View File
@@ -2,6 +2,7 @@
> 状态:MVP 设计基线
> 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置
> 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果、原文/本地白话和保存设置;问卦簿列表与「解」仍未进入应用,不能把本文件尚未实现的页面当成已上线功能
## 1. 体验定位
@@ -21,11 +22,15 @@
## 2. 信息架构
MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和清晰的返回行为:
MVP 顶层不需要底部导航。首次欢迎与回访首页是不同状态;核心流程仍保持线性,并使用单 Activity 和清晰的返回行为:
```text
欢迎/方法说明
└── 起念
首次欢迎/方法说明
└── 回访首页
├── 开始一问 → 起念
└── 最近一次 / 问卦簿
起念
└── 投币(1/6…6/6)
└── 结果
├── 本地解释
@@ -54,7 +59,26 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
主操作只有一个:“开始”。“查看方法”是次级文本操作,不制造必须完成的教程轮播。
### 3.2 起念
### 3.2 回访首页与功能扩展
目的:让回访用户快速开始新的一问,同时能回看默认保存在本机、由自己控制的记录。
首页层级固定为:
1. 唯一主操作“开始一问”;
2. “你的观照”次级区域;
3. 存在记录时显示最近一次及“查看问卦簿”;无记录时显示简短空态和隐私说明。
扩展规则:
- 不展示“敬请期待”、禁用卡片或空宫格;只有已上线且用户可进入的功能才出现。
- MVP 只显示问卦簿,不为了预留而引入底部导航。
- 当出现至少 3 个长期、同级、需要频繁切换的顶层功能时,才评估带文字标签的底部导航;最多 5 项。
- “最近一次”由历史查询派生,不复制问题或解释正文;记录删除后首页同步显示下一条或空态。
- 最近卡片必须同时显示日期、卦名、动爻概况和解释来源;问题因设置或当次选择未保存时明确写“未保存问题原文”。
- 首页在主操作附近持续显示:“起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。”末尾提供可点击的“管理保存设置”,但不能把设置做成开始流程的前置门。
### 3.3 起念
页面标题可使用“此刻,你在为何事迟疑?”。问题输入框有固定标签“正在纠结的事”,辅文提示尽量描述事实、选择和顾虑,不要求生日、性别等无关信息。
@@ -65,7 +89,7 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
- 默认不把问题发送到网络;
- 用户可选择“不写具体内容,直接开始”,最终是否允许空值见[决策记录](decisions.md#未决问题)。
### 3.3 投币
### 3.4 投币
页面始终显示:
@@ -79,7 +103,7 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
每次确认可以有一次轻触觉反馈和 150–250ms 的爻线出现动画。不得使用持续摇晃、金币飞散、音效倒计时或强制等待。开启“减少动态效果”时直接更新状态。
### 3.4 成卦与观象
### 3.5 成卦与观象
信息优先级:
@@ -93,7 +117,7 @@ MVP 顶层不需要底部导航。主流程是线性的,使用单 Activity 和
原文、本地白话和 AI 解释必须有明确标签,不使用视觉相似但来源不明的段落混排。
### 3.5 解读与落事
### 3.6 解读与落事
「解」是结果页唯一主操作。点击后先选择或显示当前方式:
@@ -104,6 +128,39 @@ AI 加载超过 300ms 时显示内联进度;按钮在请求期间禁用,避
结尾固定使用“可以试的一小步”区域,包含时间范围和撤销方式;它是建议,不是判词。
### 3.7 保存与问卦簿
第六爻确认并生成结果后,默认在后台立即创建本机会话。结果页在不打断阅读的位置显示“已保存到本机问卦簿”,并提供次级操作“本次不保存/删除本次记录”;它们不能抢占结果页唯一主操作「解」。自动保存关闭时,同一位置显示“本次未保存”和“保存本次”。
设置页必须提供以下控制:
- “自动保存完整记录”,默认开启;
- “保存问题原文”,默认开启;
- “保存解读全文”,默认开启;
- “保存行动记录”,默认开启;
- “清空全部问卦记录”,使用危险操作样式并再次确认。
关闭总开关后,三个内容开关保持其值但在界面中同步停用,便于再次开启时恢复用户偏好。总开关不影响未完成草稿的进程恢复,也不能改变 AI 同意状态。
保存边界必须分层说明:
- 固定快照包含 18 枚币面、六爻结果、动爻、方法版本和内容版本;
- 问题原文、当次解读和行动记录由各自开关控制;
- 未完成问题与投币只用于当前流程恢复,不进入问卦簿;
- 存储范围是应用私有本机存储,默认不进入系统备份、设备迁移或云同步;
- 本地保存与 AI 发送是两个独立决定,自动保存绝不触发网络请求。
本地或 AI 解读在生成后应自动关联到当次会话,并给出轻量的“已加入本次记录”反馈。重新解读默认新增版本;若产品只保留一个版本,覆盖前必须让用户明确确认。
问卦簿要求:
- 列表按保存时间倒序,展示卦名、动爻概况、问题保存状态和解释来源;
- 空状态说明“完成起卦后会默认保存在本机;你可以在设置中关闭”,并提供“开始第一次记录”和“管理保存设置”;
- 详情展示保存时的卦象快照、问题状态、解释来源、方法/内容版本和可选行动;
- 删除使用明确确认对话框,并级联删除该会话关联的解释和行动记录;
- “清空全部”必须在设置或问卦簿菜单中二次确认,并清楚说明不可恢复范围;
- 返回列表后保留原滚动位置;从问卦簿开始新流程时,返回行为不得跳到首次欢迎页。
## 4. 视觉系统
### 4.1 风格关键词
+3
View File
@@ -0,0 +1,3 @@
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+46
View File
@@ -0,0 +1,46 @@
[versions]
agp = "8.2.0"
kotlin = "1.9.20"
composeBom = "2023.10.01"
composeCompiler = "1.5.5"
coreKtx = "1.12.0"
activityCompose = "1.8.1"
lifecycle = "2.6.2"
navigation = "2.7.5"
room = "2.6.1"
dataStore = "1.0.0"
hilt = "2.48.1"
junit = "4.13.2"
androidxTestExt = "1.1.5"
androidxTestRunner = "1.5.2"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5 -2
View File
@@ -10,17 +10,20 @@ python -m http.server 4173 --bind 127.0.0.1
打开 `http://127.0.0.1:4173/prototype/`。完整状态入口、领域边界、评审清单和 Android 映射见 [`docs/prototype.md`](../docs/prototype.md)。
服务保持运行时,可用当前系统的 Chrome/Edge 重新生成八张截图并执行浏览器审计:
服务保持运行时,可用当前系统的 Chrome/Edge 重新生成 18 张截图并执行浏览器审计:
```powershell
node prototype\capture.mjs
```
脚本使用 Node 22 自带的 WebSocket 直接连接 Chrome DevTools,不安装 npm 包;它会核对两个已知卦象、AI 同意门、控制台/外部请求、按钮名称和 48px 最小触控目标。任一门禁失败时退出码为非零。
脚本使用 Node 22 自带的 WebSocket 直接连接 Chrome DevTools,不安装 npm 包;它会生成 18 个评审状态,并核对两个已知卦象、AI 同意门、首页层级、历史列表/空态、保存设置、自动保存/当次退出、解读关联、删除/清空流程、控制台/外部请求、按钮名称和 48px 最小触控目标。任一门禁失败时退出码为非零。
当前代码是 v0.3 高保真候选,已经同步 ADR-012 的默认本机自动保存策略;所有记录和设置仍只存在于浏览器内存,用于评审而非真实持久化。
约束:
- 不发起外部请求,不调用 AI;
- 不生成随机卦,用户逐枚录入硬币;
- 查询参数只用于稳定预览和截图;
- 问卦簿使用内存演示数据,刷新会恢复夹具,不代表 Room 已实现;
- 工作名“一问”与当前文字内容均为评审候选,不代表正式命名或内容授权完成。
+552 -13
View File
@@ -3,6 +3,32 @@
const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
const SAMPLE_HISTORY = [
{
id: "sample-1",
date: "今天 · 21:10",
lines: [9, 8, 8, 8, 8, 8],
question: SAMPLE_QUESTION,
explanationSource: "AI 生成",
action: "写下三条已知事实,再约一次不超过 20 分钟的信息沟通。"
},
{
id: "sample-2",
date: "昨天 · 07:40",
lines: [8, 8, 7, 8, 8, 8],
question: null,
explanationSource: "本地解读",
action: null
},
{
id: "sample-3",
date: "7 月 30 日 · 22:15",
lines: [7, 7, 8, 8, 7, 6],
question: "这段合作应该怎样调整边界?",
explanationSource: null,
action: "先把需要确认的职责写成一页清单。"
}
];
const HEXAGRAM_NAMES = [
"", "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", "小畜", "履", "泰", "否",
@@ -29,7 +55,8 @@
lock: '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8.5 10V7.5a3.5 3.5 0 017 0V10"/></svg>',
arrow: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>',
chevron: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>',
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>'
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>',
book: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5.5A2.5 2.5 0 016.5 3H11v16H6.5A2.5 2.5 0 004 21.5v-16zM20 5.5A2.5 2.5 0 0017.5 3H13v16h4.5a2.5 2.5 0 012.5 2.5v-16z"/></svg>'
};
const screen = document.querySelector("#screen");
@@ -45,9 +72,27 @@
modal: null,
consent: false,
explanationSource: "ai",
aiError: false
aiError: false,
historyRecords: [],
selectedRecordId: null,
autoSaveHistory: true,
saveQuestion: true,
saveExplanation: true,
saveAction: true,
saved: false,
currentRecordId: "current-session",
currentSessionOptedOut: false,
currentSaveMode: null,
explanationGenerated: false,
questionOrigin: "welcome",
settingsOrigin: "home",
deleteReturnScreen: "history"
};
function sampleHistory() {
return SAMPLE_HISTORY.map((record) => ({ ...record, lines: [...record.lines] }));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
@@ -103,6 +148,59 @@
};
}
function hasSavableQuestion() {
return Boolean(state.question && !state.question.startsWith("未记录具体问题"));
}
function generatedExplanationSource() {
if (!state.explanationGenerated || !state.saveExplanation) return null;
return state.explanationSource === "ai" ? "AI 生成" : "本地解读";
}
function currentSessionRecord() {
return state.historyRecords.find((record) => record.id === state.currentRecordId);
}
function persistCurrentSession(mode = "manual") {
if (state.lines.length !== 6) return;
const data = resultData();
const existing = currentSessionRecord();
const record = {
id: state.currentRecordId,
date: "刚刚",
lines: [...data.sourceLines],
question: state.saveQuestion && hasSavableQuestion() ? state.question : null,
explanationSource: generatedExplanationSource(),
action: state.explanationGenerated && state.saveAction
? "写下三条已知事实,再进行一次短沟通。"
: null
};
if (existing) Object.assign(existing, record);
else state.historyRecords.unshift(record);
state.saved = true;
state.currentSessionOptedOut = false;
state.currentSaveMode = mode;
}
function attachExplanationToCurrentSession() {
const record = currentSessionRecord();
if (!record || !state.saved) return;
if (state.saveExplanation) record.explanationSource = generatedExplanationSource();
if (state.saveAction) {
record.action = "写下三条已知事实,再进行一次短沟通。";
}
}
function prepareNewSession() {
state.currentRecordId = `current-session-${Date.now()}-${state.historyRecords.length}`;
state.saved = false;
state.currentSessionOptedOut = false;
state.currentSaveMode = null;
state.explanationGenerated = false;
state.aiError = false;
}
function stepper(current, total) {
const progress = Math.round((current / total) * 100);
return `
@@ -141,6 +239,259 @@
</section>`;
}
function historyRecordData(record) {
const changedLines = transformLines(record.lines);
const movingIndexes = record.lines
.map((value, index) => lineIsMoving(value) ? index : -1)
.filter((index) => index >= 0);
return {
primary: lookupHexagram(record.lines),
changed: lookupHexagram(changedLines),
changedLines,
movingIndexes
};
}
function recordRoute(record) {
const data = historyRecordData(record);
return data.movingIndexes.length
? `${data.primary.name} <span>之</span> ${data.changed.name}`
: data.primary.name;
}
function recentRecordCard(record) {
const data = historyRecordData(record);
return `
<button class="recent-card" type="button" data-action="open-history-detail" data-record-id="${record.id}">
<span class="recent-card__top">
<span>${record.date}</span>
<span class="source-badge">最近一次</span>
</span>
<span class="recent-card__route">${recordRoute(record)}</span>
<span class="recent-card__question">${escapeHtml(record.question ?? "未保存问题原文")}</span>
<span class="recent-card__meta">
<span>${data.movingIndexes.length ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
<span>${record.explanationSource ?? "仅保存卦象"}</span>
${icons.chevron}
</span>
</button>`;
}
function homeView() {
const latest = state.historyRecords[0];
return `
<section class="page page--home" aria-labelledby="home-title">
<header class="home-intro">
<p class="eyebrow">慢一点,看清当下</p>
<h1 id="home-title">此刻,有什么想安静看清?</h1>
<p class="lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</p>
<button class="button button--primary home-primary" type="button" data-action="begin">
开始一问 ${icons.arrow}
</button>
</header>
<section class="home-shelf" aria-labelledby="reflection-shelf-title">
<div class="section-heading home-shelf__heading">
<h2 id="reflection-shelf-title">你的观照</h2>
<button class="text-link" type="button" data-action="open-history">查看问卦簿</button>
</div>
${latest ? recentRecordCard(latest) : `
<button class="home-empty-card" type="button" data-action="open-history">
<span class="home-empty-card__icon">${icons.book}</span>
<span><strong>问卦簿尚无记录</strong><small>${state.autoSaveHistory ? "完成起卦后会默认保存在本机" : "自动保存已关闭,可在结果页保存本次"}</small></span>
${icons.chevron}
</button>`}
<aside class="local-data-card" data-local-data-notice>
<span class="local-data-card__icon">${icons.lock}</span>
<div>
<strong>起卦与历史默认保存在本机,不主动上传。</strong>
<p>只有你选择 AI 解读时,本次所需内容才会发送。</p>
<button class="text-link" type="button" data-action="open-settings">管理保存设置</button>
</div>
</aside>
</section>
</section>`;
}
function historyCard(record) {
const data = historyRecordData(record);
return `
<li>
<button class="history-card" type="button" data-action="open-history-detail" data-record-id="${record.id}">
<span class="history-card__top">
<span>${record.date}</span>
<span>${data.movingIndexes.length ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
</span>
<span class="history-card__route">${recordRoute(record)}</span>
<span class="history-card__question">${escapeHtml(record.question ?? "未保存问题原文")}</span>
<span class="history-card__footer">
<span>${record.explanationSource ?? "仅保存卦象"}</span>
${icons.chevron}
</span>
</button>
</li>`;
}
function historyView() {
const hasRecords = state.historyRecords.length > 0;
return `
<section class="page page--compact page--history" aria-labelledby="history-title">
<p class="eyebrow">本机记录</p>
<div class="history-title-row">
<h1 id="history-title">问卦簿</h1>
${hasRecords ? `<span class="source-badge">${state.historyRecords.length} 条</span>` : ""}
</div>
<p class="lede">回看当时看见了什么,而不是用旧结果替今天做决定。</p>
<div class="privacy-strip">
${icons.lock}
<span>记录只在本机,默认排除系统备份与设备迁移;你可随时删除。</span>
</div>
${hasRecords ? `
<ol class="history-list" data-history-list>
${state.historyRecords.map(historyCard).join("")}
</ol>` : `
<div class="history-empty" data-history-empty>
<span class="history-empty__mark" aria-hidden="true"><i></i><i></i></span>
<h2>还没有保存的记录</h2>
<p>${state.autoSaveHistory ? "完成一次起卦后,会默认把问题、卦象与后续解读保存在本机。" : "自动保存已经关闭;完成后仍可在结果页选择保存本次。"}</p>
<button class="text-link" type="button" data-action="open-settings">管理保存设置</button>
</div>`}
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="begin">
${hasRecords ? "开始新的一问" : "开始第一次记录"} ${icons.arrow}
</button>
</div>
</section>`;
}
function settingRow({ action, title, description, checked, disabled = false, detail = false }) {
return `
<label class="setting-row${detail ? " setting-row--detail" : ""}${disabled ? " setting-row--disabled" : ""}">
<span class="setting-row__copy"><strong>${title}</strong><small>${description}</small></span>
<input
class="switch-control"
type="checkbox"
role="switch"
data-action="${action}"
${checked ? "checked" : ""}
${disabled ? "disabled" : ""}
/>
</label>`;
}
function settingsView() {
const detailsDisabled = !state.autoSaveHistory;
return `
<section class="page page--compact page--settings" aria-labelledby="settings-title">
<p class="eyebrow">本机与隐私</p>
<h1 id="settings-title">保存设置</h1>
<p class="lede">默认留住完整的反思过程;你可以全局关闭,也可以只调整保存内容。</p>
<div class="settings-boundary">
${icons.shield}
<div>
<strong>只保存在 App 私有存储</strong>
<p>历史不会主动上传,并默认排除系统备份、设备迁移与云同步。</p>
</div>
</div>
<section class="settings-card" aria-label="历史保存选项">
${settingRow({
action: "auto-save-toggle",
title: "自动保存完整记录",
description: state.autoSaveHistory ? "已开启 · 完成起卦后立即保存到本机" : "已关闭 · 结果页仍可手动保存本次",
checked: state.autoSaveHistory
})}
<div class="settings-card__details${detailsDisabled ? " settings-card__details--disabled" : ""}">
${settingRow({
action: "save-question-setting",
title: "保存问题原文",
description: "与你写下的卦象背景一同回顾",
checked: state.saveQuestion,
disabled: detailsDisabled,
detail: true
})}
${settingRow({
action: "save-explanation-setting",
title: "保存解读全文",
description: "本地或 AI 解读会加入同一次记录",
checked: state.saveExplanation,
disabled: detailsDisabled,
detail: true
})}
${settingRow({
action: "save-action-setting",
title: "保存行动记录",
description: "保留“可以试的一小步”与完成状态",
checked: state.saveAction,
disabled: detailsDisabled,
detail: true
})}
</div>
</section>
<p class="settings-ai-note">这些开关只控制本机历史,不会允许或触发 AI。每次 AI 解读仍需要你主动选择并确认发送范围。</p>
<section class="settings-danger" aria-labelledby="history-management-title">
<div>
<h2 id="history-management-title">管理本机记录</h2>
<p>当前原型会话中有 ${state.historyRecords.length} 条记录。</p>
</div>
<button class="button button--danger-quiet" type="button" data-action="open-clear-all" ${state.historyRecords.length ? "" : "disabled"}>清空全部问卦记录</button>
</section>
</section>`;
}
function historyDetailView() {
const record = state.historyRecords.find((item) => item.id === state.selectedRecordId);
if (!record) return historyView();
const data = historyRecordData(record);
const hasMoving = data.movingIndexes.length > 0;
return `
<article class="page page--compact page--history-detail" aria-labelledby="history-detail-title">
<header class="history-detail-header">
<p class="eyebrow">${record.date}</p>
<h1 id="history-detail-title">${recordRoute(record)}</h1>
<p>保存的起卦快照</p>
</header>
<div class="result-hero history-result-hero">
<div class="result-kicker"><span>coin-v1 · 本地快照</span><span>${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span></div>
<div class="hexagram-pair${hasMoving ? "" : " hexagram-pair--single"}">
${hexagramFigure(record.lines, data.primary, true)}
${hasMoving ? `<div class="transform-arrow" aria-label="变化为">之</div>${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
</div>
</div>
<section class="content-section" aria-labelledby="saved-question-title">
<div class="section-heading"><h2 id="saved-question-title">当时所问</h2><span class="source-badge">${record.question ? "已保存" : "未保存"}</span></div>
<div class="content-card"><p>${escapeHtml(record.question ?? "这条记录没有保存问题原文。卦象结果和方法版本仍可独立复核。")}</p></div>
</section>
<section class="content-section" aria-labelledby="saved-content-title">
<div class="section-heading"><h2 id="saved-content-title">保存内容</h2></div>
<dl class="record-facts">
<div><dt>起卦结果</dt><dd>${data.primary.name}${hasMoving ? `之${data.changed.name}` : ""} · ${data.movingIndexes.length ? data.movingIndexes.map((index) => lineName(record.lines[index], index)).join("、") : "无动爻"}</dd></div>
<div><dt>解释来源</dt><dd>${record.explanationSource ?? "未保存解释"}</dd></div>
<div><dt>版本</dt><dd>coin-v1 · content-demo</dd></div>
</dl>
</section>
${record.action ? `
<section class="content-section" aria-labelledby="saved-action-title">
<div class="small-step"><div class="small-step__label" id="saved-action-title">当时记下的一小步</div><p>${escapeHtml(record.action)}</p></div>
</section>` : ""}
<div class="detail-danger-zone">
<button class="button button--danger-quiet" type="button" data-action="delete-record">删除这条记录</button>
<p>删除前会再次确认;原型刷新后恢复演示数据。</p>
</div>
</article>`;
}
function questionView() {
return `
<section class="page" aria-labelledby="question-title">
@@ -158,7 +509,7 @@
data-question-input
>${escapeHtml(state.question)}</textarea>
<div class="field-meta">
<span>只保存在本次原型会话中</span>
<span>${state.autoSaveHistory ? "完成起卦后默认保存在本机" : "自动保存已关闭,本次可稍后手动保存"}</span>
<span data-char-count>${state.question.length}/120</span>
</div>
@@ -307,6 +658,39 @@
</div>`;
}
function currentSavePanel({ compact = false } = {}) {
if (state.saved) {
const explanationAttached = state.explanationGenerated && state.saveExplanation;
const savedContext = state.saveQuestion && hasSavableQuestion() ? "问题、卦象" : "卦象";
return `
<div class="current-save-panel${compact ? " current-save-panel--compact" : ""}" data-current-save-status>
<div class="current-save-panel__status">
<span class="current-save-panel__icon">${icons.book}</span>
<span>
<strong>${explanationAttached ? "解读已加入本次记录" : state.currentSaveMode === "automatic" ? "已自动保存到本机" : "已保存到本机问卦簿"}</strong>
<small>${explanationAttached ? `与${savedContext}归在同一次观照中` : `${savedContext}快照已进入问卦簿`}</small>
</span>
</div>
<div class="current-save-panel__actions">
<button class="text-link" type="button" data-action="open-history">查看问卦簿</button>
<button class="text-link text-link--danger" type="button" data-action="remove-current-record">本次不保存</button>
</div>
</div>`;
}
return `
<div class="current-save-panel current-save-panel--off${compact ? " current-save-panel--compact" : ""}" data-current-save-status>
<div class="current-save-panel__status">
<span class="current-save-panel__icon">${icons.lock}</span>
<span>
<strong>本次未保存</strong>
<small>${state.currentSessionOptedOut ? "你已选择不保留这次记录" : "自动保存已关闭;不会写入问卦簿"}</small>
</span>
</div>
<button class="button button--secondary" type="button" data-action="save-current">保存本次</button>
</div>`;
}
function resultView() {
const data = resultData();
const copy = resultCopy(data);
@@ -365,7 +749,10 @@
</div>` : ""}
<hr class="divider" />
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
<div class="result-secondary-actions">
${currentSavePanel()}
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
</div>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="open-interpretation">
@@ -430,11 +817,58 @@
<p class="safety-note">这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。</p>
<div class="bottom-action">
<button class="button button--secondary" type="button" data-action="back-to-result">回到起卦结果</button>
${currentSavePanel({ compact: true })}
<button class="button button--quiet" type="button" data-action="back-to-result">回到起卦结果</button>
</div>
</article>`;
}
function deleteRecordModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet modal-sheet--dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-description" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="delete-modal-title">删除这条记录?</h2>
<p id="delete-modal-description">卦象、问题、已保存解读和行动记录会一并从本机删除。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="notice notice--danger">
${icons.shield}<p>原型使用演示数据;正式版删除不可恢复,因此必须在这里再次确认。</p>
</div>
<div class="modal-actions">
<button class="button button--danger" type="button" data-action="confirm-delete">确认删除</button>
<button class="button button--secondary" type="button" data-action="close-modal">取消</button>
</div>
</section>
</div>`;
}
function clearAllModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet modal-sheet--dialog" role="alertdialog" aria-modal="true" aria-labelledby="clear-modal-title" aria-describedby="clear-modal-description" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="clear-modal-title">清空全部记录?</h2>
<p id="clear-modal-description">当前 ${state.historyRecords.length} 条记录的卦象、问题、解读和行动内容都会从本机删除。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="notice notice--danger">
${icons.shield}<p>这项操作不可恢复,但不会改变你的自动保存设置。</p>
</div>
<div class="modal-actions">
<button class="button button--danger" type="button" data-action="confirm-clear-all">确认清空全部</button>
<button class="button button--secondary" type="button" data-action="close-modal">取消</button>
</div>
</section>
</div>`;
}
function interpretationModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
@@ -532,6 +966,8 @@
if (state.modal === "interpretation") modalRoot.innerHTML = interpretationModal();
else if (state.modal === "consent") modalRoot.innerHTML = consentModal();
else if (state.modal === "method") modalRoot.innerHTML = methodModal();
else if (state.modal === "delete") modalRoot.innerHTML = deleteRecordModal();
else if (state.modal === "clear-all") modalRoot.innerHTML = clearAllModal();
else modalRoot.innerHTML = "";
document.body.style.overflow = state.modal ? "hidden" : "";
@@ -544,13 +980,17 @@
function render(options = {}) {
if (state.screen === "welcome") screen.innerHTML = welcomeView();
else if (state.screen === "home") screen.innerHTML = homeView();
else if (state.screen === "history") screen.innerHTML = historyView();
else if (state.screen === "history-detail") screen.innerHTML = historyDetailView();
else if (state.screen === "settings") screen.innerHTML = settingsView();
else if (state.screen === "question") screen.innerHTML = questionView();
else if (state.screen === "casting") screen.innerHTML = castingView();
else if (state.screen === "result") screen.innerHTML = resultView();
else if (state.screen === "loading") screen.innerHTML = loadingView();
else if (state.screen === "explanation") screen.innerHTML = explanationView();
backButton.hidden = state.screen === "welcome";
backButton.hidden = state.screen === "welcome" || state.screen === "home";
renderModal();
if (!options.preserveFocus) {
@@ -577,7 +1017,10 @@
closeModal();
return;
}
if (state.screen === "question") state.screen = "welcome";
if (state.screen === "question") state.screen = state.questionOrigin;
else if (state.screen === "history") state.screen = "home";
else if (state.screen === "history-detail") state.screen = "history";
else if (state.screen === "settings") state.screen = state.settingsOrigin;
else if (state.screen === "casting") state.screen = "question";
else if (state.screen === "result") state.screen = "question";
else if (state.screen === "loading" || state.screen === "explanation") state.screen = "result";
@@ -591,6 +1034,8 @@
render();
window.setTimeout(() => {
if (state.screen !== "loading") return;
state.explanationGenerated = true;
attachExplanationToCurrentSession();
state.screen = "explanation";
render();
}, 900);
@@ -605,10 +1050,21 @@
});
document.addEventListener("change", (event) => {
if (!event.target.matches("[data-action='consent-toggle']")) return;
state.consent = event.target.checked;
const confirm = document.querySelector("[data-action='confirm-ai']");
if (confirm) confirm.disabled = !state.consent;
if (event.target.matches("[data-action='consent-toggle']")) {
state.consent = event.target.checked;
const confirm = document.querySelector("[data-action='confirm-ai']");
if (confirm) confirm.disabled = !state.consent;
} else if (event.target.matches("[data-action='auto-save-toggle']")) {
state.autoSaveHistory = event.target.checked;
render({ preserveFocus: true });
document.querySelector("[data-action='auto-save-toggle']")?.focus();
} else if (event.target.matches("[data-action='save-question-setting']")) {
state.saveQuestion = event.target.checked;
} else if (event.target.matches("[data-action='save-explanation-setting']")) {
state.saveExplanation = event.target.checked;
} else if (event.target.matches("[data-action='save-action-setting']")) {
state.saveAction = event.target.checked;
}
});
document.addEventListener("click", (event) => {
@@ -620,9 +1076,22 @@
if (action === "back") goBack();
else if (action === "begin") {
state.questionOrigin = state.screen;
state.screen = "question";
render();
} else if (action === "open-history") {
state.screen = "history";
render();
} else if (action === "open-settings") {
state.settingsOrigin = state.screen === "settings" ? "home" : state.screen;
state.screen = "settings";
render();
} else if (action === "open-history-detail") {
state.selectedRecordId = actionTarget.dataset.recordId;
state.screen = "history-detail";
render();
} else if (action === "skip-question") {
prepareNewSession();
state.question = "未记录具体问题(仅作自我观察)";
state.lines = [];
state.coins = [null, null, null];
@@ -630,6 +1099,7 @@
render();
} else if (action === "start-casting") {
if (!state.question.trim()) return;
prepareNewSession();
state.lines = [];
state.coins = [null, null, null];
state.screen = "casting";
@@ -645,6 +1115,7 @@
state.lines.push(state.coins.reduce((total, value) => total + value, 0));
state.coins = [null, null, null];
if (state.lines.length === 6) {
if (state.autoSaveHistory) persistCurrentSession("automatic");
state.screen = "result";
}
render();
@@ -663,6 +1134,8 @@
} else if (action === "choose-local") {
state.modal = null;
state.explanationSource = "local";
state.explanationGenerated = true;
attachExplanationToCurrentSession();
state.screen = "explanation";
render();
} else if (action === "confirm-ai") {
@@ -673,12 +1146,56 @@
state.modal = "consent";
state.consent = false;
renderModal();
} else if (action === "save-current") {
persistCurrentSession();
render();
showToast("本次记录已保存到本机");
} else if (action === "remove-current-record") {
if (!state.saved) return;
state.selectedRecordId = state.currentRecordId;
state.deleteReturnScreen = state.screen;
state.modal = "delete";
renderModal();
} else if (action === "delete-record") {
state.deleteReturnScreen = "history";
state.modal = "delete";
renderModal();
} else if (action === "confirm-delete") {
const deletedCurrentSession = state.selectedRecordId === state.currentRecordId;
state.historyRecords = state.historyRecords.filter((record) => record.id !== state.selectedRecordId);
state.selectedRecordId = null;
state.modal = null;
if (deletedCurrentSession) {
state.saved = false;
state.currentSessionOptedOut = true;
state.currentSaveMode = null;
}
state.screen = state.deleteReturnScreen;
render();
showToast("记录已从本机删除");
} else if (action === "open-clear-all") {
if (!state.historyRecords.length) return;
state.modal = "clear-all";
renderModal();
} else if (action === "confirm-clear-all") {
const includedCurrentSession = Boolean(currentSessionRecord());
state.historyRecords = [];
state.selectedRecordId = null;
state.modal = null;
if (includedCurrentSession) {
state.saved = false;
state.currentSessionOptedOut = true;
state.currentSaveMode = null;
}
render();
showToast("已清空全部本机记录");
} else if (action === "restart") {
state.question = "";
state.lines = [];
state.coins = [null, null, null];
state.aiError = false;
state.screen = "welcome";
prepareNewSession();
state.questionOrigin = state.historyRecords.length ? "home" : "welcome";
state.screen = "question";
render();
} else if (action === "back-to-result") {
state.screen = "result";
@@ -705,6 +1222,22 @@
return;
}
if (["home", "home-empty", "history", "history-empty", "history-detail", "delete", "settings", "settings-off", "clear-all"].includes(view)) {
state.historyRecords = view === "home-empty" || view === "history-empty" ? [] : sampleHistory();
state.screen = view.startsWith("home")
? "home"
: view === "history-detail" || view === "delete"
? "history-detail"
: view === "settings" || view === "settings-off" || view === "clear-all"
? "settings"
: "history";
state.selectedRecordId = state.historyRecords[0]?.id ?? null;
if (view === "settings-off") state.autoSaveHistory = false;
if (view === "delete") state.modal = "delete";
if (view === "clear-all") state.modal = "clear-all";
return;
}
state.question = SAMPLE_QUESTION;
if (view === "casting") {
state.screen = "casting";
@@ -715,14 +1248,20 @@
state.lines = view === "static" ? [7, 7, 7, 7, 7, 7] : [...SAMPLE_LINES];
state.screen = "result";
if (view === "result-not-saved") state.autoSaveHistory = false;
if (state.autoSaveHistory) persistCurrentSession("automatic");
if (view === "consent") state.modal = "consent";
else if (view === "explanation") {
state.screen = "explanation";
state.explanationSource = "ai";
state.explanationGenerated = true;
attachExplanationToCurrentSession();
} else if (view === "local") {
state.screen = "explanation";
state.explanationSource = "local";
state.explanationGenerated = true;
attachExplanationToCurrentSession();
} else if (view === "error") {
state.aiError = true;
}
+223 -4
View File
@@ -19,7 +19,17 @@ const cases = [
{ view: "static", file: "05-result-static.png", fullPage: true },
{ view: "consent", file: "06-ai-consent.png", fullPage: false },
{ view: "explanation", file: "07-ai-explanation.png", fullPage: true },
{ view: "error", file: "08-ai-error.png", fullPage: true }
{ view: "error", file: "08-ai-error.png", fullPage: true },
{ view: "home", file: "09-home-returning.png", fullPage: true },
{ view: "home-empty", file: "10-home-empty.png", fullPage: true },
{ view: "history", file: "11-history-list.png", fullPage: true },
{ view: "history-empty", file: "12-history-empty.png", fullPage: true },
{ view: "history-detail", file: "13-history-detail.png", fullPage: true },
{ view: "settings", file: "14-settings-default.png", fullPage: true },
{ view: "delete", file: "15-delete-confirm.png", fullPage: false },
{ view: "settings-off", file: "16-settings-off.png", fullPage: true },
{ view: "result-not-saved", file: "17-result-not-saved.png", fullPage: true },
{ view: "clear-all", file: "18-clear-all-confirm.png", fullPage: false }
];
function findChrome() {
@@ -206,6 +216,26 @@ async function auditView(client, view) {
})()`);
}
async function auditLayout(client, { name, width, height, view }) {
await client.send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: false,
screenWidth: width,
screenHeight: height
});
await navigate(client, view);
return evaluate(client, `(() => ({
name: ${JSON.stringify(name)},
view: ${JSON.stringify(view)},
viewport: { width: innerWidth, height: innerHeight },
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
mainReachable: Boolean(document.querySelector('#screen'))
}))()`);
}
async function run() {
const health = await fetch(baseUrl);
if (!health.ok) {
@@ -265,6 +295,18 @@ async function run() {
const screenshots = [];
for (const item of cases) screenshots.push(await capture(client, item));
await navigate(client, "casting");
for (let remainingRound = 0; remainingRound < 4; remainingRound += 1) {
for (let coinIndex = 0; coinIndex < 3; coinIndex += 1) {
await evaluate(client, `document.querySelector('[data-action="toggle-coin"][data-index="${coinIndex}"]')?.click()`);
}
await evaluate(client, "document.querySelector('[data-action=\"confirm-line\"]')?.click()");
}
const completedCastingSave = await evaluate(client, `(() => ({
resultVisible: Boolean(document.querySelector('#result-title')),
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim()
}))()`);
await navigate(client, "result");
const movingResult = await evaluate(client, `(() => ({
names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()),
@@ -289,28 +331,158 @@ async function run() {
await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.click()");
await delay(1050);
const explanationSource = await evaluate(client, "document.querySelector('.source-badge')?.textContent.trim()");
const explanationSaveStatus = await evaluate(client, "document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, ' ').trim()");
await navigate(client, "home");
const homeState = await evaluate(client, `(() => ({
primaryActions: document.querySelectorAll('[data-action="begin"]').length,
recentRoute: document.querySelector('.recent-card__route')?.textContent.replace(/\\s+/g, " ").trim(),
historyEntry: document.querySelectorAll('[data-action="open-history"]').length,
settingsEntry: document.querySelectorAll('[data-action="open-settings"]').length,
localNotice: document.querySelector('[data-local-data-notice]')?.textContent.replace(/\\s+/g, " ").trim(),
bottomNavigation: document.querySelectorAll('nav, [role="navigation"]').length
}))()`);
await evaluate(client, "document.querySelector('[data-action=\"begin\"]')?.click()");
const homeForwardTitle = await evaluate(client, "document.querySelector('#question-title')?.textContent.trim()");
await evaluate(client, "document.querySelector('[data-action=\"back\"]')?.click()");
const homeBackTitle = await evaluate(client, "document.querySelector('#home-title')?.textContent.trim()");
await navigate(client, "history");
const historyState = await evaluate(client, `(() => ({
count: document.querySelectorAll('.history-card').length,
firstRoute: document.querySelector('.history-card__route')?.textContent.replace(/\\s+/g, " ").trim()
}))()`);
await navigate(client, "history-empty");
const historyEmptyState = await evaluate(client, `(() => ({
hasEmptyGuidance: Boolean(document.querySelector('[data-history-empty]')),
hasPrimaryAction: Boolean(document.querySelector('[data-action="begin"]')),
hasSettingsEntry: Boolean(document.querySelector('[data-action="open-settings"]'))
}))()`);
await navigate(client, "settings");
const settingsDefaults = await evaluate(client, `(() => ({
autoSave: document.querySelector('[data-action="auto-save-toggle"]')?.checked,
question: document.querySelector('[data-action="save-question-setting"]')?.checked,
explanation: document.querySelector('[data-action="save-explanation-setting"]')?.checked,
action: document.querySelector('[data-action="save-action-setting"]')?.checked,
detailDisabled: [...document.querySelectorAll('.settings-card__details input')].some((input) => input.disabled),
boundary: document.querySelector('.settings-boundary')?.textContent.replace(/\\s+/g, " ").trim()
}))()`);
await evaluate(client, "document.querySelector('[data-action=\"auto-save-toggle\"]')?.click()");
await delay(100);
const settingsDisabled = await evaluate(client, `(() => ({
autoSave: document.querySelector('[data-action="auto-save-toggle"]')?.checked,
detailsDisabled: [...document.querySelectorAll('.settings-card__details input')].every((input) => input.disabled),
retainedValues: [...document.querySelectorAll('.settings-card__details input')].every((input) => input.checked)
}))()`);
await navigate(client, "result");
const autoSaveBefore = await evaluate(client, `(() => ({
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(),
canOptOut: Boolean(document.querySelector('[data-action="remove-current-record"]'))
}))()`);
await evaluate(client, "document.querySelector('[data-action=\"remove-current-record\"]')?.click()");
const optOutDialogVisible = await evaluate(client, "Boolean(document.querySelector('[role=\"alertdialog\"]'))");
await evaluate(client, "document.querySelector('[data-action=\"confirm-delete\"]')?.click()");
await delay(100);
const afterOptOut = await evaluate(client, `(() => ({
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(),
canSave: Boolean(document.querySelector('[data-action="save-current"]'))
}))()`);
await evaluate(client, "document.querySelector('[data-action=\"save-current\"]')?.click()");
await delay(100);
const afterManualSave = await evaluate(client, "document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, ' ').trim()");
await navigate(client, "result-not-saved");
const autoSaveOffResult = await evaluate(client, `(() => ({
status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(),
canSave: Boolean(document.querySelector('[data-action="save-current"]'))
}))()`);
await navigate(client, "clear-all");
const clearBefore = await evaluate(client, `(() => ({
dialogVisible: Boolean(document.querySelector('[role="alertdialog"]')),
countText: document.querySelector('#clear-modal-description')?.textContent.trim()
}))()`);
await evaluate(client, "document.querySelector('[data-action=\"confirm-clear-all\"]')?.click()");
await delay(100);
const clearAfter = await evaluate(client, `(() => ({
settingsVisible: document.querySelector('#settings-title')?.textContent.trim(),
recordCount: document.querySelector('.settings-danger p')?.textContent.trim(),
clearDisabled: document.querySelector('[data-action="open-clear-all"]')?.disabled,
autoSaveRetained: document.querySelector('[data-action="auto-save-toggle"]')?.checked,
modalClosed: !document.querySelector('[role="alertdialog"]')
}))()`);
await navigate(client, "delete");
const deleteBefore = await evaluate(client, `(() => ({
dialogVisible: Boolean(document.querySelector('[role="alertdialog"]')),
detailVisible: Boolean(document.querySelector('#history-detail-title'))
}))()`);
await evaluate(client, "document.querySelector('[data-action=\"confirm-delete\"]')?.click()");
await delay(100);
const deleteAfter = await evaluate(client, `(() => ({
onHistory: document.querySelector('#history-title')?.textContent.trim(),
historyCount: document.querySelectorAll('.history-card').length,
modalClosed: !document.querySelector('[role="alertdialog"]')
}))()`);
const accessibility = [];
for (const item of cases) accessibility.push(await auditView(client, item.view));
const layoutAudits = [];
for (const layout of [
{ name: "small-phone", width: 375, height: 812, view: "home" },
{ name: "large-phone", width: 430, height: 932, view: "history" },
{ name: "large-phone-settings", width: 430, height: 932, view: "settings" },
{ name: "phone-landscape", width: 792, height: 360, view: "history-detail" }
]) {
layoutAudits.push(await auditLayout(client, layout));
}
await client.send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value: "reduce" }]
});
await navigate(client, "home");
const reducedMotion = await evaluate(client, `(() => {
const page = document.querySelector('.page');
const duration = Number.parseFloat(getComputedStyle(page).animationDuration) || 0;
return { animationDurationSeconds: duration, reduced: duration <= 0.001 };
})()`);
await client.send("Emulation.setEmulatedMedia", { features: [] });
const report = {
chrome,
baseUrl,
screenshots,
completedCastingSave,
movingResult,
staticResult,
consentGate: {
disabledBeforeConsent: consentBefore,
disabledAfterConsent: consentAfter,
explanationSource
explanationSource,
explanationSaveStatus
},
homeState: { ...homeState, forwardTitle: homeForwardTitle, backTitle: homeBackTitle },
historyState,
historyEmptyState,
settingsFlow: { defaults: settingsDefaults, disabled: settingsDisabled },
autoSaveFlow: { before: autoSaveBefore, optOutDialogVisible, afterOptOut, afterManualSave, autoSaveOffResult },
clearAllFlow: { before: clearBefore, after: clearAfter },
deleteFlow: { before: deleteBefore, after: deleteAfter },
consoleErrors,
runtimeErrors,
externalRequests: [...new Set(externalRequests)],
accessibility
accessibility,
layoutAudits,
reducedMotion
};
const failed =
completedCastingSave.resultVisible !== true ||
!completedCastingSave.status?.includes("已自动保存到本机") ||
movingResult.names.join(",") !== "复,坤" ||
movingResult.numbers.join(",") !== "第 24 卦,第 2 卦" ||
!movingResult.moving.includes("1 个动爻") ||
@@ -319,12 +491,59 @@ async function run() {
consentBefore !== true ||
consentAfter !== false ||
explanationSource !== "AI 生成" ||
!explanationSaveStatus?.includes("解读已加入本次记录") ||
homeState.primaryActions !== 1 ||
homeState.recentRoute !== "复 之 坤" ||
homeState.historyEntry < 1 ||
homeState.settingsEntry !== 1 ||
!homeState.localNotice?.includes("起卦与历史默认保存在本机,不主动上传") ||
!homeState.localNotice?.includes("只有你选择 AI 解读时,本次所需内容才会发送") ||
homeState.bottomNavigation !== 0 ||
homeForwardTitle !== "你想看清什么?" ||
homeBackTitle !== "此刻,有什么想安静看清?" ||
historyState.count !== 3 ||
historyState.firstRoute !== "复 之 坤" ||
historyEmptyState.hasEmptyGuidance !== true ||
historyEmptyState.hasPrimaryAction !== true ||
historyEmptyState.hasSettingsEntry !== true ||
settingsDefaults.autoSave !== true ||
settingsDefaults.question !== true ||
settingsDefaults.explanation !== true ||
settingsDefaults.action !== true ||
settingsDefaults.detailDisabled !== false ||
!settingsDefaults.boundary?.includes("默认排除系统备份、设备迁移与云同步") ||
settingsDisabled.autoSave !== false ||
settingsDisabled.detailsDisabled !== true ||
settingsDisabled.retainedValues !== true ||
!autoSaveBefore.status?.includes("已自动保存到本机") ||
autoSaveBefore.canOptOut !== true ||
optOutDialogVisible !== true ||
!afterOptOut.status?.includes("本次未保存") ||
!afterOptOut.status?.includes("你已选择不保留这次记录") ||
afterOptOut.canSave !== true ||
!afterManualSave?.includes("已保存到本机问卦簿") ||
!autoSaveOffResult.status?.includes("自动保存已关闭") ||
autoSaveOffResult.canSave !== true ||
clearBefore.dialogVisible !== true ||
!clearBefore.countText?.includes("当前 3 条记录") ||
clearAfter.settingsVisible !== "保存设置" ||
!clearAfter.recordCount?.includes("0 条记录") ||
clearAfter.clearDisabled !== true ||
clearAfter.autoSaveRetained !== true ||
clearAfter.modalClosed !== true ||
deleteBefore.dialogVisible !== true ||
deleteBefore.detailVisible !== true ||
deleteAfter.onHistory !== "问卦簿" ||
deleteAfter.historyCount !== 2 ||
deleteAfter.modalClosed !== true ||
consoleErrors.length > 0 ||
runtimeErrors.length > 0 ||
externalRequests.length > 0 ||
accessibility.some((item) =>
item.unnamedButtons > 0 || item.touchIssues.length > 0 || item.smallCheckboxLabels > 0
);
) ||
layoutAudits.some((item) => item.horizontalOverflow || !item.mainReachable) ||
reducedMotion.reduced !== true;
console.log(JSON.stringify(report, null, 2));
if (failed) process.exitCode = 1;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Some files were not shown because too many files have changed in this diff Show More