feat: add local hexagram content for offline reading
Load a versioned Wikisource jing plus project-authored plain drafts so results can show labeled original and vernacular texts without unauthorized modern translations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -57,6 +57,11 @@ android {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -76,14 +76,33 @@ class MainActivityTest {
|
||||
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.onNodeWithTag("auto_save_history").assertIsOff()
|
||||
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.onNodeWithTag("auto_save_history").assertIsOn()
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
runCatching {
|
||||
composeRule.onNodeWithTag("auto_save_history").assertIsOn()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -27,6 +28,7 @@ private object Destination {
|
||||
const val Casting = "casting"
|
||||
const val Result = "result"
|
||||
const val Settings = "settings"
|
||||
const val Sources = "sources"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -78,6 +80,7 @@ fun LingjiApp(
|
||||
},
|
||||
onOpenSettings = { navController.navigate(Destination.Settings) },
|
||||
onOpenMethodIntro = { navController.navigate(Destination.Welcome) },
|
||||
onOpenContentSources = { navController.navigate(Destination.Sources) },
|
||||
)
|
||||
}
|
||||
composable(Destination.Question) {
|
||||
@@ -127,5 +130,11 @@ fun LingjiApp(
|
||||
onBack = navController::popBackStack,
|
||||
)
|
||||
}
|
||||
composable(Destination.Sources) {
|
||||
ContentSourcesScreen(
|
||||
manifest = homeState.contentManifest,
|
||||
onBack = navController::popBackStack,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.opcapp.flash.core.model
|
||||
|
||||
data class ContentSource(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val edition: String,
|
||||
val license: String,
|
||||
val url: String,
|
||||
) {
|
||||
init {
|
||||
require(id.isNotBlank()) { "Source id must not be blank" }
|
||||
require(title.isNotBlank()) { "Source title must not be blank" }
|
||||
require(edition.isNotBlank()) { "Source edition must not be blank" }
|
||||
require(license.isNotBlank()) { "Source license must not be blank" }
|
||||
require(url.isNotBlank()) { "Source url must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
data class ContentManifest(
|
||||
val contentVersion: String,
|
||||
val digest: String,
|
||||
val sources: List<ContentSource>,
|
||||
val loadError: String? = null,
|
||||
) {
|
||||
init {
|
||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||
require(loadError == null || loadError.isNotBlank()) {
|
||||
"Content load error must be non-blank or null"
|
||||
}
|
||||
require(sources.map(ContentSource::id).distinct().size == sources.size) {
|
||||
"Content sources must have unique ids"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package net.opcapp.flash.data.content
|
||||
|
||||
import net.opcapp.flash.core.model.ContentManifest
|
||||
import net.opcapp.flash.core.model.HexagramContent
|
||||
|
||||
interface HexagramContentRepository {
|
||||
val contentVersion: String
|
||||
val manifest: ContentManifest
|
||||
|
||||
fun contentFor(
|
||||
kingWenNumber: Int,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,6 +14,9 @@ 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
|
||||
@@ -22,6 +25,7 @@ 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
|
||||
@@ -52,6 +56,10 @@ data class CastingUiState(
|
||||
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)
|
||||
@@ -67,12 +75,14 @@ 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(
|
||||
@@ -86,6 +96,10 @@ class CastingViewModel @Inject constructor(
|
||||
} else {
|
||||
HistorySaveStatus.Saving
|
||||
},
|
||||
primaryContent = restoredContent.primary,
|
||||
transformedContent = restoredContent.transformed,
|
||||
contentError = restoredContent.error,
|
||||
showSpecialUsage = restoredContent.showSpecialUsage,
|
||||
),
|
||||
)
|
||||
val uiState: StateFlow<CastingUiState> = _uiState.asStateFlow()
|
||||
@@ -138,6 +152,7 @@ class CastingViewModel @Inject constructor(
|
||||
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(
|
||||
@@ -146,6 +161,10 @@ class CastingViewModel @Inject constructor(
|
||||
result = result,
|
||||
sessionId = sessionId,
|
||||
saveStatus = HistorySaveStatus.Saving,
|
||||
primaryContent = content.primary,
|
||||
transformedContent = content.transformed,
|
||||
contentError = content.error,
|
||||
showSpecialUsage = content.showSpecialUsage,
|
||||
)
|
||||
persistResult(autoOnly = true)
|
||||
return true
|
||||
@@ -288,11 +307,51 @@ class CastingViewModel @Inject constructor(
|
||||
CastResult.record(
|
||||
computation = CastEngine.cast(rounds),
|
||||
metadata = CastMetadata(
|
||||
contentVersion = CONTENT_VERSION,
|
||||
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)
|
||||
|
||||
@@ -311,7 +370,4 @@ class CastingViewModel @Inject constructor(
|
||||
const val SessionId = "casting_session_id"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CONTENT_VERSION = "hexagram-names-v1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ fun HomeScreen(
|
||||
onStartCasting: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenMethodIntro: () -> Unit,
|
||||
onOpenContentSources: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
@@ -134,6 +135,12 @@ fun HomeScreen(
|
||||
) {
|
||||
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))
|
||||
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
@@ -20,13 +22,20 @@ data class HomeUiState(
|
||||
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(),
|
||||
@@ -39,6 +48,7 @@ class HomeViewModel @Inject constructor(
|
||||
savePolicy = savePolicy,
|
||||
hasSeenMethodIntro = hasSeenMethodIntro,
|
||||
isReady = true,
|
||||
contentManifest = contentManifest,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
|
||||
@@ -45,6 +45,7 @@ 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
|
||||
@@ -94,6 +95,10 @@ fun CastResultScreen(
|
||||
ResultContent(
|
||||
result = result,
|
||||
saveStatus = uiState.saveStatus,
|
||||
primaryContent = uiState.primaryContent,
|
||||
transformedContent = uiState.transformedContent,
|
||||
contentError = uiState.contentError,
|
||||
showSpecialUsage = uiState.showSpecialUsage,
|
||||
onSave = onSave,
|
||||
onDelete = onDelete,
|
||||
onRetry = onRetry,
|
||||
@@ -110,6 +115,10 @@ fun CastResultScreen(
|
||||
private fun ResultContent(
|
||||
result: CastResult,
|
||||
saveStatus: HistorySaveStatus,
|
||||
primaryContent: HexagramContent?,
|
||||
transformedContent: HexagramContent?,
|
||||
contentError: String?,
|
||||
showSpecialUsage: Boolean,
|
||||
onSave: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -175,6 +184,13 @@ private fun ResultContent(
|
||||
}
|
||||
}
|
||||
RawLinesCard(result.lineValuesBottomUp)
|
||||
HexagramTextsCard(
|
||||
result = result,
|
||||
primaryContent = primaryContent,
|
||||
transformedContent = transformedContent,
|
||||
contentError = contentError,
|
||||
showSpecialUsage = showSpecialUsage,
|
||||
)
|
||||
SaveStatusCard(
|
||||
status = saveStatus,
|
||||
onSave = onSave,
|
||||
@@ -182,7 +198,7 @@ private fun ResultContent(
|
||||
onRetry = onRetry,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.content_package_pending),
|
||||
text = stringResource(R.string.content_plain_draft_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -197,6 +213,104 @@ private fun ResultContent(
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
|
||||
@@ -103,7 +103,23 @@
|
||||
<string name="save_this_time">保存本次</string>
|
||||
<string name="do_not_save_this_time">本次不保存</string>
|
||||
<string name="retry">重试</string>
|
||||
<string name="content_package_pending">当前显示可复核的卦号、卦名和六爻事实。经典原文与现代白话将在来源和授权审核完成后接入。</string>
|
||||
<string name="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>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package net.opcapp.flash.data.content
|
||||
|
||||
import net.opcapp.flash.core.model.ContentManifest
|
||||
import net.opcapp.flash.core.model.ContentSource
|
||||
import net.opcapp.flash.core.model.HexagramContent
|
||||
|
||||
class FakeHexagramContentRepository(
|
||||
override val contentVersion: String = "fixture-only-v1",
|
||||
entries: List<HexagramContent>,
|
||||
sources: List<ContentSource> = emptyList(),
|
||||
digest: String = "fixture-digest",
|
||||
) : HexagramContentRepository {
|
||||
private val entriesById: Map<Int, HexagramContent>
|
||||
|
||||
override val manifest: ContentManifest = ContentManifest(
|
||||
contentVersion = contentVersion,
|
||||
digest = digest,
|
||||
sources = sources,
|
||||
)
|
||||
|
||||
init {
|
||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user