Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534c88993e | ||
|
|
4235cf9011 | ||
|
|
d82ab836a2 | ||
|
|
730bc528be | ||
|
|
de480ee9bb | ||
|
|
bad67fa5a8 | ||
|
|
a82af6939f |
@@ -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
|
||||
@@ -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
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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("𠀀"))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** 灵机自撰白话草稿。按文王序 1–64;每项为 [卦辞, 初, 二, 三, 四, 五, 上]。 */
|
||||
export const lingjiPlain = [
|
||||
["开始即通达,利于守持正道。", "龙还潜藏着,先不要施展。", "龙出现在田野,利于去见有识之人。", "君子整日健进,夜里仍警惕;虽有危难,可以无灾。", "或跳进深渊,可以无灾。", "龙飞在天上,利于去见有识之人。", "龙飞得过高,会有悔恨。"],
|
||||
["开始即通达,利于像母马那样守持正道。君子有所前往,先会迷路,随后才得到依靠。利于西南得到同类,东北失去同类。安于守正,吉。", "踩到霜,坚冰就要来了。", "正直方正而广大,不必演习,没有不利。", "蕴含文采,可以守正。或跟从王事,不求自己成功,却能有结果。", "扎紧口袋,无灾也无称誉。", "黄色下裳,大为吉祥。", "龙在郊野争斗,血流成青黄色。"],
|
||||
["开始即通达,利于守正。不要急着前往,利于立诸侯。", "徘徊不进,利于安居守正,利于立诸侯。", "困顿难行,乘马盘旋。不是强盗,是来求婚。女子守正不嫁,十年才许嫁。", "追鹿却没有虞人向导,只是陷入林中。君子察觉危险,不如放弃;再往前会有憾恨。", "乘马盘旋,去求婚,前往吉,没有不利。", "屯积膏泽。小事守正吉,大事守正凶。", "乘马盘旋,血泪涟涟。"],
|
||||
["亨通。我不是要去求童蒙,是童蒙来求我。初次卜问则告知;再三乱问,就不告知。利于守正。", "启发蒙昧,利于用刑罚纠正人,用桎梏约束;若一再用刑,就有憾恨。", "包容蒙昧,吉。接纳妇人,吉。孩子能自立,家庭就不再受困。", "不要娶这个女子,她见了财礼就失了自身。无利可图。", "困于蒙昧,憾恨。", "童蒙,吉。", "打击蒙昧。不利于做强盗,利于抵御强盗。"],
|
||||
["有所等待,有诚信。光辉亨通,守正吉。利于涉大河。", "在郊野等待,利于用恒常的方法,无灾。", "在沙滩等待,小有议论,最终吉。", "在泥中等待,招来贼寇。", "在血中等待,从洞穴里出来。", "在酒食中等待,守正吉。", "进入洞穴,有不请自来的三位客人;尊敬他们,最终吉。"],
|
||||
["有诚信,在窒塞中警惕,中正则吉,最终凶。利于见大人,不利于涉大河。", "不永久纠缠事务,小有议论,最终吉。", "败于讼事,回家逃避。邑人三百户,无灾。", "食用旧德,守正有危,最终吉。或跟从王事,没有成功。", "不能胜诉,仍复归于常理,改变态度,安静守正则吉。", "讼事,元吉。", "或赐给大带,一天之内被剥夺三次。"],
|
||||
["贞正。大人吉,无灾。兵众,正而不邪才可用。", "出兵要有纪律;否则,凶。", "在队伍中,无灾,王三次赐命。", "师或载尸而还,凶。", "师左次而退,无灾。", "田里有禽,利于执言,无灾。长子帅师,弟子载尸,贞凶。", "大君有命,开国承家。小人不可重用。"],
|
||||
["吉。原筮,元永贞,无灾。不安宁的人前来,后夫凶。", "有诚信,比之无灾。有诚信充满瓦缶,终来有别的吉。", "比之从内,贞吉。", "比之匪人。", "在外相比,贞吉。", "显比。王用三驱,失前禽。邑人不诫,吉。", "比之无首,凶。"],
|
||||
["亨通。密云不雨,自我西郊。", "复归于自己的道路,何其咎?吉。", "牵复,吉。", "舆说辐。夫妻反目。", "有孚,血去惕出,无灾。", "有孚挛如,富以其邻。", "既雨既处,尚德载。妇贞厉。月几望,君子征凶。"],
|
||||
["踩踏老虎尾巴,不咬人,亨通。", "素履往,无灾。", "履道坦坦,幽人贞吉。", "眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。", "履虎尾,愬愬,终吉。", "夬履,贞厉。", "视履考祥,其旋元吉。"],
|
||||
["小往大来,吉,亨通。", "拔茅连根,以其类,征吉。", "包荒,用冯河,不遐遗。朋亡,得尚于中行。", "无平不陂,无往不复。艰贞无灾。勿恤其孚,于食有福。", "翩翩,不富以其邻,不戒以孚。", "帝乙归妹,以祉元吉。", "城复于隍,勿用师。自邑告命,贞吝。"],
|
||||
["否之匪人,不利于君子守正。大的往,小的来。", "拔茅连根,以其类,守正吉,亨通。", "包容奉承。小人吉,大人否塞。亨通。", "包藏羞辱。", "有天命,无灾。同类依附而得福。", "休止否塞,大人吉。心中念着将亡将亡,才能系于苞桑。", "倾覆否塞。先否塞,后喜悦。"],
|
||||
["同人于野,亨通。利于涉大河,利于君子守正。", "同人于门,无灾。", "同人于宗,吝。", "伏戎于莽,升其高陵,三年不兴。", "乘其墉,弗克攻,吉。", "同人,先号咷而后笑。大师克相遇。", "同人于郊,无悔。"],
|
||||
["元亨。", "无交害,匪咎,艰则无灾。", "大车以载,有攸往,无灾。", "公用亨于天子。小人弗克。", "匪其彭,无灾。", "厥孚交如,威如,吉。", "自天佑之,吉无不利。"],
|
||||
["亨通。君子有终。", "谦谦君子,用涉大川,吉。", "鸣谦,贞吉。", "劳谦,君子有终,吉。", "无不利,撝谦。", "不富以其邻,利用侵伐,无不利。", "鸣谦,利用行师,征邑国。"],
|
||||
["利于建侯行师。", "鸣豫,凶。", "介于石,不终日,贞吉。", "盱豫,悔。迟则有悔。", "由豫,大有得。勿疑,朋盍簪。", "贞疾,恒不死。", "冥豫,成有渝,无灾。"],
|
||||
["元亨,利贞,无灾。", "官有渝,贞吉。出门交有功。", "系小子,失丈夫。", "系丈夫,失小子。随有求得,利居贞。", "随有获,贞凶。有孚在道,以明,何咎。", "孚于嘉,吉。", "拘系之,乃从维之。王用亨于西山。"],
|
||||
["元亨,利涉大川。先甲三日,后甲三日。", "干父之蛊,有子,考无咎。厉,终吉。", "干母之蛊,不可贞。", "干父之蛊,小有悔,无大咎。", "裕父之蛊,往见吝。", "干父之蛊,用誉。", "不事王侯,高尚其事。"],
|
||||
["元亨,利贞。至于八月有凶。", "咸临,贞吉。", "咸临,吉,无不利。", "甘临,无攸利。既忧之,无灾。", "至临,无灾。", "知临,大君之宜,吉。", "敦临,吉,无灾。"],
|
||||
["盥而不荐,有孚颙若。", "童观,小人无咎,君子吝。", "窥观,利女贞。", "观我生,进退。", "观国之光,利用宾于王。", "观我生,君子无咎。", "观其生,君子无咎。"],
|
||||
["亨通。利用狱。", "屦校灭趾,无灾。", "噬肤灭鼻,无灾。", "噬腊肉,遇毒。小吝,无灾。", "噬干胏,得金矢。利艰贞,吉。", "噬干肉,得黄金。贞厉,无灾。", "何校灭耳,凶。"],
|
||||
["亨通。小利有攸往。", "贲其趾,舍车而徒。", "贲其须。", "贲如濡如,永贞吉。", "贲如皤如,白马翰如。匪寇婚媾。", "贲于丘园,束帛戋戋。吝,终吉。", "白贲,无灾。"],
|
||||
["不利于有所前往。", "剥床以足,蔑贞,凶。", "剥床以辨,蔑贞,凶。", "剥之,无灾。", "剥床以肤,凶。", "贯鱼,以宫人宠,无不利。", "硕果不食。君子得舆,小人剥庐。"],
|
||||
["亨通。出入没有疾病,朋友来也无灾。一来一回按那条路走,七天会回复。利于有所前往。", "回复得还不远,没有大的悔恨,大为吉祥。", "美好地回复,吉。", "频繁回复,有危难,无灾。", "走在行列中间,独自回复。", "敦厚地回复,没有悔恨。", "迷路却不回复,凶,有灾祸。用此出兵,终有大败,连累国君,凶;到十年还不能出征。"],
|
||||
["元亨,利贞。其匪正有眚,不利有攸往。", "无妄,往吉。", "不耕获,不菑畲,则利有攸往。", "无妄之灾。或系之牛,行人之得,邑人之灾。", "可贞,无灾。", "无妄之疾,勿药有喜。", "无妄,行有眚,无攸利。"],
|
||||
["利贞。不家食,吉。利涉大川。", "有厉,利已。", "舆说輹。", "良马逐,利艰贞。日闲舆卫,利有攸往。", "童牛之牿,元吉。", "豮豕之牙,吉。", "何天之衢,亨。"],
|
||||
["贞吉。观颐,自求口实。", "舍尔灵龟,观我朵颐,凶。", "颠颐,拂经,于丘颐,征凶。", "拂颐,贞凶。十年勿用,无攸利。", "颠颐,吉。虎视眈眈,其欲逐逐,无灾。", "拂经,居贞吉,不可涉大川。", "由颐,厉吉,利涉大川。"],
|
||||
["栋桡。利有攸往,亨。", "藉用白茅,无灾。", "枯杨生稊,老夫得其女妻,无不利。", "栋桡,凶。", "栋隆,吉。有它吝。", "枯杨生华,老妇得其士夫,无咎无誉。", "过涉灭顶,凶,无灾。"],
|
||||
["有孚,维心亨,行有尚。", "习坎,入于坎窞,凶。", "坎有险,求小得。", "来之坎坎,险且枕,入于坎窞,勿用。", "樽酒簋贰,用缶,纳约自牖,终无灾。", "坎不盈,祗既平,无灾。", "系用徽纆,置于丛棘,三岁不得,凶。"],
|
||||
["利贞,亨。畜牝牛,吉。", "履错然,敬之,无灾。", "黄离,元吉。", "日昃之离,不鼓缶而歌,则大耋之嗟,凶。", "突如其来如,焚如,死如,弃如。", "出涕沱若,戚嗟若,吉。", "王用出征,有嘉折首,获匪其丑,无灾。"],
|
||||
["亨通。利贞。取女吉。", "咸其拇。", "咸其腓,凶。居吉。", "咸其股,执其随,往吝。", "贞吉,悔亡。憧憧往来,朋从尔思。", "咸其脢,无悔。", "咸其辅颊舌。"],
|
||||
["亨通。无灾。利于守正。利于有所前往。", "深求恒久,守正则凶,无所利。", "悔恨消失。", "不能恒久其德,或许会承受羞辱,守正有憾。", "田里没有禽兽。", "恒久其德,守正。妇人吉,男子凶。", "振动不安于恒,凶。"],
|
||||
["亨通。小利于守正。", "遁走只剩尾巴,有危,不要有所前往。", "用黄牛皮捆住,没有人能解脱。", "被牵绊着退避,有疾病和危难。畜养臣妾,吉。", "喜好退避,君子吉,小人否塞。", "嘉美地退避,守正吉。", "充裕地退避,没有不利。"],
|
||||
["利于守正。", "壮在脚趾,出征凶,有诚信。", "贞吉。", "小人用壮,君子用罔,贞厉。公羊触藩,缠住了角。", "贞吉,悔恨消失。藩篱没被破坏,壮在大车的辐上。", "在田里丧失羊,无悔。", "公羊触藩,不能退不能进,无攸利。艰难则吉。"],
|
||||
["康侯用锡马蕃庶,白天三次接见。", "晋如摧如,贞吉。罔孚,裕无灾。", "晋如愁如,贞吉。受兹介福,于其王母。", "众允,悔亡。", "晋如鼫鼠,贞厉。", "悔亡,失得勿恤。往吉,无不利。", "晋其角,维用伐邑,厉吉,无灾,贞吝。"],
|
||||
["利于艰难中守正。", "明夷于飞,垂其翼。君子于行,三日不食。有攸往,主人有言。", "明夷,夷于左股,用拯马壮,吉。", "明夷于南狩,得其大首,不可疾贞。", "入于左腹,获明夷之心,于出门庭。", "箕子之明夷,利贞。", "不明晦。初登于天,后入于地。"],
|
||||
["利于女子守正。", "闲有家,悔亡。", "无攸遂,在中馈,贞吉。", "家人嗃嗃,悔厉吉;妇子嘻嘻,终吝。", "富家,大吉。", "王假有家,勿恤,吉。", "有孚威如,终吉。"],
|
||||
["小事吉。", "悔亡。丧马勿逐,自复。见恶人,无灾。", "遇主于巷,无灾。", "见舆曳,其牛掣,其人天且劓。无初有终。", "睽孤,遇元夫,交孚,厉无灾。", "悔亡。厥宗噬肤,往何咎。", "睽孤,见豕负涂,载鬼一车。先张之弧,后说之弧。匪寇婚媾。往遇雨则吉。"],
|
||||
["利西南,不利东北。利见大人。贞吉。", "往蹇,来誉。", "王臣蹇蹇,匪躬之故。", "往蹇,来反。", "往蹇,来连。", "大蹇,朋来。", "往蹇,来硕,吉。利见大人。"],
|
||||
["利西南。无所往,其来复吉。有攸往,夙吉。", "无灾。", "田获三狐,得黄矢,贞吉。", "负且乘,致寇至,贞吝。", "解而拇,朋至斯孚。", "君子维有解,吉。有孚于小人。", "公用射隼于高墉之上,获之,无不利。"],
|
||||
["有孚,元吉,无灾,可贞,利有攸往。曷之用?二簋可用享。", "已事遄往,无灾。酌损之。", "利贞。征凶。弗损,益之。", "三人行则损一人,一人行则得其友。", "损其疾,使遄有喜,无灾。", "或益之十朋之龟,弗克违,元吉。", "弗损,益之,无灾。贞吉。利有攸往,得臣无家。"],
|
||||
["利有攸往。利涉大川。", "利用为大作,元吉,无灾。", "或益之十朋之龟,弗克违。永贞吉。王用享于帝,吉。", "益之用凶事,无灾。有孚中行,告公用圭。", "中行,告公从,利用为依迁国。", "有孚惠心,勿问元吉。有孚惠我德。", "莫益之,或击之,立心勿恒,凶。"],
|
||||
["扬于王庭。孚号有厉。告自邑,不利即戎,利有攸往。", "壮于前趾,往不胜为咎。", "惕号,莫夜有戎,勿恤。", "壮于頄,有凶。君子夬夬,独行遇雨,若濡有愠,无灾。", "臀无肤,其行次且。牵羊悔亡,闻言不信。", "苋陆夬夬,中行无灾。", "无号,终有凶。"],
|
||||
["女壮,勿用取女。", "系于金柅,贞吉。有攸往,见凶。羸豕孚蹢躅。", "包有鱼,无灾,不利宾。", "臀无肤,其行次且,厉,无大咎。", "包无鱼,起凶。", "以杞包瓜,含章,有陨自天。", "姤其角,吝,无灾。"],
|
||||
["亨通。王假有庙。利见大人,亨,利贞。用大牲吉。利有攸往。", "有孚不终,乃乱乃萃。若号,一握为笑,勿恤,往无灾。", "引吉,无灾。孚乃利用禴。", "萃如嗟如,无攸利。往无灾,小吝。", "大吉,无灾。", "萃有位,无灾。匪孚,元永贞,悔亡。", "赍咨涕洟,无灾。"],
|
||||
["元亨。用见大人,勿恤。南征吉。", "允升,大吉。", "孚乃利用禴,无灾。", "升虚邑。", "王用亨于岐山,吉,无灾。", "贞吉,升阶。", "冥升,利于不息之贞。"],
|
||||
["亨通。贞,大人吉,无灾。有言不信。", "臀困于株木,入于幽谷,三岁不觌。", "困于酒食,朱绂方来,利用享祀。征凶,无灾。", "困于石,据于蒺藜。入于其宫,不见其妻,凶。", "来徐徐,困于金车,吝,有终。", "劓刖,困于赤绂。乃徐有说,利用祭祀。", "困于葛藟,于臲卼。曰动悔有悔,征吉。"],
|
||||
["改邑不改井,无丧无得。往来井井。汔至,亦未繘井,羸其瓶,凶。", "井泥不食,旧井无禽。", "井谷射鲋,瓮敝漏。", "井渫不食,为我心恻。可用汲,王明,并受其福。", "井甃,无灾。", "井冽,寒泉食。", "井收勿幕,有孚元吉。"],
|
||||
["己日乃孚。元亨,利贞,悔亡。", "巩用黄牛之革。", "己日乃革之,征吉,无灾。", "征凶,贞厉。革言三就,有孚。", "悔亡。有孚改命,吉。", "大人虎变,未占有孚。", "君子豹变,小人革面。征凶,居贞吉。"],
|
||||
["元吉,亨通。", "鼎颠趾,利出否。得妾以其子,无灾。", "鼎有实。我仇有疾,不我能即,吉。", "鼎耳革,其行塞,雉膏不食。方雨亏悔,终吉。", "鼎折足,覆公餗,其形渥,凶。", "鼎黄耳金铉,利贞。", "鼎玉铉,大吉,无不利。"],
|
||||
["亨通。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。", "震来虩虩,后笑言哑哑,吉。", "震来厉,亿丧贝。跻于九陵,勿逐,七日得。", "震苏苏,震行无眚。", "震遂泥。", "震往来厉,亿无丧,有事。", "震索索,视矍矍,征凶。震不于其躬,于其邻,无灾。婚媾有言。"],
|
||||
["艮其背,不获其身;行其庭,不见其人,无灾。", "艮其趾,无灾,利永贞。", "艮其腓,不拯其随,其心不快。", "艮其限,列其夤,厉薰心。", "艮其身,无灾。", "艮其辅,言有序,悔亡。", "敦艮,吉。"],
|
||||
["女归吉。利贞。", "鸿渐于干,小子厉,有言,无灾。", "鸿渐于磐,饮食衎衎,吉。", "鸿渐于陆,夫征不复,妇孕不育,凶。利御寇。", "鸿渐于木,或得其桷,无灾。", "鸿渐于陵,妇三岁不孕,终莫之胜,吉。", "鸿渐于陆,其羽可用为仪,吉。"],
|
||||
["征凶,无攸利。", "归妹以娣,跛能履,征吉。", "眇能视,利幽人之贞。", "归妹以须,反归以娣。", "归妹愆期,迟归有时。", "帝乙归妹,其君之袂不如其娣之袂良。月几望,吉。", "女承筐无实,士刲羊无血,无攸利。"],
|
||||
["亨通。王假之。勿忧,宜日中。", "遇其配主,虽旬无灾,往有尚。", "丰其蔀,日中见斗。往得疑疾,有孚发若,吉。", "丰其沛,日中见沫。折其右肱,无灾。", "丰其蔀,日中见斗。遇其夷主,吉。", "来章,有庆誉,吉。", "丰其屋,蔀其家,窥其户,阒其无人,三岁不觌,凶。"],
|
||||
["小亨。旅贞吉。", "旅琐琐,斯其所取灾。", "旅即次,怀其资,得童仆贞。", "旅焚其次,丧其童仆,贞厉。", "旅于处,得其资斧,我心不快。", "射雉,一矢亡,终以誉命。", "鸟焚其巢,旅人先笑后号咷。丧牛于易,凶。"],
|
||||
["小亨。利有攸往。利见大人。", "进退,利武人之贞。", "巽在床下,用史巫纷若,吉,无灾。", "频巽,吝。", "悔亡,田获三品。", "贞吉,悔亡,无不利。无初有终。先庚三日,后庚三日,吉。", "巽在床下,丧其资斧,贞凶。"],
|
||||
["亨通。利贞。", "和兑,吉。", "孚兑,吉,悔亡。", "来兑,凶。", "商兑未宁,介疾有喜。", "孚于剥,有厉。", "引兑。"],
|
||||
["亨通。王假有庙。利涉大川,利贞。", "用拯马壮,吉。", "涣奔其机,悔亡。", "涣其躬,无悔。", "涣其群,元吉。涣有丘,匪夷所思。", "涣汗其大号,涣王居,无灾。", "涣其血,去逖出,无灾。"],
|
||||
["亨通。苦节不可贞。", "不出户庭,无灾。", "不出门庭,凶。", "不节若,则嗟若,无灾。", "安节,亨。", "甘节,吉。往有尚。", "苦节,贞凶,悔亡。"],
|
||||
["豚鱼吉。利涉大川,利贞。", "虞吉,有它不燕。", "鸣鹤在阴,其子和之。我有好爵,吾与尔靡之。", "得敌,或鼓或罢,或泣或歌。", "月几望,马匹亡,无灾。", "有孚挛如,无灾。", "翰音登于天,贞凶。"],
|
||||
["亨通。利贞。可小事,不可大事。飞鸟遗之音,不宜上,宜下,大吉。", "飞鸟以凶。", "过其祖,遇其妣。不及其君,遇其臣,无灾。", "弗过防之,从或戕之,凶。", "无灾。弗过遇之。往厉必戒,勿用永贞。", "密云不雨,自我西郊。公弋取彼在穴。", "弗遇过之,飞鸟离之,凶。是谓灾眚。"],
|
||||
["亨通。小利贞。初吉终乱。", "曳其轮,濡其尾,无灾。", "妇丧其茀,勿逐,七日得。", "高宗伐鬼方,三年克之,小人勿用。", "繻有衣袽,终日戒。", "东邻杀牛,不如西邻之禴祭,实受其福。", "濡其首,厉。"],
|
||||
["亨通。小狐汔济,濡其尾,无攸利。", "濡其尾,吝。", "曳其轮,贞吉。", "未济,征凶,利涉大川。", "贞吉,悔亡。震用伐鬼方,三年有赏于大国。", "贞吉,无悔。君子之光,有孚,吉。", "有孚于饮酒,无灾。濡其首,有孚失是。"],
|
||||
];
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 机械检查。授权真机存在时另跑设备仪器测试;未运行的门禁不得写成已经通过。
|
||||
|
||||
@@ -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 同意状态。
|
||||
- 提示注入样例不能让模型重算卦、泄露系统提示或执行用户文本中的指令。
|
||||
- 高风险测试集不产生确定的医疗、法律、投资或人生决定。
|
||||
- 所有合法输出都有可撤销行动;所有非法输出都被客户端拒绝且可改用本地解释。
|
||||
|
||||
@@ -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. 多模块化触发条件
|
||||
|
||||
|
||||
@@ -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 不得标记完成,商店发布不得进行。
|
||||
@@ -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. 内容变更规则
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## 新增决策模板
|
||||
|
||||
|
||||
@@ -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` 中的维基文库公版《易经》与项目自撰白话草稿;这句话只描述本提交时的资源图,不构成内容负责人已签核。
|
||||
@@ -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. 读取内容的产品规则
|
||||
|
||||
|
||||
@@ -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)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
|
||||
|
||||
|
||||
@@ -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 测试环境。
|
||||
- 准备隐私政策、内容来源、免责声明和应用商店素材。
|
||||
|
||||
@@ -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-需求追踪矩阵)。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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. 发布门禁
|
||||
|
||||
发布候选必须满足:
|
||||
|
||||
@@ -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 风格关键词
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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 已实现;
|
||||
- 工作名“一问”与当前文字内容均为评审候选,不代表正式命名或内容授权完成。
|
||||
|
||||
@@ -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("&", "&")
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 86 KiB |