Compare commits
4
Commits
de480ee9bb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534c88993e | ||
|
|
4235cf9011 | ||
|
|
d82ab836a2 | ||
|
|
730bc528be |
@@ -23,6 +23,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
package-manager-cache: false
|
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
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v6
|
||||||
- name: Run local quality gates
|
- name: Run local quality gates
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*.iml
|
*.iml
|
||||||
local.properties
|
local.properties
|
||||||
captures/
|
captures/
|
||||||
|
hs_err_pid*.log
|
||||||
|
replay_pid*.log
|
||||||
|
|
||||||
# Signing material and local credentials must never enter the repository.
|
# Signing material and local credentials must never enter the repository.
|
||||||
*.jks
|
*.jks
|
||||||
|
|||||||
@@ -30,19 +30,19 @@ tools are unavailable or insufficient. State the fallback in the handoff.
|
|||||||
|
|
||||||
## Current build boundary
|
## Current build boundary
|
||||||
|
|
||||||
The repository uses `brainwave` only as a code name. Formal product name,
|
The repository keeps `brainwave` only as its code name. The accepted Android
|
||||||
organization-owned application ID, and final `minSdk` remain product decisions.
|
identity is product name `灵机`, namespace/application ID `net.opcapp.flash`, and
|
||||||
Until they are accepted, the `app` project is a Kotlin/JVM domain harness, not a
|
`minSdk` 26. The `app` project is an installable Android application; the app icon
|
||||||
publishable Android application. Do not invent placeholder release identity.
|
and store assets remain product decisions and must not be silently finalized.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Run from the repository root:
|
Run from the repository root:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew.bat verifyLocal --offline
|
.\gradlew.bat verifyLocal
|
||||||
```
|
```
|
||||||
|
|
||||||
After the Android application plugin is configured, also run the Android gates
|
`verifyLocal` includes Android lint, debug unit tests, APK assembly, repository
|
||||||
listed in `docs/quality-gates.md`. Report skipped device or Android checks
|
checks, and dependency-license coverage. Run `connectedDebugAndroidTest` separately
|
||||||
explicitly; do not imply they passed.
|
when an authorized device is present, and report skipped device checks explicitly.
|
||||||
|
|||||||
+130
-13
@@ -1,27 +1,121 @@
|
|||||||
import groovy.json.JsonSlurper
|
import groovy.json.JsonSlurper
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
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 {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
kapt {
|
||||||
main {
|
correctErrorTypes = true
|
||||||
kotlin.srcDir("src/main/java")
|
|
||||||
}
|
|
||||||
test {
|
|
||||||
kotlin.srcDir("src/test/java")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
kotlinOptions.jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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)
|
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.test {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnit()
|
useJUnit()
|
||||||
|
maxParallelForks = 1
|
||||||
|
maxHeapSize = "192m"
|
||||||
|
jvmArgs("-XX:MaxMetaspaceSize=128m", "-XX:+UseSerialGC")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("failed", "skipped")
|
events("failed", "skipped")
|
||||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
@@ -30,25 +124,48 @@ tasks.test {
|
|||||||
|
|
||||||
tasks.register("verifyDependencyLicenses") {
|
tasks.register("verifyDependencyLicenses") {
|
||||||
group = "verification"
|
group = "verification"
|
||||||
description = "Ensures every resolved JVM runtime/test artifact has a reviewed license entry."
|
description = "Ensures every resolved debug runtime/test artifact has a reviewed license entry."
|
||||||
|
|
||||||
doLast {
|
doLast {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any>
|
val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any>
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val components = manifest.getValue("components") as List<Map<String, Any>>
|
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 reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet()
|
||||||
val resolvedCoordinates = configurations.getByName("testRuntimeClasspath")
|
val resolvedCoordinates = listOf(
|
||||||
|
"debugRuntimeClasspath",
|
||||||
|
"debugUnitTestRuntimeClasspath",
|
||||||
|
"debugAndroidTestRuntimeClasspath",
|
||||||
|
)
|
||||||
|
.flatMap { configurationName ->
|
||||||
|
configurations.getByName(configurationName)
|
||||||
.resolvedConfiguration
|
.resolvedConfiguration
|
||||||
.resolvedArtifacts
|
.resolvedArtifacts
|
||||||
.map { artifact ->
|
.mapNotNull { artifact ->
|
||||||
|
if (artifact.id.componentIdentifier !is org.gradle.api.artifacts.component.ModuleComponentIdentifier) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
val id = artifact.moduleVersion.id
|
val id = artifact.moduleVersion.id
|
||||||
"${id.group}:${id.name}:${id.version}"
|
"${id.group}:${id.name}:${id.version}"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.toSet()
|
.toSet()
|
||||||
val missing = resolvedCoordinates - reviewedCoordinates
|
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()) {
|
check(missing.isEmpty()) {
|
||||||
"Dependencies missing reviewed license entries: ${missing.sorted().joinToString()}"
|
"Dependencies missing reviewed license entries: ${missing.sorted().joinToString()}"
|
||||||
}
|
}
|
||||||
|
logger.lifecycle(
|
||||||
|
"Dependency license coverage passed for ${resolvedCoordinates.size} artifacts " +
|
||||||
|
"with ${groupPolicies.size} reviewed group policies.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package brainwave.core.model
|
package net.opcapp.flash.core.model
|
||||||
|
|
||||||
data class HexagramContent(
|
data class HexagramContent(
|
||||||
val kingWenNumber: Int,
|
val kingWenNumber: Int,
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+4
-2
@@ -1,9 +1,11 @@
|
|||||||
package brainwave.data.content
|
package net.opcapp.flash.data.content
|
||||||
|
|
||||||
import brainwave.core.model.HexagramContent
|
import net.opcapp.flash.core.model.ContentManifest
|
||||||
|
import net.opcapp.flash.core.model.HexagramContent
|
||||||
|
|
||||||
interface HexagramContentRepository {
|
interface HexagramContentRepository {
|
||||||
val contentVersion: String
|
val contentVersion: String
|
||||||
|
val manifest: ContentManifest
|
||||||
|
|
||||||
fun contentFor(
|
fun contentFor(
|
||||||
kingWenNumber: Int,
|
kingWenNumber: Int,
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package brainwave.domain.casting
|
package net.opcapp.flash.domain.casting
|
||||||
|
|
||||||
data class CastComputation internal constructor(
|
data class CastComputation internal constructor(
|
||||||
val roundsBottomUp: List<CastRound>,
|
val roundsBottomUp: List<CastRound>,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package brainwave.domain.casting
|
package net.opcapp.flash.domain.casting
|
||||||
|
|
||||||
data class CastRecordDto(
|
data class CastRecordDto(
|
||||||
val schemaVersion: Int,
|
val schemaVersion: Int,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package brainwave.domain.casting
|
package net.opcapp.flash.domain.casting
|
||||||
|
|
||||||
enum class CoinSide(val score: Int) {
|
enum class CoinSide(val score: Int) {
|
||||||
CHARACTER(2),
|
CHARACTER(2),
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package brainwave.domain.casting
|
package net.opcapp.flash.domain.casting
|
||||||
|
|
||||||
object HexagramCatalog {
|
object HexagramCatalog {
|
||||||
private enum class Trigram(val patternBottomUp: String) {
|
private enum class Trigram(val patternBottomUp: String) {
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -1,13 +1,23 @@
|
|||||||
package brainwave.data.content
|
package net.opcapp.flash.data.content
|
||||||
|
|
||||||
import brainwave.core.model.HexagramContent
|
import net.opcapp.flash.core.model.ContentManifest
|
||||||
|
import net.opcapp.flash.core.model.ContentSource
|
||||||
|
import net.opcapp.flash.core.model.HexagramContent
|
||||||
|
|
||||||
class FakeHexagramContentRepository(
|
class FakeHexagramContentRepository(
|
||||||
override val contentVersion: String = "fixture-only-v1",
|
override val contentVersion: String = "fixture-only-v1",
|
||||||
entries: List<HexagramContent>,
|
entries: List<HexagramContent>,
|
||||||
|
sources: List<ContentSource> = emptyList(),
|
||||||
|
digest: String = "fixture-digest",
|
||||||
) : HexagramContentRepository {
|
) : HexagramContentRepository {
|
||||||
private val entriesById: Map<Int, HexagramContent>
|
private val entriesById: Map<Int, HexagramContent>
|
||||||
|
|
||||||
|
override val manifest: ContentManifest = ContentManifest(
|
||||||
|
contentVersion = contentVersion,
|
||||||
|
digest = digest,
|
||||||
|
sources = sources,
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||||
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
|
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package brainwave.data.content
|
package net.opcapp.flash.data.content
|
||||||
|
|
||||||
import brainwave.core.model.HexagramContent
|
import net.opcapp.flash.core.model.HexagramContent
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Assert.fail
|
import org.junit.Assert.fail
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package brainwave.domain.casting
|
package net.opcapp.flash.domain.casting
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -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("𠀀"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-1
@@ -2,6 +2,10 @@ import org.gradle.api.tasks.Exec
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
base
|
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(
|
fun registerNodeVerificationTask(
|
||||||
@@ -57,11 +61,19 @@ val verifyDependencyLicenseManifest = registerNodeVerificationTask(
|
|||||||
script = "scripts/verify-dependency-licenses.mjs",
|
script = "scripts/verify-dependency-licenses.mjs",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val verifyLocalDataBoundary = registerNodeVerificationTask(
|
||||||
|
name = "verifyLocalDataBoundary",
|
||||||
|
description = "Locks down offline permissions and excludes local records from backup and transfer.",
|
||||||
|
script = "scripts/verify-local-data-boundary.mjs",
|
||||||
|
)
|
||||||
|
|
||||||
tasks.register("verifyLocal") {
|
tasks.register("verifyLocal") {
|
||||||
group = LifecycleBasePlugin.VERIFICATION_GROUP
|
group = LifecycleBasePlugin.VERIFICATION_GROUP
|
||||||
description = "Runs every environment-independent local quality gate."
|
description = "Runs every environment-independent local quality gate."
|
||||||
dependsOn(
|
dependsOn(
|
||||||
":app:test",
|
":app:lintDebug",
|
||||||
|
":app:testDebugUnitTest",
|
||||||
|
":app:assembleDebug",
|
||||||
":app:verifyDependencyLicenses",
|
":app:verifyDependencyLicenses",
|
||||||
verifyDocs,
|
verifyDocs,
|
||||||
scanSecrets,
|
scanSecrets,
|
||||||
@@ -70,6 +82,7 @@ tasks.register("verifyLocal") {
|
|||||||
verifyContentContract,
|
verifyContentContract,
|
||||||
spotlessCheck,
|
spotlessCheck,
|
||||||
verifyDependencyLicenseManifest,
|
verifyDependencyLicenseManifest,
|
||||||
|
verifyLocalDataBoundary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"reviewedAt": "2026-08-04",
|
"reviewedAt": "2026-08-19",
|
||||||
"scopeNote": "Current JVM runtime/test graph and direct build entry points; Android dependencies are not configured yet.",
|
"scopeNote": "Android debug runtime, debug unit-test runtime, and debug instrumentation-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.",
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"id": "gradle-wrapper",
|
"id": "gradle-wrapper",
|
||||||
@@ -11,6 +11,14 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"source": "https://github.com/gradle/gradle/blob/v8.2.0/LICENSE"
|
"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",
|
"id": "kotlin-gradle-plugin",
|
||||||
"coordinates": null,
|
"coordinates": null,
|
||||||
@@ -19,6 +27,54 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
|
"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",
|
"id": "kotlin-stdlib",
|
||||||
"coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20",
|
"coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20",
|
||||||
@@ -29,11 +85,35 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jetbrains-annotations",
|
"id": "jetbrains-annotations",
|
||||||
"coordinates": "org.jetbrains:annotations:13.0",
|
"coordinates": "org.jetbrains:annotations:23.0.0",
|
||||||
"version": "13.0",
|
"version": "23.0.0",
|
||||||
"scope": "runtime-transitive",
|
"scope": "runtime-transitive",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"source": "https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom"
|
"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",
|
"id": "junit4",
|
||||||
@@ -50,6 +130,68 @@
|
|||||||
"scope": "test-transitive",
|
"scope": "test-transitive",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
|
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hamcrest-library",
|
||||||
|
"coordinates": "org.hamcrest:hamcrest-library:1.3",
|
||||||
|
"version": "1.3",
|
||||||
|
"scope": "androidTest-transitive",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hamcrest-integration",
|
||||||
|
"coordinates": "org.hamcrest:hamcrest-integration:1.3",
|
||||||
|
"version": "1.3",
|
||||||
|
"scope": "androidTest-transitive",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gson",
|
||||||
|
"coordinates": "com.google.code.gson:gson:2.9.0",
|
||||||
|
"version": "2.9.0",
|
||||||
|
"scope": "androidTest-transitive",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"source": "https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "javawriter",
|
||||||
|
"coordinates": "com.squareup:javawriter:2.1.1",
|
||||||
|
"version": "2.1.1",
|
||||||
|
"scope": "androidTest-transitive",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"source": "https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groupPolicies": [
|
||||||
|
{
|
||||||
|
"id": "androidx-runtime-policy",
|
||||||
|
"groupPrefix": "androidx",
|
||||||
|
"scope": "runtime-transitive",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dagger-runtime-policy",
|
||||||
|
"groupPrefix": "com.google.dagger",
|
||||||
|
"scope": "runtime-transitive",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kotlin-runtime-policy",
|
||||||
|
"groupPrefix": "org.jetbrains.kotlin",
|
||||||
|
"scope": "runtime-transitive",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kotlinx-runtime-policy",
|
||||||
|
"groupPrefix": "org.jetbrains.kotlinx",
|
||||||
|
"scope": "runtime-transitive",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"source": "https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
/** 灵机自撰白话草稿。按文王序 1–64;每项为 [卦辞, 初, 二, 三, 四, 五, 上]。 */
|
||||||
|
export const lingjiPlain = [
|
||||||
|
["开始即通达,利于守持正道。", "龙还潜藏着,先不要施展。", "龙出现在田野,利于去见有识之人。", "君子整日健进,夜里仍警惕;虽有危难,可以无灾。", "或跳进深渊,可以无灾。", "龙飞在天上,利于去见有识之人。", "龙飞得过高,会有悔恨。"],
|
||||||
|
["开始即通达,利于像母马那样守持正道。君子有所前往,先会迷路,随后才得到依靠。利于西南得到同类,东北失去同类。安于守正,吉。", "踩到霜,坚冰就要来了。", "正直方正而广大,不必演习,没有不利。", "蕴含文采,可以守正。或跟从王事,不求自己成功,却能有结果。", "扎紧口袋,无灾也无称誉。", "黄色下裳,大为吉祥。", "龙在郊野争斗,血流成青黄色。"],
|
||||||
|
["开始即通达,利于守正。不要急着前往,利于立诸侯。", "徘徊不进,利于安居守正,利于立诸侯。", "困顿难行,乘马盘旋。不是强盗,是来求婚。女子守正不嫁,十年才许嫁。", "追鹿却没有虞人向导,只是陷入林中。君子察觉危险,不如放弃;再往前会有憾恨。", "乘马盘旋,去求婚,前往吉,没有不利。", "屯积膏泽。小事守正吉,大事守正凶。", "乘马盘旋,血泪涟涟。"],
|
||||||
|
["亨通。我不是要去求童蒙,是童蒙来求我。初次卜问则告知;再三乱问,就不告知。利于守正。", "启发蒙昧,利于用刑罚纠正人,用桎梏约束;若一再用刑,就有憾恨。", "包容蒙昧,吉。接纳妇人,吉。孩子能自立,家庭就不再受困。", "不要娶这个女子,她见了财礼就失了自身。无利可图。", "困于蒙昧,憾恨。", "童蒙,吉。", "打击蒙昧。不利于做强盗,利于抵御强盗。"],
|
||||||
|
["有所等待,有诚信。光辉亨通,守正吉。利于涉大河。", "在郊野等待,利于用恒常的方法,无灾。", "在沙滩等待,小有议论,最终吉。", "在泥中等待,招来贼寇。", "在血中等待,从洞穴里出来。", "在酒食中等待,守正吉。", "进入洞穴,有不请自来的三位客人;尊敬他们,最终吉。"],
|
||||||
|
["有诚信,在窒塞中警惕,中正则吉,最终凶。利于见大人,不利于涉大河。", "不永久纠缠事务,小有议论,最终吉。", "败于讼事,回家逃避。邑人三百户,无灾。", "食用旧德,守正有危,最终吉。或跟从王事,没有成功。", "不能胜诉,仍复归于常理,改变态度,安静守正则吉。", "讼事,元吉。", "或赐给大带,一天之内被剥夺三次。"],
|
||||||
|
["贞正。大人吉,无灾。兵众,正而不邪才可用。", "出兵要有纪律;否则,凶。", "在队伍中,无灾,王三次赐命。", "师或载尸而还,凶。", "师左次而退,无灾。", "田里有禽,利于执言,无灾。长子帅师,弟子载尸,贞凶。", "大君有命,开国承家。小人不可重用。"],
|
||||||
|
["吉。原筮,元永贞,无灾。不安宁的人前来,后夫凶。", "有诚信,比之无灾。有诚信充满瓦缶,终来有别的吉。", "比之从内,贞吉。", "比之匪人。", "在外相比,贞吉。", "显比。王用三驱,失前禽。邑人不诫,吉。", "比之无首,凶。"],
|
||||||
|
["亨通。密云不雨,自我西郊。", "复归于自己的道路,何其咎?吉。", "牵复,吉。", "舆说辐。夫妻反目。", "有孚,血去惕出,无灾。", "有孚挛如,富以其邻。", "既雨既处,尚德载。妇贞厉。月几望,君子征凶。"],
|
||||||
|
["踩踏老虎尾巴,不咬人,亨通。", "素履往,无灾。", "履道坦坦,幽人贞吉。", "眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。", "履虎尾,愬愬,终吉。", "夬履,贞厉。", "视履考祥,其旋元吉。"],
|
||||||
|
["小往大来,吉,亨通。", "拔茅连根,以其类,征吉。", "包荒,用冯河,不遐遗。朋亡,得尚于中行。", "无平不陂,无往不复。艰贞无灾。勿恤其孚,于食有福。", "翩翩,不富以其邻,不戒以孚。", "帝乙归妹,以祉元吉。", "城复于隍,勿用师。自邑告命,贞吝。"],
|
||||||
|
["否之匪人,不利于君子守正。大的往,小的来。", "拔茅连根,以其类,守正吉,亨通。", "包容奉承。小人吉,大人否塞。亨通。", "包藏羞辱。", "有天命,无灾。同类依附而得福。", "休止否塞,大人吉。心中念着将亡将亡,才能系于苞桑。", "倾覆否塞。先否塞,后喜悦。"],
|
||||||
|
["同人于野,亨通。利于涉大河,利于君子守正。", "同人于门,无灾。", "同人于宗,吝。", "伏戎于莽,升其高陵,三年不兴。", "乘其墉,弗克攻,吉。", "同人,先号咷而后笑。大师克相遇。", "同人于郊,无悔。"],
|
||||||
|
["元亨。", "无交害,匪咎,艰则无灾。", "大车以载,有攸往,无灾。", "公用亨于天子。小人弗克。", "匪其彭,无灾。", "厥孚交如,威如,吉。", "自天佑之,吉无不利。"],
|
||||||
|
["亨通。君子有终。", "谦谦君子,用涉大川,吉。", "鸣谦,贞吉。", "劳谦,君子有终,吉。", "无不利,撝谦。", "不富以其邻,利用侵伐,无不利。", "鸣谦,利用行师,征邑国。"],
|
||||||
|
["利于建侯行师。", "鸣豫,凶。", "介于石,不终日,贞吉。", "盱豫,悔。迟则有悔。", "由豫,大有得。勿疑,朋盍簪。", "贞疾,恒不死。", "冥豫,成有渝,无灾。"],
|
||||||
|
["元亨,利贞,无灾。", "官有渝,贞吉。出门交有功。", "系小子,失丈夫。", "系丈夫,失小子。随有求得,利居贞。", "随有获,贞凶。有孚在道,以明,何咎。", "孚于嘉,吉。", "拘系之,乃从维之。王用亨于西山。"],
|
||||||
|
["元亨,利涉大川。先甲三日,后甲三日。", "干父之蛊,有子,考无咎。厉,终吉。", "干母之蛊,不可贞。", "干父之蛊,小有悔,无大咎。", "裕父之蛊,往见吝。", "干父之蛊,用誉。", "不事王侯,高尚其事。"],
|
||||||
|
["元亨,利贞。至于八月有凶。", "咸临,贞吉。", "咸临,吉,无不利。", "甘临,无攸利。既忧之,无灾。", "至临,无灾。", "知临,大君之宜,吉。", "敦临,吉,无灾。"],
|
||||||
|
["盥而不荐,有孚颙若。", "童观,小人无咎,君子吝。", "窥观,利女贞。", "观我生,进退。", "观国之光,利用宾于王。", "观我生,君子无咎。", "观其生,君子无咎。"],
|
||||||
|
["亨通。利用狱。", "屦校灭趾,无灾。", "噬肤灭鼻,无灾。", "噬腊肉,遇毒。小吝,无灾。", "噬干胏,得金矢。利艰贞,吉。", "噬干肉,得黄金。贞厉,无灾。", "何校灭耳,凶。"],
|
||||||
|
["亨通。小利有攸往。", "贲其趾,舍车而徒。", "贲其须。", "贲如濡如,永贞吉。", "贲如皤如,白马翰如。匪寇婚媾。", "贲于丘园,束帛戋戋。吝,终吉。", "白贲,无灾。"],
|
||||||
|
["不利于有所前往。", "剥床以足,蔑贞,凶。", "剥床以辨,蔑贞,凶。", "剥之,无灾。", "剥床以肤,凶。", "贯鱼,以宫人宠,无不利。", "硕果不食。君子得舆,小人剥庐。"],
|
||||||
|
["亨通。出入没有疾病,朋友来也无灾。一来一回按那条路走,七天会回复。利于有所前往。", "回复得还不远,没有大的悔恨,大为吉祥。", "美好地回复,吉。", "频繁回复,有危难,无灾。", "走在行列中间,独自回复。", "敦厚地回复,没有悔恨。", "迷路却不回复,凶,有灾祸。用此出兵,终有大败,连累国君,凶;到十年还不能出征。"],
|
||||||
|
["元亨,利贞。其匪正有眚,不利有攸往。", "无妄,往吉。", "不耕获,不菑畲,则利有攸往。", "无妄之灾。或系之牛,行人之得,邑人之灾。", "可贞,无灾。", "无妄之疾,勿药有喜。", "无妄,行有眚,无攸利。"],
|
||||||
|
["利贞。不家食,吉。利涉大川。", "有厉,利已。", "舆说輹。", "良马逐,利艰贞。日闲舆卫,利有攸往。", "童牛之牿,元吉。", "豮豕之牙,吉。", "何天之衢,亨。"],
|
||||||
|
["贞吉。观颐,自求口实。", "舍尔灵龟,观我朵颐,凶。", "颠颐,拂经,于丘颐,征凶。", "拂颐,贞凶。十年勿用,无攸利。", "颠颐,吉。虎视眈眈,其欲逐逐,无灾。", "拂经,居贞吉,不可涉大川。", "由颐,厉吉,利涉大川。"],
|
||||||
|
["栋桡。利有攸往,亨。", "藉用白茅,无灾。", "枯杨生稊,老夫得其女妻,无不利。", "栋桡,凶。", "栋隆,吉。有它吝。", "枯杨生华,老妇得其士夫,无咎无誉。", "过涉灭顶,凶,无灾。"],
|
||||||
|
["有孚,维心亨,行有尚。", "习坎,入于坎窞,凶。", "坎有险,求小得。", "来之坎坎,险且枕,入于坎窞,勿用。", "樽酒簋贰,用缶,纳约自牖,终无灾。", "坎不盈,祗既平,无灾。", "系用徽纆,置于丛棘,三岁不得,凶。"],
|
||||||
|
["利贞,亨。畜牝牛,吉。", "履错然,敬之,无灾。", "黄离,元吉。", "日昃之离,不鼓缶而歌,则大耋之嗟,凶。", "突如其来如,焚如,死如,弃如。", "出涕沱若,戚嗟若,吉。", "王用出征,有嘉折首,获匪其丑,无灾。"],
|
||||||
|
["亨通。利贞。取女吉。", "咸其拇。", "咸其腓,凶。居吉。", "咸其股,执其随,往吝。", "贞吉,悔亡。憧憧往来,朋从尔思。", "咸其脢,无悔。", "咸其辅颊舌。"],
|
||||||
|
["亨通。无灾。利于守正。利于有所前往。", "深求恒久,守正则凶,无所利。", "悔恨消失。", "不能恒久其德,或许会承受羞辱,守正有憾。", "田里没有禽兽。", "恒久其德,守正。妇人吉,男子凶。", "振动不安于恒,凶。"],
|
||||||
|
["亨通。小利于守正。", "遁走只剩尾巴,有危,不要有所前往。", "用黄牛皮捆住,没有人能解脱。", "被牵绊着退避,有疾病和危难。畜养臣妾,吉。", "喜好退避,君子吉,小人否塞。", "嘉美地退避,守正吉。", "充裕地退避,没有不利。"],
|
||||||
|
["利于守正。", "壮在脚趾,出征凶,有诚信。", "贞吉。", "小人用壮,君子用罔,贞厉。公羊触藩,缠住了角。", "贞吉,悔恨消失。藩篱没被破坏,壮在大车的辐上。", "在田里丧失羊,无悔。", "公羊触藩,不能退不能进,无攸利。艰难则吉。"],
|
||||||
|
["康侯用锡马蕃庶,白天三次接见。", "晋如摧如,贞吉。罔孚,裕无灾。", "晋如愁如,贞吉。受兹介福,于其王母。", "众允,悔亡。", "晋如鼫鼠,贞厉。", "悔亡,失得勿恤。往吉,无不利。", "晋其角,维用伐邑,厉吉,无灾,贞吝。"],
|
||||||
|
["利于艰难中守正。", "明夷于飞,垂其翼。君子于行,三日不食。有攸往,主人有言。", "明夷,夷于左股,用拯马壮,吉。", "明夷于南狩,得其大首,不可疾贞。", "入于左腹,获明夷之心,于出门庭。", "箕子之明夷,利贞。", "不明晦。初登于天,后入于地。"],
|
||||||
|
["利于女子守正。", "闲有家,悔亡。", "无攸遂,在中馈,贞吉。", "家人嗃嗃,悔厉吉;妇子嘻嘻,终吝。", "富家,大吉。", "王假有家,勿恤,吉。", "有孚威如,终吉。"],
|
||||||
|
["小事吉。", "悔亡。丧马勿逐,自复。见恶人,无灾。", "遇主于巷,无灾。", "见舆曳,其牛掣,其人天且劓。无初有终。", "睽孤,遇元夫,交孚,厉无灾。", "悔亡。厥宗噬肤,往何咎。", "睽孤,见豕负涂,载鬼一车。先张之弧,后说之弧。匪寇婚媾。往遇雨则吉。"],
|
||||||
|
["利西南,不利东北。利见大人。贞吉。", "往蹇,来誉。", "王臣蹇蹇,匪躬之故。", "往蹇,来反。", "往蹇,来连。", "大蹇,朋来。", "往蹇,来硕,吉。利见大人。"],
|
||||||
|
["利西南。无所往,其来复吉。有攸往,夙吉。", "无灾。", "田获三狐,得黄矢,贞吉。", "负且乘,致寇至,贞吝。", "解而拇,朋至斯孚。", "君子维有解,吉。有孚于小人。", "公用射隼于高墉之上,获之,无不利。"],
|
||||||
|
["有孚,元吉,无灾,可贞,利有攸往。曷之用?二簋可用享。", "已事遄往,无灾。酌损之。", "利贞。征凶。弗损,益之。", "三人行则损一人,一人行则得其友。", "损其疾,使遄有喜,无灾。", "或益之十朋之龟,弗克违,元吉。", "弗损,益之,无灾。贞吉。利有攸往,得臣无家。"],
|
||||||
|
["利有攸往。利涉大川。", "利用为大作,元吉,无灾。", "或益之十朋之龟,弗克违。永贞吉。王用享于帝,吉。", "益之用凶事,无灾。有孚中行,告公用圭。", "中行,告公从,利用为依迁国。", "有孚惠心,勿问元吉。有孚惠我德。", "莫益之,或击之,立心勿恒,凶。"],
|
||||||
|
["扬于王庭。孚号有厉。告自邑,不利即戎,利有攸往。", "壮于前趾,往不胜为咎。", "惕号,莫夜有戎,勿恤。", "壮于頄,有凶。君子夬夬,独行遇雨,若濡有愠,无灾。", "臀无肤,其行次且。牵羊悔亡,闻言不信。", "苋陆夬夬,中行无灾。", "无号,终有凶。"],
|
||||||
|
["女壮,勿用取女。", "系于金柅,贞吉。有攸往,见凶。羸豕孚蹢躅。", "包有鱼,无灾,不利宾。", "臀无肤,其行次且,厉,无大咎。", "包无鱼,起凶。", "以杞包瓜,含章,有陨自天。", "姤其角,吝,无灾。"],
|
||||||
|
["亨通。王假有庙。利见大人,亨,利贞。用大牲吉。利有攸往。", "有孚不终,乃乱乃萃。若号,一握为笑,勿恤,往无灾。", "引吉,无灾。孚乃利用禴。", "萃如嗟如,无攸利。往无灾,小吝。", "大吉,无灾。", "萃有位,无灾。匪孚,元永贞,悔亡。", "赍咨涕洟,无灾。"],
|
||||||
|
["元亨。用见大人,勿恤。南征吉。", "允升,大吉。", "孚乃利用禴,无灾。", "升虚邑。", "王用亨于岐山,吉,无灾。", "贞吉,升阶。", "冥升,利于不息之贞。"],
|
||||||
|
["亨通。贞,大人吉,无灾。有言不信。", "臀困于株木,入于幽谷,三岁不觌。", "困于酒食,朱绂方来,利用享祀。征凶,无灾。", "困于石,据于蒺藜。入于其宫,不见其妻,凶。", "来徐徐,困于金车,吝,有终。", "劓刖,困于赤绂。乃徐有说,利用祭祀。", "困于葛藟,于臲卼。曰动悔有悔,征吉。"],
|
||||||
|
["改邑不改井,无丧无得。往来井井。汔至,亦未繘井,羸其瓶,凶。", "井泥不食,旧井无禽。", "井谷射鲋,瓮敝漏。", "井渫不食,为我心恻。可用汲,王明,并受其福。", "井甃,无灾。", "井冽,寒泉食。", "井收勿幕,有孚元吉。"],
|
||||||
|
["己日乃孚。元亨,利贞,悔亡。", "巩用黄牛之革。", "己日乃革之,征吉,无灾。", "征凶,贞厉。革言三就,有孚。", "悔亡。有孚改命,吉。", "大人虎变,未占有孚。", "君子豹变,小人革面。征凶,居贞吉。"],
|
||||||
|
["元吉,亨通。", "鼎颠趾,利出否。得妾以其子,无灾。", "鼎有实。我仇有疾,不我能即,吉。", "鼎耳革,其行塞,雉膏不食。方雨亏悔,终吉。", "鼎折足,覆公餗,其形渥,凶。", "鼎黄耳金铉,利贞。", "鼎玉铉,大吉,无不利。"],
|
||||||
|
["亨通。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。", "震来虩虩,后笑言哑哑,吉。", "震来厉,亿丧贝。跻于九陵,勿逐,七日得。", "震苏苏,震行无眚。", "震遂泥。", "震往来厉,亿无丧,有事。", "震索索,视矍矍,征凶。震不于其躬,于其邻,无灾。婚媾有言。"],
|
||||||
|
["艮其背,不获其身;行其庭,不见其人,无灾。", "艮其趾,无灾,利永贞。", "艮其腓,不拯其随,其心不快。", "艮其限,列其夤,厉薰心。", "艮其身,无灾。", "艮其辅,言有序,悔亡。", "敦艮,吉。"],
|
||||||
|
["女归吉。利贞。", "鸿渐于干,小子厉,有言,无灾。", "鸿渐于磐,饮食衎衎,吉。", "鸿渐于陆,夫征不复,妇孕不育,凶。利御寇。", "鸿渐于木,或得其桷,无灾。", "鸿渐于陵,妇三岁不孕,终莫之胜,吉。", "鸿渐于陆,其羽可用为仪,吉。"],
|
||||||
|
["征凶,无攸利。", "归妹以娣,跛能履,征吉。", "眇能视,利幽人之贞。", "归妹以须,反归以娣。", "归妹愆期,迟归有时。", "帝乙归妹,其君之袂不如其娣之袂良。月几望,吉。", "女承筐无实,士刲羊无血,无攸利。"],
|
||||||
|
["亨通。王假之。勿忧,宜日中。", "遇其配主,虽旬无灾,往有尚。", "丰其蔀,日中见斗。往得疑疾,有孚发若,吉。", "丰其沛,日中见沫。折其右肱,无灾。", "丰其蔀,日中见斗。遇其夷主,吉。", "来章,有庆誉,吉。", "丰其屋,蔀其家,窥其户,阒其无人,三岁不觌,凶。"],
|
||||||
|
["小亨。旅贞吉。", "旅琐琐,斯其所取灾。", "旅即次,怀其资,得童仆贞。", "旅焚其次,丧其童仆,贞厉。", "旅于处,得其资斧,我心不快。", "射雉,一矢亡,终以誉命。", "鸟焚其巢,旅人先笑后号咷。丧牛于易,凶。"],
|
||||||
|
["小亨。利有攸往。利见大人。", "进退,利武人之贞。", "巽在床下,用史巫纷若,吉,无灾。", "频巽,吝。", "悔亡,田获三品。", "贞吉,悔亡,无不利。无初有终。先庚三日,后庚三日,吉。", "巽在床下,丧其资斧,贞凶。"],
|
||||||
|
["亨通。利贞。", "和兑,吉。", "孚兑,吉,悔亡。", "来兑,凶。", "商兑未宁,介疾有喜。", "孚于剥,有厉。", "引兑。"],
|
||||||
|
["亨通。王假有庙。利涉大川,利贞。", "用拯马壮,吉。", "涣奔其机,悔亡。", "涣其躬,无悔。", "涣其群,元吉。涣有丘,匪夷所思。", "涣汗其大号,涣王居,无灾。", "涣其血,去逖出,无灾。"],
|
||||||
|
["亨通。苦节不可贞。", "不出户庭,无灾。", "不出门庭,凶。", "不节若,则嗟若,无灾。", "安节,亨。", "甘节,吉。往有尚。", "苦节,贞凶,悔亡。"],
|
||||||
|
["豚鱼吉。利涉大川,利贞。", "虞吉,有它不燕。", "鸣鹤在阴,其子和之。我有好爵,吾与尔靡之。", "得敌,或鼓或罢,或泣或歌。", "月几望,马匹亡,无灾。", "有孚挛如,无灾。", "翰音登于天,贞凶。"],
|
||||||
|
["亨通。利贞。可小事,不可大事。飞鸟遗之音,不宜上,宜下,大吉。", "飞鸟以凶。", "过其祖,遇其妣。不及其君,遇其臣,无灾。", "弗过防之,从或戕之,凶。", "无灾。弗过遇之。往厉必戒,勿用永贞。", "密云不雨,自我西郊。公弋取彼在穴。", "弗遇过之,飞鸟离之,凶。是谓灾眚。"],
|
||||||
|
["亨通。小利贞。初吉终乱。", "曳其轮,濡其尾,无灾。", "妇丧其茀,勿逐,七日得。", "高宗伐鬼方,三年克之,小人勿用。", "繻有衣袽,终日戒。", "东邻杀牛,不如西邻之禴祭,实受其福。", "濡其首,厉。"],
|
||||||
|
["亨通。小狐汔济,濡其尾,无攸利。", "濡其尾,吝。", "曳其轮,贞吉。", "未济,征凶,利涉大川。", "贞吉,悔亡。震用伐鬼方,三年有赏于大国。", "贞吉,无悔。君子之光,有孚,吉。", "有孚于饮酒,无灾。濡其首,有孚失是。"],
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -1,14 +1,14 @@
|
|||||||
# Brainwave 项目文档
|
# Brainwave 项目文档
|
||||||
|
|
||||||
> 文档状态:方案基线
|
> 文档状态:方案基线
|
||||||
> 最后核验:2026-08-04
|
> 最后核验:2026-08-19
|
||||||
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策
|
> 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 已接入维基文库《易经》原文与项目自撰白话草稿,待内容负责人审校;P-1 v0.3 待视觉复核;P5 未开始
|
||||||
|
|
||||||
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
|
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
|
||||||
|
|
||||||
## 项目一句话
|
## 项目一句话
|
||||||
|
|
||||||
Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
|
灵机(仓库代号 Brainwave)是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
|
||||||
|
|
||||||
## 文档地图
|
## 文档地图
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
|||||||
| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
|
| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
|
||||||
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
|
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
|
||||||
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
|
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
|
||||||
|
| [内容审校](content-review.md) | 审校卦辞来源或准备发布时 | 来源、异文和签核状态 |
|
||||||
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
|
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
|
||||||
| [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 |
|
| [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 |
|
||||||
| [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 |
|
| [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 |
|
||||||
@@ -69,6 +70,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
|||||||
已确认:
|
已确认:
|
||||||
|
|
||||||
- 目标平台为原生 Android。
|
- 目标平台为原生 Android。
|
||||||
|
- 正式产品名为“灵机”,namespace/application ID 为 `net.opcapp.flash`,`minSdk=26`。
|
||||||
- 使用 Kotlin、Jetpack Compose 和单 Activity 架构。
|
- 使用 Kotlin、Jetpack Compose 和单 Activity 架构。
|
||||||
- 起卦完全在本地完成,AI 不得参与或更改起卦结果。
|
- 起卦完全在本地完成,AI 不得参与或更改起卦结果。
|
||||||
- 用户真实投币并录入;MVP 不提供随机起卦按钮。
|
- 用户真实投币并录入;MVP 不提供随机起卦按钮。
|
||||||
@@ -89,4 +91,4 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
|||||||
- 测试命令、构建方式或发布门禁改变;
|
- 测试命令、构建方式或发布门禁改变;
|
||||||
- 出现可能再次发生的缺陷。
|
- 出现可能再次发生的缺陷。
|
||||||
|
|
||||||
文档链接、需求编号和验证命令将随工程骨架一起接入 CI 检查。当前尚无 Gradle 工程,不能声称任何构建或测试已经通过。
|
文档链接、需求编号和验证命令由 `verifyLocal` 机械检查。授权真机存在时另跑设备仪器测试;未运行的门禁不得写成已经通过。
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ Android 官方建议新应用采用清晰的 UI/数据分层、单向数据流
|
|||||||
工程初始化后建议采用:
|
工程初始化后建议采用:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/src/main/java/<package>/
|
app/src/main/java/net/opcapp/flash/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── BrainwaveApplication.kt
|
│ ├── LingjiApplication.kt
|
||||||
│ ├── MainActivity.kt
|
│ ├── MainActivity.kt
|
||||||
│ └── BrainwaveNavHost.kt
|
│ └── LingjiApp.kt
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── model/ # 跨层不可变领域模型
|
│ ├── model/ # 跨层不可变领域模型
|
||||||
│ ├── designsystem/ # 主题、字体、间距、卦象 Canvas
|
│ ├── designsystem/ # 主题、字体、间距、卦象 Canvas
|
||||||
@@ -69,6 +69,8 @@ app/src/main/java/<package>/
|
|||||||
|
|
||||||
测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。
|
测试按相同包结构镜像放入 `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. 依赖方向
|
## 4. 依赖方向
|
||||||
|
|
||||||
允许:
|
允许:
|
||||||
@@ -180,7 +182,7 @@ App → HTTPS backend → model provider
|
|||||||
- 禁止直接引入完整“算命 SDK”或无法审计的卦象计算库。
|
- 禁止直接引入完整“算命 SDK”或无法审计的卦象计算库。
|
||||||
- Compose 依赖使用 BOM 对齐版本。
|
- Compose 依赖使用 BOM 对齐版本。
|
||||||
- `compileSdk`/`targetSdk` 使用实现时最新稳定且满足商店要求的版本,不在方案文档硬编码会迅速过期的数值。
|
- `compileSdk`/`targetSdk` 使用实现时最新稳定且满足商店要求的版本,不在方案文档硬编码会迅速过期的数值。
|
||||||
- `minSdk` 默认提案为 26,最终值见[决策记录](decisions.md#未决问题)。
|
- `minSdk=26` 已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认;提高最低版本必须以依赖要求或覆盖率数据另立决策。
|
||||||
|
|
||||||
## 11. 多模块化触发条件
|
## 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,6 +1,6 @@
|
|||||||
# 数据、内容与隐私契约
|
# 数据、内容与隐私契约
|
||||||
|
|
||||||
> 状态:结构已定义,内容来源与授权仍为发布阻塞项
|
> 状态:`zh-Hans-2026.1` 已接入应用;白话为草稿,发布仍待审校
|
||||||
> 适用范围:卦库 assets、Room、DataStore、导入脚本和内容审核
|
> 适用范围:卦库 assets、Room、DataStore、导入脚本和内容审核
|
||||||
|
|
||||||
## 1. 数据分类
|
## 1. 数据分类
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。
|
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。
|
||||||
|
|
||||||
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。
|
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。`scripts/verify-content-contract.mjs` 校验夹具与 `content/packages/hexagram-content.json`;Kotlin `HexagramContentParser` 用同一套规则加载 assets。`HexagramContentRepository` 在缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。来源与审校状态见 [内容审校](content-review.md)。
|
||||||
|
|
||||||
## 3. 内容完整性门禁
|
## 3. 内容完整性门禁
|
||||||
|
|
||||||
@@ -88,6 +88,8 @@
|
|||||||
|
|
||||||
## 5. Room 数据模型提案
|
## 5. Room 数据模型提案
|
||||||
|
|
||||||
|
当前实现只有 `CastingSessionEntity` / `casting_sessions`(schema version 1)。`ExplanationEntity` 与 `ActionNoteEntity` 仍是提案,不得在 UI 中假装已经保存解读或行动。
|
||||||
|
|
||||||
历史记录不是起卦事实源,但应保存可复核的快照:
|
历史记录不是起卦事实源,但应保存可复核的快照:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+36
-5
@@ -131,16 +131,47 @@
|
|||||||
- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。
|
- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。
|
||||||
- 复审触发:Android 工具链启用、现有脚本无法表达新边界,或专用工具能以可接受成本提供明显更强的检查。
|
- 复审触发: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 | 问题 | 推荐默认 | 阻塞阶段 |
|
| ID | 问题 | 推荐默认 | 阻塞阶段 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| TBD-001 | 正式产品名与应用图标 | Brainwave 仅作代码代号 | P0 商店配置 |
|
| TBD-001 | 应用图标与商店素材 | 产品名已由 ADR-014 确认为“灵机”;图标不使用未确认成稿 | P6 商店配置 |
|
||||||
| TBD-002 | application ID | 使用组织所有的反向域名 | P0 |
|
|
||||||
| TBD-003 | minSdk | 26;创建工程时复核覆盖率和依赖要求 | P0 |
|
|
||||||
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
|
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
|
||||||
| TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 |
|
| TBD-005 | 经典原文、现代白话的版本与授权 | 已由 ADR-015 接受;内容负责人审校仍阻塞发布 | P2,发布阻塞 |
|
||||||
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 |
|
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 已由 ADR-016 接受:全九/全六时展示 | P2/P3 |
|
||||||
| TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 |
|
| TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 |
|
||||||
| TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 |
|
| TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 |
|
||||||
| TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 |
|
| TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 |
|
||||||
|
|||||||
+37
-10
@@ -1,17 +1,44 @@
|
|||||||
# 依赖与许可证清单
|
# 依赖与许可证清单
|
||||||
|
|
||||||
> 状态:当前 JVM harness 已核验;Android application 依赖尚未配置
|
> 状态:Android debug runtime、debug unit-test runtime 与 debug instrumentation-test runtime 依赖图已建立自动覆盖门禁
|
||||||
> 适用范围:实际解析的 JVM runtime/test graph,以及直接使用的构建入口
|
> 适用范围:实际解析的 Android 调试运行时、单元测试/仪器测试运行时,以及直接使用的构建入口
|
||||||
|
|
||||||
本清单不是未来 Android APK 的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal --offline`;Android 依赖启用后,还要生成 release runtime 的完整报告并复核是否需要在应用或分发包中附带许可证文本。
|
本清单不是上架包的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal`;发布前仍须对 release runtime 生成完整报告,复核许可证文本与随包义务。
|
||||||
|
|
||||||
|
## 直接条目
|
||||||
|
|
||||||
| ID | 版本 | 当前范围 | SPDX | 权威许可来源 |
|
| ID | 版本 | 当前范围 | SPDX | 权威许可来源 |
|
||||||
|---|---:|---|---|---|
|
|---|---:|---|---|---|
|
||||||
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle 8.2 license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) |
|
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) |
|
||||||
| `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin 1.9.20 license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
|
| `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-stdlib` | 1.9.20 | JVM runtime | Apache-2.0 | [Kotlin 1.9.20 license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
|
| `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
|
||||||
| `jetbrains-annotations` | 13.0 | JVM runtime transitive | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom) |
|
| `hilt-gradle-plugin` | 2.48.1 | 构建插件 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
|
||||||
| `junit4` | 4.13.2 | test | EPL-1.0 | [JUnit 4.13.2 license](https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt) |
|
| `compose-bom` | 2023.10.01 | 运行时版本约束 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
|
||||||
| `hamcrest-core` | 1.3 | test transitive | BSD-3-Clause | [Hamcrest 1.3 license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/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) |
|
||||||
|
|
||||||
当前没有第三方 Android runtime 库、字体、纹理、插画、音效或可发布《易经》内容进入工程;这句话只描述本提交时的依赖图,不构成未来授权。
|
## 传递依赖组策略
|
||||||
|
|
||||||
|
下列策略只允许当前解析图中的 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,7 +5,7 @@
|
|||||||
|
|
||||||
本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。
|
本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。
|
||||||
|
|
||||||
实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/brainwave/domain/casting/`;`.\gradlew.bat :app:test --offline` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。
|
实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/net/opcapp/flash/domain/casting/`;`.\gradlew.bat :app:testDebugUnitTest` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。
|
||||||
|
|
||||||
## 1. 术语和类型
|
## 1. 术语和类型
|
||||||
|
|
||||||
|
|||||||
+14
-17
@@ -1,7 +1,7 @@
|
|||||||
# 本地开发环境基线
|
# 本地开发环境基线
|
||||||
|
|
||||||
> 状态:已从当前主机实测
|
> 状态:已从当前主机实测
|
||||||
> 最后核验:2026-08-04
|
> 最后核验:2026-08-19
|
||||||
> 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证
|
> 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证
|
||||||
|
|
||||||
本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。
|
本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
| Node.js / npm | `22.22.1` / `11.12.1` |
|
| Node.js / npm | `22.22.1` / `11.12.1` |
|
||||||
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
|
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
|
||||||
|
|
||||||
首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些仓库级入口已经建立;`app` 暂时是纯 Kotlin/JVM 领域 harness,不是可安装 Android application。
|
首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些入口和可安装的 Android application 已建立;正式应用名为“灵机”,application ID 为 `net.opcapp.flash`。
|
||||||
|
|
||||||
Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。
|
Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。
|
||||||
|
|
||||||
@@ -111,23 +111,21 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
|
|||||||
.\gradlew.bat <task>
|
.\gradlew.bat <task>
|
||||||
```
|
```
|
||||||
|
|
||||||
不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、Kotlin 1.9.20 与 JUnit 4.13.2 可离线完成领域构建和测试;Compose、Hilt、Room 等 Android 依赖仍未配置或验证,不能据此推断可离线解析。
|
不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、AGP 8.2.0、Kotlin 1.9.20、Compose、Hilt、Room、DataStore、Navigation 与 JUnit 可完成 Android debug 构建、lint、单元测试和测试 APK 编译;首次解析已经联网完成,后续离线可用性仍以实际命令为准。
|
||||||
|
|
||||||
## 7. 已连接 Android 真机
|
## 7. 已连接 Android 真机
|
||||||
|
|
||||||
检测时存在 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
|
2026-08-07 本轮曾检测到 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
|
||||||
|
|
||||||
| 项目 | 实测值 |
|
| 项目 | 实测值 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 厂商/型号 | OnePlus `PKG110` |
|
| 厂商/型号 | Samsung `SM-G9700` |
|
||||||
| Android | 16 |
|
| Android | 12 |
|
||||||
| API Level | 36 |
|
| API Level | 31 |
|
||||||
| ABI | `arm64-v8a` |
|
| 物理分辨率 | `1080 × 2280` |
|
||||||
| 物理分辨率 | `1264 × 2780` |
|
| 物理密度 | `480 dpi` |
|
||||||
| 当前覆盖分辨率 | `1080 × 2376` |
|
|
||||||
| 物理/覆盖密度 | `560 / 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. 网络与代理状态
|
## 8. 网络与代理状态
|
||||||
|
|
||||||
@@ -146,21 +144,20 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
|
|||||||
2. 使用项目自带 Gradle Wrapper,不依赖全局 `gradle` 或 `kotlinc`。
|
2. 使用项目自带 Gradle Wrapper,不依赖全局 `gradle` 或 `kotlinc`。
|
||||||
3. 首次可构建配置只能依赖已安装的 SDK Platform 34 和 Build Tools 34.0.0;若模板要求更高版本,应先记录差异,再决定调整模板或安装 SDK。
|
3. 首次可构建配置只能依赖已安装的 SDK Platform 34 和 Build Tools 34.0.0;若模板要求更高版本,应先记录差异,再决定调整模板或安装 SDK。
|
||||||
4. 命令行构建是必须能力,不假设 Android Studio 存在。
|
4. 命令行构建是必须能力,不假设 Android Studio 存在。
|
||||||
5. 仪器测试优先使用已连接的 API 36、arm64 真机;不假设 Emulator/AVD 存在。
|
5. 仪器测试优先使用当前可用的 API 31 三星真机;不假设 Emulator/AVD 或固定设备存在。
|
||||||
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
|
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
|
||||||
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
|
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
|
||||||
8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
|
8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
|
||||||
|
|
||||||
## 10. 已知缺口
|
## 10. 已知缺口
|
||||||
|
|
||||||
- 可安装的 Android application 壳尚未创建;正式名称、组织所有的 application ID 与最终 `minSdk` 仍待确认。
|
|
||||||
- Android Studio 未安装。
|
- Android Studio 未安装。
|
||||||
- Android Emulator、system image 和 AVD 不可用。
|
- Android Emulator、system image 和 AVD 不可用。
|
||||||
- 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。
|
- 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。
|
||||||
- Compose、Hilt、Room、DataStore 与 Navigation 的 Maven 依赖尚未完整离线验证。
|
- `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。
|
||||||
- 代理已配置,但外部仓库和 Android CLI 网络连通性尚未验证。
|
- 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。
|
||||||
|
|
||||||
已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI 与可执行的 `verifyLocal` 已建立;`verifyLocal --offline` 已在 JDK 17 上通过。它当前不包含 Android lint、APK 构建或设备测试。
|
已解除的缺口: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)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
|
这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
|
||||||
|
|
||||||
|
|||||||
+35
-31
@@ -1,6 +1,6 @@
|
|||||||
# 分阶段实施计划
|
# 分阶段实施计划
|
||||||
|
|
||||||
> 状态:P-1 v0.3 待视觉复核;P0 仓库门禁已建立但 Android 壳受 TBD-001~003 阻塞;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始
|
> 状态:P0/P1 已完成;P3 离线主流程已落地但未达到 UX 全表验收;P4 会话保存已落地,问卦簿/解读未开始;P2 已接入公版《易经》与自撰白话草稿,待审校签核;P-1 v0.3 待视觉复核
|
||||||
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
|
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
|
||||||
|
|
||||||
## 1. 依赖图
|
## 1. 依赖图
|
||||||
@@ -47,11 +47,11 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
|||||||
任务:
|
任务:
|
||||||
|
|
||||||
- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
|
- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
|
||||||
- [ ] 基于 Google `android/architecture-templates` 的 `base` 分支初始化 Android 壳;模板定制需要 TBD-002 的正式 application ID,不能用临时发布身份替代。
|
- [x] 参照 Google `android/architecture-templates` 的 `base` 分支建立单模块 Android 壳和分层边界。
|
||||||
- [ ] 确认正式应用名称、package/application ID、minSdk(TBD-001~003)。
|
- [x] 确认正式应用名称“灵机”、namespace/application ID `net.opcapp.flash`、`minSdk=26`(ADR-014)。
|
||||||
- [ ] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation;Version Catalog 已先用于 JVM harness。
|
- [x] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation,并由 Version Catalog 固定兼容版本。
|
||||||
- [ ] 配置 Gradle Wrapper、格式化、lint、单元测试和 CI:Wrapper、无依赖格式门禁、JVM 单测与 CI 已完成;Android lint 要等 application 插件启用。
|
- [x] 配置 Gradle Wrapper、格式、Android lint、单元测试、debug APK、依赖许可证门禁和 CI 同入口验证。
|
||||||
- [ ] 建立 [系统架构](architecture.md)中的包结构和空 feature 边界:`domain/casting` 已落地,其余随 Android 壳建立。
|
- [x] 建立 [系统架构](architecture.md)中的 `app`、`core/designsystem`、`domain`、`data`、`feature/home` 与 `feature/result` 边界;未实施 feature 不创建空包。
|
||||||
- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
|
- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
|
||||||
- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。
|
- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。
|
||||||
|
|
||||||
@@ -59,9 +59,11 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
|||||||
|
|
||||||
- Windows 上一条命令可执行格式、lint、单测和 debug 构建。
|
- Windows 上一条命令可执行格式、lint、单测和 debug 构建。
|
||||||
- CI 使用同一组命令。
|
- 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:领域核心
|
## 4. P1:领域核心
|
||||||
|
|
||||||
目标:在纯 Kotlin 中完成并证明三枚铜币算法。
|
目标:在纯 Kotlin 中完成并证明三枚铜币算法。
|
||||||
@@ -82,7 +84,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
|||||||
- 测试无随机、无网络、无系统时间依赖。
|
- 测试无随机、无网络、无系统时间依赖。
|
||||||
- `CastEngine` API 经评审后冻结为 `coin-v1`。
|
- `CastEngine` API 经评审后冻结为 `coin-v1`。
|
||||||
|
|
||||||
当前证据:`.\gradlew.bat :app:test --offline` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/brainwave/domain/casting/`。`brainwave` 是内部代码命名空间,不是 TBD-002 的 application ID。
|
当前证据:`.\gradlew.bat :app:testDebugUnitTest` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/net/opcapp/flash/domain/casting/`,且领域边界脚本禁止 Android/Compose/数据库/网络依赖。
|
||||||
|
|
||||||
## 5. P2:内容数据管线
|
## 5. P2:内容数据管线
|
||||||
|
|
||||||
@@ -90,18 +92,18 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
|||||||
|
|
||||||
任务:
|
任务:
|
||||||
|
|
||||||
- [ ] 决定原文版本、现代白话来源和授权(TBD-005,发布阻塞)。
|
- [x] 决定原文版本、现代白话来源和授权(TBD-005):ADR-015 接受“自有白话 + 维基文库公版《易经》原文”;发布仍待审校签核。
|
||||||
- [ ] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema 已完成;Android/Kotlin assets 解析器待 Android 壳建立。
|
- [x] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema、Kotlin assets 解析器与 `zh-Hans-2026.1` 包已落地。
|
||||||
- [ ] 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。
|
- [x] 录入/导入 64 卦、卦辞、384 条爻辞及乾用九、坤用六;白话为项目自撰草稿。
|
||||||
- [ ] 建立来源清单、许可证清单和内容审核记录;schema 已强制每个来源包含版本、许可证与 URL。
|
- [x] 建立来源清单、许可证清单和内容审核记录:见 [内容审校](content-review.md);schema 已强制每个来源包含版本、许可证与 URL。
|
||||||
- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。
|
- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。
|
||||||
- [ ] 实现 `HexagramContentRepository` fake 与 assets 版本:接口、强校验只读模型和测试 fake 已完成,assets 实现待 Android parser。
|
- [x] 实现 `HexagramContentRepository` fake 与 assets 版本:加载失败时展示完整性错误,不回退到相邻卦。
|
||||||
|
|
||||||
退出条件:
|
退出条件:
|
||||||
|
|
||||||
- 64 卦/384 爻数据完整、唯一且来源可追踪。
|
- [x] 64 卦/384 爻数据完整、唯一且来源可追踪。
|
||||||
- 缺失、重复、非法顺序和错误映射测试均能失败。
|
- [x] 缺失、重复、非法顺序和错误映射测试均能失败。
|
||||||
- 内容负责人确认可再分发。
|
- [ ] 内容负责人确认可再分发。
|
||||||
|
|
||||||
## 6. P3:核心用户流程与东方设计系统
|
## 6. P3:核心用户流程与东方设计系统
|
||||||
|
|
||||||
@@ -109,13 +111,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
|||||||
|
|
||||||
任务:
|
任务:
|
||||||
|
|
||||||
- 实现颜色、字体、间距、形状和动画令牌。
|
- [x] 实现颜色、字体、间距、形状和动画令牌:当前为 `LingjiTheme` 纸墨朱砂配色与系统衬线/无衬线配对;独立 token 文件、指定字体包和动效仍未做。
|
||||||
- 实现卦象 `Canvas`、动爻标记和读屏语义。
|
- [x] 实现卦象 `Canvas`、动爻标记和读屏语义:结果页绘制本卦/之卦,动爻同时使用颜色、圆点和文字。
|
||||||
- 实现首次说明、起念、投币和结果页面。
|
- [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。
|
||||||
- 实现 `CastingSessionViewModel` 状态机及 SavedState 恢复。
|
- [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。
|
||||||
- 支持前五轮返回修改、第六轮封印和明确重新起卦。
|
- [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。
|
||||||
- 接入本地内容并区分原文/本地白话。
|
- [x] 接入本地内容并区分原文/本地白话:结果页展示本卦卦辞、动爻爻辞和之卦卦辞,并标注原文/白话;乾全九、坤全六时展示用九/用六。白话仍为待审校草稿。
|
||||||
- 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配。
|
- [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。
|
||||||
|
|
||||||
退出条件:
|
退出条件:
|
||||||
|
|
||||||
@@ -124,20 +126,22 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
|||||||
- AI/网络代码尚未存在也不影响流程。
|
- AI/网络代码尚未存在也不影响流程。
|
||||||
- [UX 与东方视觉](ux-design.md)检查表通过。
|
- [UX 与东方视觉](ux-design.md)检查表通过。
|
||||||
|
|
||||||
|
当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。内容包已接入但白话待审校。
|
||||||
|
|
||||||
## 7. P4:本地解释与历史
|
## 7. P4:本地解释与历史
|
||||||
|
|
||||||
目标:形成不依赖 AI 的完整 MVP。
|
目标:形成不依赖 AI 的完整 MVP。
|
||||||
|
|
||||||
任务:
|
任务:
|
||||||
|
|
||||||
- 实现版本化本地解释模板。
|
- [ ] 实现版本化本地解释模板。
|
||||||
- 实现“可以试的一小步”的非裁决式结构。
|
- [ ] 实现“可以试的一小步”的非裁决式结构。
|
||||||
- 实现 Room 历史:第六爻锁定后按策略自动保存快照,问题原文、当次解读和行动记录默认保存到同一会话。
|
- [x] 实现 Room 历史会话:第六爻锁定后按 `autoSaveHistory` 写入 `casting_sessions` 快照;问题原文受内容开关控制。解读与行动表尚未建立。
|
||||||
- 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;支持总开关关闭后的“保存本次”和全局设置不变的“本次不保存/删除本次记录”。
|
- [x] 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;总开关关闭后结果页提供“保存本次”,已保存时可“本次不保存/删除本次记录”。解读/行动开关目前只保留偏好,不写入任何正文。
|
||||||
- 实现按时间倒序的问卦簿、派生“最近一次”、空态、详情、解释版本、单条删除和清空全部确认。
|
- [ ] 实现按时间倒序的问卦簿、空态、详情、解释版本、单条删除和清空全部确认。首页仅展示条数与最近一次派生摘要,不进入列表。
|
||||||
- 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
|
- [x] 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
|
||||||
- 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件。
|
- [x] 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件;`verifyLocalDataBoundary` 锁定 `allowBackup=false` 且无 INTERNET 权限。
|
||||||
- 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试。
|
- [ ] 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试:schema 1 无需迁移;编解码、Room 仪器测试和首页设置仪器测试已有,策略组合与级联删除未覆盖。
|
||||||
|
|
||||||
退出条件:
|
退出条件:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 产品规格
|
# 产品规格
|
||||||
|
|
||||||
> 状态:MVP 方案基线
|
> 状态:MVP 方案基线
|
||||||
> 产品代号:Brainwave(正式名称 TBD)
|
> 产品代号:Brainwave(正式名称:灵机)
|
||||||
> 原始输入:[原始需求.txt](原始需求.txt)
|
> 原始输入:[原始需求.txt](原始需求.txt)
|
||||||
|
|
||||||
## 1. 产品定义
|
## 1. 产品定义
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
|
在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
|
||||||
|
|
||||||
本轮使用“一问”作为视觉工作名。它不构成正式命名决定;正式名称、应用图标和商店素材仍是 [TBD-001](decisions.md#未决问题)。
|
v0.3 截图仍保留当时的视觉工作名“一问”,不得当作当前产品文案。正式产品名已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认为“灵机”;应用图标与商店素材仍见 [TBD-001](decisions.md#未决问题),P3 固化正式页面前须刷新原型品牌文案。
|
||||||
|
|
||||||
## 2. 交付范围
|
## 2. 交付范围
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -1,6 +1,6 @@
|
|||||||
# 质量门禁与验证策略
|
# 质量门禁与验证策略
|
||||||
|
|
||||||
> 状态:JVM/harness 门禁已启用;Android lint、构建、UI 与设备门禁等待 Android application 壳
|
> 状态:Android 本地门禁已启用;设备门禁在授权真机存在时单独执行
|
||||||
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
|
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
|
||||||
|
|
||||||
## 1. 反馈循环
|
## 1. 反馈循环
|
||||||
@@ -21,26 +21,27 @@
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew.bat spotlessCheck --offline
|
.\gradlew.bat spotlessCheck --offline
|
||||||
.\gradlew.bat :app:test --offline
|
.\gradlew.bat :app:testDebugUnitTest --offline
|
||||||
.\gradlew.bat verifyContentContract --offline
|
.\gradlew.bat verifyContentContract --offline
|
||||||
.\gradlew.bat verifyLocal --offline
|
.\gradlew.bat verifyLocal
|
||||||
```
|
```
|
||||||
|
|
||||||
`verifyLocal` 当前聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、已解析 JVM 依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。
|
`verifyLocal` 聚合无依赖格式检查、领域穷举测试、内容 repository 测试、历史编解码测试、问题校验测试、Android lint、debug APK、已解析 Android 调试/单测/仪器测试依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan、原型 JavaScript 语法检查,以及本机备份排除/无 INTERNET 权限检查。CI 执行同一个聚合任务。首次解析 Android 依赖时不能强制 `--offline`;缓存完成后可用离线模式复核。
|
||||||
|
|
||||||
Android application 插件配置后,Windows 环境还必须提供:
|
授权真机存在时,Windows 环境还必须执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew.bat spotlessCheck
|
.\gradlew.bat spotlessCheck
|
||||||
.\gradlew.bat lintDebug
|
|
||||||
.\gradlew.bat testDebugUnitTest
|
|
||||||
.\gradlew.bat assembleDebug
|
|
||||||
.\gradlew.bat connectedDebugAndroidTest
|
.\gradlew.bat connectedDebugAndroidTest
|
||||||
```
|
```
|
||||||
|
|
||||||
若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,应与纯 JVM 快速门禁分开。
|
若本机存在两个不兼容的 ADB server,可在只保留一个 server 的前提下使用等价的可重复入口:
|
||||||
|
|
||||||
到那时必须把 Android lint 和 debug build 加入现有 `verifyLocal`,使代理仍不必猜测验证组合。在完成这一步之前,`verifyLocal` 通过只证明 JVM 与仓库门禁,不代表 APK 已构建或真机测试已通过。
|
```powershell
|
||||||
|
.\scripts\run-connected-tests.ps1 -AdbPath <能够稳定识别设备的 adb.exe>
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本重新构建主/测试 APK、检查授权状态、覆盖安装两者并直接调用同一个 `AndroidJUnitRunner`;失败、零测试或缺少 `OK` 结果都会使脚本失败。若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。设备测试继续与环境独立的 `verifyLocal` 分开;`verifyLocal` 通过证明 APK 已构建,但不等于真机测试已经通过。
|
||||||
|
|
||||||
## 3. 测试层次
|
## 3. 测试层次
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ Android application 插件配置后,Windows 环境还必须提供:
|
|||||||
| FR-C-007 | SavedState/重建测试 | 旋转、切后台、进程恢复 |
|
| FR-C-007 | SavedState/重建测试 | 旋转、切后台、进程恢复 |
|
||||||
| FR-R-001~003 | UI 语义与 screenshot 测试 | TalkBack、色觉与长文阅读 |
|
| FR-R-001~003 | UI 语义与 screenshot 测试 | TalkBack、色觉与长文阅读 |
|
||||||
| FR-R-004 | 网络失败测试 | 飞行模式 |
|
| FR-R-004 | 网络失败测试 | 飞行模式 |
|
||||||
| FR-R-005 | 内容 schema/授权清单检查 | 内容负责人签核 |
|
| FR-R-005 | 内容 schema/授权清单检查 | 自动校验已接入;内容负责人签核仍待完成 |
|
||||||
| FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 |
|
| FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 |
|
||||||
| FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 |
|
| FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 |
|
||||||
| FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 |
|
| FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
> 状态:MVP 设计基线
|
> 状态:MVP 设计基线
|
||||||
> 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置
|
> 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置
|
||||||
|
> 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果、原文/本地白话和保存设置;问卦簿列表与「解」仍未进入应用,不能把本文件尚未实现的页面当成已上线功能
|
||||||
|
|
||||||
## 1. 体验定位
|
## 1. 体验定位
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
@@ -1,9 +1,46 @@
|
|||||||
[versions]
|
[versions]
|
||||||
|
agp = "8.2.0"
|
||||||
kotlin = "1.9.20"
|
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"
|
junit = "4.13.2"
|
||||||
|
androidxTestExt = "1.1.5"
|
||||||
|
androidxTestRunner = "1.5.2"
|
||||||
|
|
||||||
[libraries]
|
[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" }
|
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]
|
[plugins]
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
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,85 @@
|
|||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { kingWenEntries, validateContentPackage } from "./content-contract.mjs";
|
||||||
|
import { repositoryRoot } from "./repository-files.mjs";
|
||||||
|
import { lingjiPlain } from "../content/plain/lingji-plain.mjs";
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
"乾", "坤", "屯", "蒙", "需", "讼", "师", "比",
|
||||||
|
"小畜", "履", "泰", "否", "同人", "大有", "谦", "豫",
|
||||||
|
"随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
|
||||||
|
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒",
|
||||||
|
"遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解",
|
||||||
|
"损", "益", "夬", "姤", "萃", "升", "困", "井",
|
||||||
|
"革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅",
|
||||||
|
"巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (lingjiPlain.length !== 64) {
|
||||||
|
throw new Error(`lingji plain table must contain 64 hexagrams, got ${lingjiPlain.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jingPath = path.join(repositoryRoot, "content", "raw", "zhouyi-wikisource-jing.json");
|
||||||
|
const jing = JSON.parse(await readFile(jingPath, "utf8"));
|
||||||
|
if (!Array.isArray(jing) || jing.length !== 64) {
|
||||||
|
throw new Error("Wikisource jing import must contain 64 hexagrams; run scripts/import-zhouyi-wikisource.mjs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const jingById = new Map(jing.map((item) => [item.kingWenNumber, item]));
|
||||||
|
const entries = kingWenEntries();
|
||||||
|
const originalSource = "wikisource-zhouyi-jing-zh-Hans";
|
||||||
|
const plainSource = "lingji-plain-zh-Hans-2026.1";
|
||||||
|
|
||||||
|
const contentPackage = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
contentVersion: "zh-Hans-2026.1",
|
||||||
|
specialUsageTexts: { qian: true, kun: true },
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: originalSource,
|
||||||
|
title: "维基文库《周易》易经",
|
||||||
|
edition: "仅卦辞、爻辞与乾用九、坤用六,不含十翼;由 zh.wikisource.org 导入并经 Wikimedia zh-hans 转换",
|
||||||
|
license: "公版(Wikimedia PD-old)",
|
||||||
|
url: "https://zh.wikisource.org/wiki/周易",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: plainSource,
|
||||||
|
title: "灵机本地白话",
|
||||||
|
edition: "2026.1 项目自撰草稿,待内容负责人审校后视为可再分发",
|
||||||
|
license: "项目自撰;审校完成前仅用于本机构建,不以现代译注冒充公版",
|
||||||
|
url: "https://creativecommons.org/licenses/by/4.0/deed.zh-Hans",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hexagrams: entries.map((entry) => {
|
||||||
|
const original = jingById.get(entry.kingWenNumber);
|
||||||
|
if (!original) {
|
||||||
|
throw new Error(`Missing jing text for King Wen ${entry.kingWenNumber}`);
|
||||||
|
}
|
||||||
|
const plain = lingjiPlain[entry.kingWenNumber - 1];
|
||||||
|
if (!Array.isArray(plain) || plain.length !== 7 || plain.some((text) => !String(text).trim())) {
|
||||||
|
throw new Error(`Plain text for King Wen ${entry.kingWenNumber} must contain 7 non-blank strings`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
name: names[entry.kingWenNumber - 1],
|
||||||
|
symbol: String.fromCodePoint(0x4DC0 + entry.kingWenNumber - 1),
|
||||||
|
judgmentOriginal: original.judgmentOriginal,
|
||||||
|
judgmentPlain: plain[0],
|
||||||
|
lineTextsBottomUp: original.lineTextsBottomUp,
|
||||||
|
linePlainBottomUp: plain.slice(1),
|
||||||
|
specialUsageText: original.specialUsageText ?? null,
|
||||||
|
sourceRefs: [originalSource, plainSource],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const errors = validateContentPackage(contentPackage);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Built content package failed validation:\n${errors.join("\n")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDirectory = path.join(repositoryRoot, "content", "packages");
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
const outputPath = path.join(outputDirectory, "hexagram-content.json");
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(contentPackage, null, 2)}\n`);
|
||||||
|
process.stdout.write(`wrote ${outputPath}\n`);
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { kingWenEntries } from "./content-contract.mjs";
|
||||||
|
import { repositoryRoot } from "./repository-files.mjs";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || "http://127.0.0.1:1080";
|
||||||
|
const limit = Number.parseInt(process.env.IMPORT_LIMIT ?? "64", 10);
|
||||||
|
const userAgent = "LingjiContentImport/1.0 (local Zhouyi pipeline; public-domain jingwen only)";
|
||||||
|
const outputDirectory = path.join(repositoryRoot, "content", "raw");
|
||||||
|
const outputPath = path.join(outputDirectory, "zhouyi-wikisource-jing.json");
|
||||||
|
|
||||||
|
const titles = [
|
||||||
|
"乾", "坤", "屯", "蒙", "需", "訟", "師", "比",
|
||||||
|
"小畜", "履", "泰", "否", "同人", "大有", "謙", "豫",
|
||||||
|
"隨", "蠱", "臨", "觀", "噬嗑", "賁", "剝", "復",
|
||||||
|
"无妄", "大畜", "頤", "大過", "坎", "離", "咸", "恆",
|
||||||
|
"遯", "大壯", "晉", "明夷", "家人", "睽", "蹇", "解",
|
||||||
|
"損", "益", "夬", "姤", "萃", "升", "困", "井",
|
||||||
|
"革", "鼎", "震", "艮", "漸", "歸妹", "豐", "旅",
|
||||||
|
"巽", "兌", "渙", "節", "中孚", "小過", "既濟", "未濟",
|
||||||
|
];
|
||||||
|
|
||||||
|
const linePattern = /^(初九|九二|九三|九四|九五|上九|初六|六二|六三|六四|六五|上六|用九|用六)[::,,](.*)$/u;
|
||||||
|
|
||||||
|
function stripMarkup(wikitext) {
|
||||||
|
return wikitext
|
||||||
|
.replace(/-\{([^}|]+)-\}/gu, "$1")
|
||||||
|
.replace(/\{\{[^}]*\}\}/gu, "")
|
||||||
|
.replace(/<[^>]+>/gu, "")
|
||||||
|
.replace(/'{2,}/gu, "")
|
||||||
|
.replace(/\[\[File:[^\]]*\]\]/gu, "")
|
||||||
|
.replace(/\[\[(?:[^\|\]]*\|)?([^\]]+)\]\]/gu, "$1")
|
||||||
|
.replace(/ /gu, " ")
|
||||||
|
.replace(/\r/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClassicFields(section) {
|
||||||
|
const rawLines = section
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/^[ *#:]+/u, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let judgment = "";
|
||||||
|
const lines = [];
|
||||||
|
let special = null;
|
||||||
|
for (const rawLine of rawLines) {
|
||||||
|
const compact = rawLine.replace(/\s+/gu, "");
|
||||||
|
const hit = linePattern.exec(compact) ?? linePattern.exec(rawLine);
|
||||||
|
if (hit) {
|
||||||
|
const label = hit[1];
|
||||||
|
const text = `${label}:${hit[2].trim()}`;
|
||||||
|
if (label === "用九" || label === "用六") {
|
||||||
|
special = text;
|
||||||
|
} else {
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
} else if (lines.length === 0 && special == null) {
|
||||||
|
const fragment = rawLine.replace(/\s+/gu, "");
|
||||||
|
judgment = judgment ? `${judgment}${fragment}` : fragment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
judgment = judgment
|
||||||
|
.replace(/^(?:周易)?[\u4e00-\u9fff]{1,3}[::]/u, "")
|
||||||
|
.trim();
|
||||||
|
if (!judgment) throw new Error("missing judgment");
|
||||||
|
if (lines.length !== 6) {
|
||||||
|
throw new Error(`expected 6 lines, got ${lines.length}: ${lines.join(" | ")}`);
|
||||||
|
}
|
||||||
|
return { judgmentOriginal: judgment, lineTextsBottomUp: lines, specialUsageText: special };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClassicSection(wikitext) {
|
||||||
|
const normalized = stripMarkup(wikitext).replace(/\u3000/gu, " ");
|
||||||
|
const classicIndex = normalized.search(/易[經经][::]/u);
|
||||||
|
if (classicIndex < 0) throw new Error("missing 易經 section");
|
||||||
|
const afterClassic = normalized.slice(classicIndex);
|
||||||
|
const stop = afterClassic.search(/\n[ *#]*彖曰[::]/u);
|
||||||
|
return (stop >= 0 ? afterClassic.slice(0, stop) : afterClassic)
|
||||||
|
.replace(/^易[經经][::]\s*/u, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(value) {
|
||||||
|
return value
|
||||||
|
.replace(/<[^>]+>/gu, "")
|
||||||
|
.replace(/"/gu, "\"")
|
||||||
|
.replace(/&/gu, "&")
|
||||||
|
.replace(/</gu, "<")
|
||||||
|
.replace(/>/gu, ">")
|
||||||
|
.replace(/\n+/gu, "\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function curlJson(args) {
|
||||||
|
const { stdout } = await execFileAsync("curl.exe", [
|
||||||
|
"-sS",
|
||||||
|
"--fail",
|
||||||
|
"--retry",
|
||||||
|
"12",
|
||||||
|
"--retry-delay",
|
||||||
|
"20",
|
||||||
|
"--retry-all-errors",
|
||||||
|
"--max-time",
|
||||||
|
"45",
|
||||||
|
"-x",
|
||||||
|
proxyUrl,
|
||||||
|
"-H",
|
||||||
|
`User-Agent: ${userAgent}`,
|
||||||
|
"-H",
|
||||||
|
"Accept: application/json",
|
||||||
|
...args,
|
||||||
|
], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleFallbacks = {
|
||||||
|
恆: ["恒"],
|
||||||
|
遯: ["遁"],
|
||||||
|
晉: ["晋"],
|
||||||
|
大壯: ["大壮"],
|
||||||
|
歸妹: ["归妹"],
|
||||||
|
豐: ["丰"],
|
||||||
|
兌: ["兑"],
|
||||||
|
渙: ["涣"],
|
||||||
|
節: ["节"],
|
||||||
|
既濟: ["既济"],
|
||||||
|
未濟: ["未济"],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchWikitext(title) {
|
||||||
|
const candidates = [title, ...(titleFallbacks[title] ?? [])];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const url = new URL("https://zh.wikisource.org/w/api.php");
|
||||||
|
url.searchParams.set("action", "parse");
|
||||||
|
url.searchParams.set("page", `周易/${candidate}`);
|
||||||
|
url.searchParams.set("prop", "wikitext");
|
||||||
|
url.searchParams.set("format", "json");
|
||||||
|
const payload = await curlJson([url.toString()]);
|
||||||
|
const wikitext = payload?.parse?.wikitext?.["*"];
|
||||||
|
if (typeof wikitext === "string") return wikitext;
|
||||||
|
process.stdout.write(`missing ${candidate}, trying fallback\n`);
|
||||||
|
}
|
||||||
|
throw new Error(`no wikitext for ${title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toHansFields(classic) {
|
||||||
|
const pieces = [
|
||||||
|
classic.judgmentOriginal,
|
||||||
|
...classic.lineTextsBottomUp,
|
||||||
|
...(classic.specialUsageText == null ? [] : [classic.specialUsageText]),
|
||||||
|
];
|
||||||
|
const converted = await convertToHans(pieces.join("\n¶\n"));
|
||||||
|
const parts = converted
|
||||||
|
.split("¶")
|
||||||
|
.map((part) => part.replace(/\s+/gu, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length !== pieces.length) {
|
||||||
|
throw new Error(`zh-hans field count ${parts.length} != ${pieces.length}: ${converted}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
judgmentOriginal: parts[0],
|
||||||
|
lineTextsBottomUp: parts.slice(1, 7),
|
||||||
|
specialUsageText: classic.specialUsageText == null ? null : parts[7],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function convertToHans(text) {
|
||||||
|
const payload = await curlJson([
|
||||||
|
"--data-urlencode", "action=parse",
|
||||||
|
"--data-urlencode", `text=${text}`,
|
||||||
|
"--data-urlencode", "prop=text",
|
||||||
|
"--data-urlencode", "variant=zh-hans",
|
||||||
|
"--data-urlencode", "disablelimitreport=1",
|
||||||
|
"--data-urlencode", "wrapoutputclass=",
|
||||||
|
"--data-urlencode", "contentmodel=wikitext",
|
||||||
|
"--data-urlencode", "format=json",
|
||||||
|
"https://zh.wikisource.org/w/api.php",
|
||||||
|
]);
|
||||||
|
const html = payload?.parse?.text?.["*"];
|
||||||
|
if (typeof html !== "string") throw new Error("zh-hans conversion returned no text");
|
||||||
|
return stripHtml(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = kingWenEntries();
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
let imported = [];
|
||||||
|
try {
|
||||||
|
imported = JSON.parse(await readFile(outputPath, "utf8"));
|
||||||
|
if (!Array.isArray(imported)) imported = [];
|
||||||
|
} catch {
|
||||||
|
imported = [];
|
||||||
|
}
|
||||||
|
const done = new Set(imported.map((item) => item.kingWenNumber));
|
||||||
|
|
||||||
|
for (const [index, title] of titles.entries()) {
|
||||||
|
if (index >= limit) break;
|
||||||
|
const kingWenNumber = index + 1;
|
||||||
|
if (done.has(kingWenNumber)) {
|
||||||
|
process.stdout.write(`skip ${kingWenNumber}/64 ${title}\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wikitext = await fetchWikitext(title);
|
||||||
|
const traditional = extractClassicSection(wikitext);
|
||||||
|
const classic = await toHansFields(parseClassicFields(traditional));
|
||||||
|
imported.push({
|
||||||
|
kingWenNumber,
|
||||||
|
wikisourceTitle: title,
|
||||||
|
sourcePage: `https://zh.wikisource.org/wiki/周易/${title}`,
|
||||||
|
...entries[index],
|
||||||
|
...classic,
|
||||||
|
});
|
||||||
|
imported.sort((left, right) => left.kingWenNumber - right.kingWenNumber);
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(imported, null, 2)}\n`);
|
||||||
|
process.stdout.write(`imported ${kingWenNumber}/64 ${title}\n`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`wrote ${outputPath} (${imported.length} hexagrams)\n`);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$AdbPath = (Join-Path $env:ANDROID_HOME "platform-tools\adb.exe")
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$gradleWrapper = Join-Path $repositoryRoot "gradlew.bat"
|
||||||
|
$mainApk = Join-Path $repositoryRoot "app\build\outputs\apk\debug\app-debug.apk"
|
||||||
|
$testApk = Join-Path $repositoryRoot "app\build\outputs\apk\androidTest\debug\app-debug-androidTest.apk"
|
||||||
|
$instrumentation = "net.opcapp.flash.test/androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $AdbPath -PathType Leaf)) {
|
||||||
|
throw "ADB executable not found: $AdbPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $gradleWrapper :app:assembleDebug :app:assembleDebugAndroidTest --no-daemon --console=plain
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Android app or test APK build failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceState = (& $AdbPath get-state 2>$null)
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $deviceState.Trim() -ne "device") {
|
||||||
|
throw "No authorized Android device is available through: $AdbPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $AdbPath install -r $mainApk
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Main APK installation failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
& $AdbPath install -r $testApk
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Test APK installation failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
$instrumentationOutput = & $AdbPath shell am instrument -w -r $instrumentation 2>&1
|
||||||
|
$instrumentationExitCode = $LASTEXITCODE
|
||||||
|
$instrumentationOutput | Write-Output
|
||||||
|
$joinedOutput = $instrumentationOutput -join "`n"
|
||||||
|
|
||||||
|
if (
|
||||||
|
$instrumentationExitCode -ne 0 -or
|
||||||
|
$joinedOutput -match "FAILURES!!!" -or
|
||||||
|
$joinedOutput -notmatch "OK \(\d+ tests?\)"
|
||||||
|
) {
|
||||||
|
throw "Connected Android instrumentation tests failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Connected Android instrumentation tests passed."
|
||||||
@@ -19,7 +19,9 @@ const kotlinCatalogPath = path.join(
|
|||||||
"src",
|
"src",
|
||||||
"main",
|
"main",
|
||||||
"java",
|
"java",
|
||||||
"brainwave",
|
"net",
|
||||||
|
"opcapp",
|
||||||
|
"flash",
|
||||||
"domain",
|
"domain",
|
||||||
"casting",
|
"casting",
|
||||||
"HexagramCatalog.kt",
|
"HexagramCatalog.kt",
|
||||||
@@ -102,3 +104,52 @@ for (const [name, mutate, expected] of negativeCases) {
|
|||||||
console.log(
|
console.log(
|
||||||
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`,
|
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const packagePath = path.join(repositoryRoot, "content", "packages", "hexagram-content.json");
|
||||||
|
const namesPath = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"app",
|
||||||
|
"src",
|
||||||
|
"main",
|
||||||
|
"java",
|
||||||
|
"net",
|
||||||
|
"opcapp",
|
||||||
|
"flash",
|
||||||
|
"core",
|
||||||
|
"model",
|
||||||
|
"HexagramNames.kt",
|
||||||
|
);
|
||||||
|
const namesSource = await readFile(namesPath, "utf8");
|
||||||
|
const namesBlock = namesSource.match(/private val names = listOf\(([\s\S]*?)\)/u);
|
||||||
|
if (!namesBlock) {
|
||||||
|
throw new Error("HexagramNames.kt name table could not be parsed");
|
||||||
|
}
|
||||||
|
const catalogNames = [...namesBlock[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]);
|
||||||
|
if (catalogNames.length !== 64) {
|
||||||
|
throw new Error("HexagramNames.kt must contain 64 names");
|
||||||
|
}
|
||||||
|
|
||||||
|
const published = JSON.parse(await readFile(packagePath, "utf8"));
|
||||||
|
const publishedErrors = validateContentPackage(published);
|
||||||
|
if (publishedErrors.length > 0) {
|
||||||
|
throw new Error(`Published content package failed validation:\n${publishedErrors.join("\n")}`);
|
||||||
|
}
|
||||||
|
published.hexagrams.forEach((hexagram) => {
|
||||||
|
const expectedName = catalogNames[hexagram.kingWenNumber - 1];
|
||||||
|
const expectedSymbol = String.fromCodePoint(0x4DC0 + hexagram.kingWenNumber - 1);
|
||||||
|
if (hexagram.name !== expectedName) {
|
||||||
|
throw new Error(`Published package name for ${hexagram.kingWenNumber} must be ${expectedName}`);
|
||||||
|
}
|
||||||
|
if (hexagram.symbol !== expectedSymbol) {
|
||||||
|
throw new Error(`Published package symbol for ${hexagram.kingWenNumber} must be ${expectedSymbol}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (published.contentVersion !== "zh-Hans-2026.1") {
|
||||||
|
throw new Error("Published contentVersion must be zh-Hans-2026.1");
|
||||||
|
}
|
||||||
|
if (!published.specialUsageTexts.qian || !published.specialUsageTexts.kun) {
|
||||||
|
throw new Error("Published package must include Qian 用九 and Kun 用六");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Published content package ${published.contentVersion} passed (digest ${contentDigest(published).slice(0, 12)}…).`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -17,19 +17,42 @@ if (manifest.schemaVersion !== 1) failures.push("manifest schemaVersion must be
|
|||||||
if (!Array.isArray(manifest.components) || manifest.components.length === 0) {
|
if (!Array.isArray(manifest.components) || manifest.components.length === 0) {
|
||||||
failures.push("manifest components must not be empty");
|
failures.push("manifest components must not be empty");
|
||||||
}
|
}
|
||||||
|
if (!Array.isArray(manifest.groupPolicies) || manifest.groupPolicies.length === 0) {
|
||||||
|
failures.push("manifest groupPolicies must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
const allowedLicenses = new Set(["Apache-2.0", "BSD-3-Clause", "EPL-1.0"]);
|
const allowedLicenses = new Set(["Apache-2.0", "BSD-3-Clause", "EPL-1.0"]);
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
const coordinates = new Set();
|
const coordinates = new Set();
|
||||||
for (const [index, component] of (manifest.components ?? []).entries()) {
|
const validateCommonFields = (entry, prefix) => {
|
||||||
const prefix = `components[${index}]`;
|
for (const field of ["id", "scope", "license", "source"]) {
|
||||||
for (const field of ["id", "version", "scope", "license", "source"]) {
|
if (typeof entry[field] !== "string" || entry[field].trim() === "") {
|
||||||
if (typeof component[field] !== "string" || component[field].trim() === "") {
|
|
||||||
failures.push(`${prefix}.${field} must be non-blank text`);
|
failures.push(`${prefix}.${field} must be non-blank text`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ids.has(component.id)) failures.push(`${prefix}.id must be unique`);
|
if (ids.has(entry.id)) failures.push(`${prefix}.id must be unique`);
|
||||||
ids.add(component.id);
|
ids.add(entry.id);
|
||||||
|
if (!allowedLicenses.has(entry.license)) failures.push(`${prefix}.license is not reviewed`);
|
||||||
|
try {
|
||||||
|
const url = new URL(entry.source);
|
||||||
|
if (url.protocol !== "https:") throw new Error("not HTTPS");
|
||||||
|
} catch {
|
||||||
|
failures.push(`${prefix}.source must be an absolute HTTPS URL`);
|
||||||
|
}
|
||||||
|
if (!documentation.includes(entry.id) || !documentation.includes(entry.license)) {
|
||||||
|
failures.push(`${prefix} is not represented in docs/dependency-licenses.md`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [index, component] of (manifest.components ?? []).entries()) {
|
||||||
|
const prefix = `components[${index}]`;
|
||||||
|
validateCommonFields(component, prefix);
|
||||||
|
if (typeof component.version !== "string" || component.version.trim() === "") {
|
||||||
|
failures.push(`${prefix}.version must be non-blank text`);
|
||||||
|
}
|
||||||
|
if (!documentation.includes(component.version)) {
|
||||||
|
failures.push(`${prefix}.version is not represented in docs/dependency-licenses.md`);
|
||||||
|
}
|
||||||
if (component.coordinates !== null) {
|
if (component.coordinates !== null) {
|
||||||
if (typeof component.coordinates !== "string" || component.coordinates.split(":").length !== 3) {
|
if (typeof component.coordinates !== "string" || component.coordinates.split(":").length !== 3) {
|
||||||
failures.push(`${prefix}.coordinates must be group:name:version or null`);
|
failures.push(`${prefix}.coordinates must be group:name:version or null`);
|
||||||
@@ -38,24 +61,31 @@ for (const [index, component] of (manifest.components ?? []).entries()) {
|
|||||||
}
|
}
|
||||||
coordinates.add(component.coordinates);
|
coordinates.add(component.coordinates);
|
||||||
}
|
}
|
||||||
if (!allowedLicenses.has(component.license)) failures.push(`${prefix}.license is not reviewed`);
|
|
||||||
try {
|
|
||||||
const url = new URL(component.source);
|
|
||||||
if (url.protocol !== "https:") throw new Error("not HTTPS");
|
|
||||||
} catch {
|
|
||||||
failures.push(`${prefix}.source must be an absolute HTTPS URL`);
|
|
||||||
}
|
}
|
||||||
if (!documentation.includes(component.id) ||
|
|
||||||
!documentation.includes(component.version) ||
|
const groupPrefixes = new Set();
|
||||||
!documentation.includes(component.license)) {
|
for (const [index, policy] of (manifest.groupPolicies ?? []).entries()) {
|
||||||
failures.push(`${prefix} is not represented in docs/dependency-licenses.md`);
|
const prefix = `groupPolicies[${index}]`;
|
||||||
|
validateCommonFields(policy, prefix);
|
||||||
|
if (typeof policy.groupPrefix !== "string" || !/^[a-z][a-z0-9.]+$/u.test(policy.groupPrefix)) {
|
||||||
|
failures.push(`${prefix}.groupPrefix must be a Maven group prefix`);
|
||||||
|
} else if (groupPrefixes.has(policy.groupPrefix)) {
|
||||||
|
failures.push(`${prefix}.groupPrefix must be unique`);
|
||||||
}
|
}
|
||||||
|
groupPrefixes.add(policy.groupPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = (name) => new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "mu").exec(catalog)?.[1];
|
const version = (name) => new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "mu").exec(catalog)?.[1];
|
||||||
const expectedVersions = new Map([
|
const expectedVersions = new Map([
|
||||||
["gradle-wrapper", /gradle-([\d.]+)-bin\.zip/u.exec(wrapper)?.[1]],
|
["gradle-wrapper", /gradle-([\d.]+)-bin\.zip/u.exec(wrapper)?.[1]],
|
||||||
|
["android-gradle-plugin", version("agp")],
|
||||||
["kotlin-gradle-plugin", version("kotlin")],
|
["kotlin-gradle-plugin", version("kotlin")],
|
||||||
|
["hilt-gradle-plugin", version("hilt")],
|
||||||
|
["compose-bom", version("composeBom")],
|
||||||
|
["navigation-compose", version("navigation")],
|
||||||
|
["room-runtime", version("room")],
|
||||||
|
["datastore-preferences", version("dataStore")],
|
||||||
|
["hilt-android", version("hilt")],
|
||||||
["kotlin-stdlib", version("kotlin")],
|
["kotlin-stdlib", version("kotlin")],
|
||||||
["junit4", version("junit")],
|
["junit4", version("junit")],
|
||||||
]);
|
]);
|
||||||
@@ -71,5 +101,8 @@ if (failures.length > 0) {
|
|||||||
for (const failure of failures) console.error(`- ${failure}`);
|
for (const failure of failures) console.error(`- ${failure}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} else {
|
} else {
|
||||||
console.log(`Dependency-license manifest passed (${manifest.components.length} reviewed components).`);
|
console.log(
|
||||||
|
`Dependency-license manifest passed (${manifest.components.length} components, ` +
|
||||||
|
`${manifest.groupPolicies.length} group policies).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ import {
|
|||||||
walkFiles,
|
walkFiles,
|
||||||
} from "./repository-files.mjs";
|
} from "./repository-files.mjs";
|
||||||
|
|
||||||
const domainRoot = path.join(repositoryRoot, "app", "src", "main", "java", "brainwave", "domain");
|
const domainRoot = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"app",
|
||||||
|
"src",
|
||||||
|
"main",
|
||||||
|
"java",
|
||||||
|
"net",
|
||||||
|
"opcapp",
|
||||||
|
"flash",
|
||||||
|
"domain",
|
||||||
|
);
|
||||||
const forbiddenImports = [
|
const forbiddenImports = [
|
||||||
/^android\./u,
|
/^android\./u,
|
||||||
/^androidx\./u,
|
/^androidx\./u,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const checkedExtensions = new Set([
|
|||||||
".md",
|
".md",
|
||||||
".mjs",
|
".mjs",
|
||||||
".properties",
|
".properties",
|
||||||
|
".ps1",
|
||||||
".toml",
|
".toml",
|
||||||
".xml",
|
".xml",
|
||||||
".yaml",
|
".yaml",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { repositoryRoot } from "./repository-files.mjs";
|
||||||
|
|
||||||
|
const manifestPath = path.join(repositoryRoot, "app", "src", "main", "AndroidManifest.xml");
|
||||||
|
const backupRulesPath = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"app",
|
||||||
|
"src",
|
||||||
|
"main",
|
||||||
|
"res",
|
||||||
|
"xml",
|
||||||
|
"backup_rules.xml",
|
||||||
|
);
|
||||||
|
const extractionRulesPath = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"app",
|
||||||
|
"src",
|
||||||
|
"main",
|
||||||
|
"res",
|
||||||
|
"xml",
|
||||||
|
"data_extraction_rules.xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [manifest, backupRules, extractionRules] = await Promise.all([
|
||||||
|
readFile(manifestPath, "utf8"),
|
||||||
|
readFile(backupRulesPath, "utf8"),
|
||||||
|
readFile(extractionRulesPath, "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
if (!manifest.includes('android:allowBackup="false"')) {
|
||||||
|
failures.push("Android backup must remain disabled");
|
||||||
|
}
|
||||||
|
if (!manifest.includes('android:usesCleartextTraffic="false"')) {
|
||||||
|
failures.push("cleartext traffic must remain disabled");
|
||||||
|
}
|
||||||
|
if (/android\.permission\.INTERNET/u.test(manifest)) {
|
||||||
|
failures.push("the offline application must not request INTERNET permission");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const domain of ["root", "file", "database", "sharedpref", "external"]) {
|
||||||
|
const exclusion = `<exclude domain="${domain}" path="." />`;
|
||||||
|
if (!backupRules.includes(exclusion)) {
|
||||||
|
failures.push(`legacy backup rules do not exclude ${domain}`);
|
||||||
|
}
|
||||||
|
const extractionOccurrences = extractionRules.split(exclusion).length - 1;
|
||||||
|
if (extractionOccurrences !== 2) {
|
||||||
|
failures.push(
|
||||||
|
`data extraction rules must exclude ${domain} from cloud backup and device transfer`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error("Local-data boundary verification failed:");
|
||||||
|
for (const failure of failures) console.error(`- ${failure}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("Local-data boundary verification passed.");
|
||||||
|
}
|
||||||
+1
-1
@@ -14,6 +14,6 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repository code name only. This is not the formal product name (TBD-001).
|
// The repository keeps its original code name; the Android product identity is defined in :app.
|
||||||
rootProject.name = "brainwave"
|
rootProject.name = "brainwave"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
Reference in New Issue
Block a user