fix android trivia experience
No files matched your search
@@ -6,4 +6,4 @@ local.properties
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
.DS_Store
|
||||
@@ -13,6 +13,7 @@
|
||||
## Coding Guidelines
|
||||
|
||||
- Keep code simple and easy to read.
|
||||
- Never use em dashes in app user-facing copy.
|
||||
- Avoid excessive comments. We want code itself to be super readable. Only comment when absolutely necessary.
|
||||
- Do not create or modify implementation code unless the user explicitly asks for code changes.
|
||||
- Do not create or modify tests unless the user explicitly asks for tests.
|
||||
|
||||
@@ -4,7 +4,7 @@ Bert's Atlas is an offline geography trivia game for Android and iOS. Product be
|
||||
|
||||
## Architecture
|
||||
|
||||
- `domain`: pure Kotlin Multiplatform quiz, timing, scoring, mastery, streak, and selection rules
|
||||
- `domain`: pure Kotlin Multiplatform trivia, timing, scoring, mastery, streak, and selection rules
|
||||
- `composeApp`: shared Compose UI, bundled resources, Room persistence, and platform adapters
|
||||
- `catalogTool`: non-test JVM catalog validator and editorial report generator
|
||||
- `androidApp`: thin Android application host
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Bert's Atlas"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.bertsatlas
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import app.bertsatlas.data.buildBertsAtlasDatabase
|
||||
import app.bertsatlas.data.createPreferencesStore
|
||||
@@ -9,9 +10,18 @@ import app.bertsatlas.data.getDatabaseBuilder
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var controller: AppController? = null
|
||||
private val appBackCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (controller?.handleBack() == true) return
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
onBackPressedDispatcher.addCallback(this, appBackCallback)
|
||||
val database = getDatabaseBuilder(this).buildBertsAtlasDatabase()
|
||||
val preferences = createPreferencesStore(filesDir.resolve("berts-atlas.preferences_pb").absolutePath)
|
||||
val feedback = AndroidDeviceFeedback(this)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="512" android:viewportHeight="512">
|
||||
<group android:scaleX="0.66" android:scaleY="0.66" android:translateX="55" android:translateY="85">
|
||||
<path android:fillColor="#F7BFBE" android:strokeColor="#27635B" android:strokeWidth="17" android:pathData="M105,270C47,237 68,151 135,146C138,74 231,54 272,107C327,62 414,105 402,177C465,207 444,297 387,312C369,397 264,415 214,363C152,395 79,335 105,270Z"/>
|
||||
<path android:fillColor="#F6E9E1" android:strokeColor="#27635B" android:strokeWidth="19" android:pathData="M154,205H217C232,205 245,217 245,233V269C245,285 232,297 217,297H154C138,297 126,285 126,269V233C126,217 138,205 154,205Z"/>
|
||||
<path android:fillColor="#F6E9E1" android:strokeColor="#27635B" android:strokeWidth="19" android:pathData="M296,205H359C375,205 387,217 387,233V269C387,285 375,297 359,297H296C280,297 268,285 268,269V233C268,217 280,205 296,205Z"/>
|
||||
<path android:strokeColor="#27635B" android:strokeWidth="19" android:pathData="M245,245L268,245"/>
|
||||
<path android:fillColor="#27635B" android:pathData="M185,250m-14,0a14,14 0,1 0,28 0a14,14 0,1 0,-28 0M328,250m-14,0a14,14 0,1 0,28 0a14,14 0,1 0,-28 0"/>
|
||||
<path android:strokeColor="#27635B" android:strokeWidth="15" android:strokeLineCap="round" android:pathData="M204,325Q256,372 309,325"/>
|
||||
</group>
|
||||
<group android:scaleX="0.82" android:scaleY="0.82" android:translateX="64" android:translateY="41">
|
||||
<path android:fillColor="#F1DCC7" android:strokeColor="#27635B" android:strokeWidth="10" android:pathData="M300,105L332,91L360,104L390,91L418,105L418,196L390,182L360,195L332,182L300,196Z"/>
|
||||
<path android:fillColor="#B7CFC8" android:strokeColor="#27635B" android:strokeWidth="10" android:pathData="M332,91L360,104L360,195L332,182Z"/>
|
||||
<path android:fillColor="#E9AF8B" android:strokeColor="#27635B" android:strokeWidth="10" android:pathData="M390,91L418,105L418,196L390,182Z"/>
|
||||
<path android:strokeColor="#E1814E" android:strokeWidth="7" android:strokeLineCap="round" android:pathData="M308,157C328,139 336,164 354,147C370,133 375,118 392,135C402,147 406,153 414,150"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/parchment" />
|
||||
<item android:drawable="@drawable/app_icon_foreground" android:gravity="center" android:width="192dp" android:height="192dp" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/parchment" />
|
||||
<foreground android:drawable="@drawable/app_icon_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/parchment" />
|
||||
<foreground android:drawable="@drawable/app_icon_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">@color/parchment</item>
|
||||
<item name="android:navigationBarColor">@color/parchment</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/parchment</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/app_icon_foreground</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/parchment</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="parchment">#F6E9E1</color>
|
||||
</resources>
|
||||
@@ -3,8 +3,8 @@
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">#F7F0DF</item>
|
||||
<item name="android:navigationBarColor">#F7F0DF</item>
|
||||
<item name="android:statusBarColor">#F6E9E1</item>
|
||||
<item name="android:navigationBarColor">#F6E9E1</item>
|
||||
<item name="android:windowBackground">@drawable/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -123,6 +123,11 @@ private class Validator(private val path: Path) {
|
||||
val label = "fact ${id(fact)}"
|
||||
expectInt(fact, "schemaVersion", 1, label)
|
||||
listOf("heading", "text").forEach { text(fact, it, label) }
|
||||
val heading = value(fact, "heading")
|
||||
val copy = value(fact, "text")
|
||||
if (heading == "Atlas placement" || copy.contains("UN M49", ignoreCase = true)) {
|
||||
errors += "$label exposes catalog metadata as lesson copy"
|
||||
}
|
||||
refs(label, strings(fact, "countryIds"), countryIds, "country")
|
||||
val sourcesForFact = strings(fact, "sourceIds")
|
||||
if (sourcesForFact.isEmpty()) errors += "$label has no factual source"
|
||||
@@ -207,6 +212,9 @@ private class Validator(private val path: Path) {
|
||||
}
|
||||
val hints = strings(question, "supportedHints")
|
||||
(hints - validHints).forEach { errors += "$label has unsupported hint $it" }
|
||||
if ("factual_clue" in hints && value(question, "explanation").contains(answer, ignoreCase = true)) {
|
||||
errors += "$label factual clue contains its answer"
|
||||
}
|
||||
if (format != "letter_bank" && "reveal_letter" in hints) errors += "$label has an incompatible letter hint"
|
||||
if (format != "multiple_choice" && "remove_choices" in hints) errors += "$label has incompatible choice removal"
|
||||
refs(label, strings(question, "countryIds"), countryIds, "country")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#F6E9E1"/>
|
||||
<g transform="translate(55 85) scale(.66)">
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/>
|
||||
<path stroke="#27635B" stroke-width="19" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/>
|
||||
<path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
</g>
|
||||
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round">
|
||||
<path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/>
|
||||
<path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/>
|
||||
<path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/>
|
||||
<path fill="none" d="M332 91v91m28-78v91m30-104v91"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert cheering after a correct answer</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<path fill="none" stroke="#3E2447" stroke-width="11" stroke-linecap="round" d="m158 252 21 17 31-34m140 0-31 34-21-17M200 324q57 62 113 0"/>
|
||||
<path fill="#147568" d="m425 61 15 34 37 4-28 25 8 36-32-19-32 19 8-36-28-25 37-4z"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><path stroke="#27635B" stroke-width="15" d="M245 246h23"/>
|
||||
<path fill="none" stroke="#27635B" stroke-width="11" stroke-linecap="round" d="m158 252 21 17 31-34m140 0-31 34-21-17M200 324q57 62 113 0"/>
|
||||
<path fill="#27635B" d="m425 61 15 34 37 4-28 25 8 36-32-19-32 19 8-36-28-25 37-4z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 844 B After Width: | Height: | Size: 844 B |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert delighted by a surprising fact</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="15" fill="#3E2447"/><circle cx="328" cy="251" r="15" fill="#3E2447"/><circle cx="257" cy="342" r="25" fill="#FFFBF2" stroke="#3E2447" stroke-width="10"/>
|
||||
<path fill="#E3AD35" stroke="#3E2447" stroke-width="8" d="M410 45c55 0 74 68 31 96l-2 25h-58l-2-25c-43-28-24-96 31-96z"/><path stroke="#3E2447" stroke-width="9" d="M382 181h57"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><path stroke="#27635B" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="15" fill="#27635B"/><circle cx="328" cy="251" r="15" fill="#27635B"/><circle cx="257" cy="342" r="25" fill="#F6E9E1" stroke="#27635B" stroke-width="10"/>
|
||||
<path fill="#E9AF8B" stroke="#27635B" stroke-width="8" d="M410 45c55 0 74 68 31 96l-2 25h-58l-2-25c-43-28-24-96 31-96z"/><path stroke="#27635B" stroke-width="9" d="M382 181h57"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 978 B |
@@ -1,11 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert smiling behind oversized glasses</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<path fill="none" stroke="#C45348" stroke-width="8" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/>
|
||||
<path stroke="#3E2447" stroke-width="15" stroke-linecap="round" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="12" fill="#3E2447"/><circle cx="328" cy="250" r="12" fill="#3E2447"/>
|
||||
<path fill="none" stroke="#3E2447" stroke-width="12" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
<circle cx="418" cy="120" r="27" fill="#17647E"/><path stroke="#17647E" stroke-width="11" stroke-linecap="round" d="M418 72V42m35 44 23-22"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<path fill="none" stroke="#E1814E" stroke-width="8" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
|
||||
<path stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="12" fill="#27635B"/><circle cx="328" cy="250" r="12" fill="#27635B"/>
|
||||
<path fill="none" stroke="#27635B" stroke-width="12" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
<circle cx="418" cy="120" r="27" fill="#778BA5"/><path stroke="#778BA5" stroke-width="11" stroke-linecap="round" d="M418 72V42m35 44 23-22"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert offering encouragement after a wrong answer</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="11" fill="#3E2447"/><circle cx="328" cy="251" r="11" fill="#3E2447"/><path fill="none" stroke="#3E2447" stroke-width="12" stroke-linecap="round" d="M205 350q52-44 104 0"/>
|
||||
<path fill="none" stroke="#17647E" stroke-width="10" stroke-linecap="round" d="M413 80c34 31 7 61-7 81-14-20-41-50-7-81l7-8z"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><path stroke="#27635B" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="11" fill="#27635B"/><circle cx="328" cy="251" r="11" fill="#27635B"/><path fill="none" stroke="#27635B" stroke-width="12" stroke-linecap="round" d="M205 350q52-44 104 0"/>
|
||||
<path fill="none" stroke="#778BA5" stroke-width="10" stroke-linecap="round" d="M413 80c34 31 7 61-7 81-14-20-41-50-7-81l7-8z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 958 B |
@@ -1 +1,152 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert correct accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"success star","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-12]},{"t":22,"s":[12]},{"t":45,"s":[-12]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[70,70,100]},{"t":22,"s":[110,110,100]},{"t":45,"s":[70,70,100]}]}},"shapes":[{"ty":"sr","sy":1,"pt":{"a":0,"k":5},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"or":{"a":0,"k":100},"os":{"a":0,"k":0},"ir":{"a":0,"k":45},"is":{"a":0,"k":0}},{"ty":"fl","c":{"a":0,"k":[0.08,0.46,0.41,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert correct accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "success star",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-12
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"s": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
-12
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
70,
|
||||
70,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"s": [
|
||||
110,
|
||||
110,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
70,
|
||||
70,
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sr",
|
||||
"sy": 1,
|
||||
"pt": {
|
||||
"a": 0,
|
||||
"k": 5
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"or": {
|
||||
"a": 0,
|
||||
"k": 42
|
||||
},
|
||||
"os": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"ir": {
|
||||
"a": 0,
|
||||
"k": 18
|
||||
},
|
||||
"is": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.152941,
|
||||
0.388235,
|
||||
0.356863,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,115 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert fact accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"fact bubble","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[256,270,0]},{"t":30,"s":[256,242,0]},{"t":60,"s":[256,270,0]}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[190,150]}},{"ty":"fl","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert fact accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "fact bubble",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
420,
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
420,
|
||||
82,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
420,
|
||||
100,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
72,
|
||||
54
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.466667,
|
||||
0.545098,
|
||||
0.647059,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,113 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert hint accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"hint glow","ks":{"o":{"a":1,"k":[{"t":0,"s":[45]},{"t":30,"s":[100]},{"t":60,"s":[45]}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[180,180]}},{"ty":"fl","c":{"a":0,"k":[0.89,0.68,0.21,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert hint accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "hint glow",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
64,
|
||||
64
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.913725,
|
||||
0.686275,
|
||||
0.545098,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,157 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":90,"w":512,"h":512,"nm":"bert idle","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"spark","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-8]},{"t":45,"s":[8]},{"t":90,"s":[-8]}]},"p":{"a":0,"k":[410,105,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[85,85,100]},{"t":45,"s":[110,110,100]},{"t":90,"s":[85,85,100]}]}},"ao":0,"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[48,48]},"nm":"spark circle"},{"ty":"fl","c":{"a":0,"k":[0.09,0.45,0.41,1]},"o":{"a":0,"k":100},"r":1,"nm":"teal fill"}],"ip":0,"op":90,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"bert","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[256,266,0]},{"t":45,"s":[256,258,0]},{"t":90,"s":[256,266,0]}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[310,270]},"nm":"brain silhouette"},{"ty":"fl","c":{"a":0,"k":[0.91,0.43,0.36,1]},"o":{"a":0,"k":100},"r":1,"nm":"coral fill"},{"ty":"st","c":{"a":0,"k":[0.24,0.14,0.28,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":12},"lc":2,"lj":2,"nm":"plum stroke"}],"ip":0,"op":90,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 90,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert idle accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "curiosity spark",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 90,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 90,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
412,
|
||||
102,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
75,
|
||||
75,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
105,
|
||||
105,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 90,
|
||||
"s": [
|
||||
75,
|
||||
75,
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
34,
|
||||
34
|
||||
]
|
||||
},
|
||||
"nm": "spark dot"
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.152941,
|
||||
0.388235,
|
||||
0.356863,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1,
|
||||
"nm": "teal fill"
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 90,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,124 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert incorrect accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"kind wobble","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-4]},{"t":15,"s":[4]},{"t":30,"s":[-4]},{"t":45,"s":[0]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[170,170]}},{"ty":"st","c":{"a":0,"k":[0.61,0.24,0.2,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":18},"lc":2,"lj":2}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert incorrect accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "kind wobble",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-4
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"s": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
-4
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
42,
|
||||
42
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.882353,
|
||||
0.505882,
|
||||
0.305882,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 18
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,112 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert overtime accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"clock","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0]},{"t":60,"s":[360]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[190,190]}},{"ty":"st","c":{"a":0,"k":[0.89,0.68,0.21,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert overtime accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "clock",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
360
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
70,
|
||||
70
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.913725,
|
||||
0.686275,
|
||||
0.545098,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 20
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,131 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert results accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"result seal","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-8]},{"t":22,"s":[8]},{"t":45,"s":[-8]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"sr","sy":1,"pt":{"a":0,"k":12},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"or":{"a":0,"k":110},"os":{"a":0,"k":0},"ir":{"a":0,"k":90},"is":{"a":0,"k":0}},{"ty":"fl","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert results accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "result seal",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"s": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sr",
|
||||
"sy": 1,
|
||||
"pt": {
|
||||
"a": 0,
|
||||
"k": 12
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"or": {
|
||||
"a": 0,
|
||||
"k": 42
|
||||
},
|
||||
"os": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"ir": {
|
||||
"a": 0,
|
||||
"k": 32
|
||||
},
|
||||
"is": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.466667,
|
||||
0.545098,
|
||||
0.647059,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,158 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":30,"w":512,"h":512,"nm":"bert passport stamp accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"stamp","ks":{"o":{"a":1,"k":[{"t":0,"s":[0]},{"t":10,"s":[100]},{"t":30,"s":[100]}]},"r":{"a":1,"k":[{"t":0,"s":[-18]},{"t":10,"s":[3]},{"t":30,"s":[0]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[150,150,100]},{"t":10,"s":[95,95,100]},{"t":30,"s":[100,100,100]}]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[220,220]}},{"ty":"st","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert passport stamp accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "stamp",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-18
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"s": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
150,
|
||||
150,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"s": [
|
||||
95,
|
||||
95,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
72,
|
||||
72
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.466667,
|
||||
0.545098,
|
||||
0.647059,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 20
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,112 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert streak accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"streak ring","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0]},{"t":60,"s":[360]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[210,210]}},{"ty":"st","c":{"a":0,"k":[0.91,0.43,0.36,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":22},"lc":2,"lj":2}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert streak accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "streak ring",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
360
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
70,
|
||||
70
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.968627,
|
||||
0.74902,
|
||||
0.745098,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 22
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import app.bertsatlas.domain.model.HintType
|
||||
import app.bertsatlas.domain.model.Question
|
||||
import app.bertsatlas.domain.model.QuizDuration
|
||||
import app.bertsatlas.domain.model.RunPhase
|
||||
import app.bertsatlas.domain.model.RunStatus
|
||||
import app.bertsatlas.ui.AnswerState
|
||||
import app.bertsatlas.ui.BertsAtlasTheme
|
||||
import app.bertsatlas.ui.BootstrapScreen
|
||||
@@ -27,6 +28,7 @@ import app.bertsatlas.ui.FlagUi
|
||||
import app.bertsatlas.ui.FunFactScreen
|
||||
import app.bertsatlas.ui.HintChooserScreen
|
||||
import app.bertsatlas.ui.HomeScreen
|
||||
import app.bertsatlas.ui.LeaveQuizConfirmationScreen
|
||||
import app.bertsatlas.ui.LetterSlotUi
|
||||
import app.bertsatlas.ui.LetterTileUi
|
||||
import app.bertsatlas.ui.OptionsScreen
|
||||
@@ -66,12 +68,21 @@ fun App(
|
||||
@Composable
|
||||
private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
val run = state.run
|
||||
if (state.leaveConfirmationRequested) {
|
||||
LeaveQuizConfirmationScreen(
|
||||
onCancel = controller::cancelLeave,
|
||||
onLeave = controller::confirmLeave,
|
||||
)
|
||||
return
|
||||
}
|
||||
when (val destination = state.destination) {
|
||||
Destination.Bootstrap -> BootstrapScreen()
|
||||
Destination.Home -> HomeScreen(
|
||||
isFirstLaunch = state.firstLaunch,
|
||||
streakDays = state.streak.currentDays,
|
||||
progressSummary = "${state.attempts.size} questions explored",
|
||||
totalPoints = state.attempts.sumOf { it.pointsAwarded } - state.attempts.sumOf { it.hintPointsDeducted },
|
||||
hasStartedTrivia = !state.firstLaunch,
|
||||
onStart = controller::start,
|
||||
onOptions = controller::openOptions,
|
||||
onProgress = { controller.navigate(Destination.Progress) },
|
||||
@@ -88,7 +99,7 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
score = run.score,
|
||||
timeRemaining = run.sessionRemainingMillis.formatTime(),
|
||||
onResume = controller::resume,
|
||||
onAbandon = controller::abandon,
|
||||
onAbandon = controller::requestLeave,
|
||||
)
|
||||
Destination.Quiz -> if (run == null) BootstrapScreen() else RunDestination(controller, state)
|
||||
Destination.Results -> {
|
||||
@@ -98,19 +109,20 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
correctAnswers = result.correctAnswers,
|
||||
answeredQuestions = result.answeredQuestions,
|
||||
accuracy = "${result.accuracyPercent}%",
|
||||
averageResponseTime = "${result.averageResponseMillis / 1_000.0}s",
|
||||
averageResponseTime = result.averageResponseMillis.formatSeconds(),
|
||||
bestCorrectStreak = result.bestCorrectStreak,
|
||||
masteryImproved = result.improvedMasteryItems,
|
||||
revisitCountries = result.revisitCountryIds.mapNotNull { id -> controller.catalog.countries.firstOrNull { it.id == id }?.displayName },
|
||||
newPersonalBest = state.newPersonalBest,
|
||||
onPlayAgain = controller::start,
|
||||
onHome = { controller.navigate(Destination.Home) },
|
||||
abandoned = result.status == RunStatus.Abandoned,
|
||||
)
|
||||
}
|
||||
Destination.Progress -> ProgressScreen(
|
||||
currentStreak = state.streak.currentDays,
|
||||
bestStreak = state.streak.bestDays,
|
||||
overallAccuracy = if (state.attempts.isEmpty()) "—" else "${state.attempts.count { it.correct } * 100 / state.attempts.size}%",
|
||||
overallAccuracy = if (state.attempts.isEmpty()) "Not yet" else "${state.attempts.count { it.correct } * 100 / state.attempts.size}%",
|
||||
totalAnswered = state.attempts.size,
|
||||
masteryGroups = controller.progressGroups,
|
||||
strongCountries = countryNames(controller, state.mastery.filter { it.level.name in setOf("Familiar", "Mastered") }.map { it.countryId }),
|
||||
@@ -163,28 +175,32 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
prompt = question.prompt,
|
||||
imageDescription = if (promptFlag == null) null else "A flag to identify",
|
||||
score = run.score,
|
||||
sessionTime = if (run.configuration.duration == QuizDuration.Unlimited) {
|
||||
(run.attempts.sumOf { it.responseMillis } + run.activeQuestion.questionElapsedMillis).formatTime()
|
||||
} else run.sessionRemainingMillis.formatTime(),
|
||||
questionTime = run.activeQuestion.questionRemainingMillis.formatTime(),
|
||||
timerExpired = run.activeQuestion.questionRemainingMillis == 0L,
|
||||
choices = (question as? Question.MultipleChoice)?.let {
|
||||
run.activeQuestion.orderedChoices.map { choice ->
|
||||
val choiceCountry = controller.catalog.countries.firstOrNull { it.id == choice.id }
|
||||
ChoiceUi(
|
||||
id = choice.id,
|
||||
label = choice.label,
|
||||
state = if (choice.id in run.activeQuestion.removedChoiceIds) AnswerState.Eliminated else AnswerState.Available,
|
||||
enabled = choice.id !in run.activeQuestion.removedChoiceIds && run.phase == RunPhase.Answering,
|
||||
flag = choice.assetId?.let { FlagUi(it.removePrefix("flag-"), choiceCountry?.displayName ?: choice.label) },
|
||||
flag = choice.assetId?.let { FlagUi(it.removePrefix("flag-"), choice.label) },
|
||||
)
|
||||
}
|
||||
}.orEmpty(),
|
||||
letterSlots = letterSlots(question, run.activeQuestion.placements, run.activeQuestion.letterTiles),
|
||||
letterSlots = letterSlots(
|
||||
question,
|
||||
run.activeQuestion.placements,
|
||||
run.activeQuestion.letterTiles,
|
||||
run.activeQuestion.appliedHints.mapNotNullTo(linkedSetOf()) { it.revealedTileId },
|
||||
),
|
||||
letterTiles = run.activeQuestion.letterTiles.map { tile ->
|
||||
LetterTileUi(tile.id, tile.character.toString(), run.activeQuestion.placements.firstOrNull { it.tileId == tile.id }?.slotIndex, tile.id !in run.activeQuestion.removedTileIds)
|
||||
},
|
||||
hintAvailable = run.activeQuestion.hint == null && run.phase == RunPhase.Answering,
|
||||
appliedHintMessages = run.activeQuestion.appliedHints.mapNotNull { it.message },
|
||||
hintAvailable = run.activeQuestion.question.supportedHints.any {
|
||||
it !in run.activeQuestion.usedHintTypes
|
||||
} && run.phase == RunPhase.Answering,
|
||||
unlimited = run.configuration.duration == QuizDuration.Unlimited,
|
||||
canSubmit = question is Question.LetterBank && run.activeQuestion.placements.size == question.displayAnswer.count { it.isLetter() },
|
||||
onChoice = controller::answerChoice,
|
||||
@@ -196,11 +212,25 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
onFinish = controller::finishUnlimited,
|
||||
onLeave = controller::requestLeave,
|
||||
promptFlag = promptFlag,
|
||||
runProgress = run.configuration.duration.activeDurationMillis?.let { duration ->
|
||||
1f - ((run.sessionRemainingMillis ?: duration).toFloat() / duration.toFloat())
|
||||
},
|
||||
runProgressDescription = "Trivia time remaining ${run.sessionRemainingMillis.formatTime()}",
|
||||
)
|
||||
}
|
||||
RunPhase.HintChooser -> HintChooserScreen(
|
||||
score = run.score,
|
||||
hints = run.activeQuestion.question.supportedHints.map { ChoiceUi(it.name, it.displayName()) },
|
||||
hints = run.activeQuestion.question.supportedHints.map { hint ->
|
||||
val used = hint in run.activeQuestion.usedHintTypes
|
||||
ChoiceUi(
|
||||
id = hint.name,
|
||||
label = hint.displayName(),
|
||||
supportingText = if (used) "✓ Used" else null,
|
||||
state = if (used) AnswerState.Selected else AnswerState.Available,
|
||||
enabled = !used,
|
||||
)
|
||||
},
|
||||
chargedHintLabel = "−${run.score / 2} points",
|
||||
onChooseHint = { id -> controller.chooseHint(HintType.valueOf(id)) },
|
||||
onCancel = controller::cancelHint,
|
||||
)
|
||||
@@ -227,7 +257,7 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
RunPhase.FunFact -> requireNotNull(run.visibleFact).let { fact ->
|
||||
FunFactScreen(fact.heading, fact.body, countryNames(controller, fact.countryIds).joinToString(), onContinue = controller::continueRun)
|
||||
}
|
||||
RunPhase.Completed -> BootstrapScreen("Finishing your field notes…")
|
||||
RunPhase.Completed -> BootstrapScreen("Preparing your summary…")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +271,12 @@ private fun topicChoices() = listOf(
|
||||
ChoiceUi("MixedGeography", "Mixed geography", "A broader grab bag"),
|
||||
)
|
||||
|
||||
private fun letterSlots(question: Question, placements: List<app.bertsatlas.domain.model.LetterPlacement>, tiles: List<app.bertsatlas.domain.model.LetterTile>): List<LetterSlotUi> {
|
||||
private fun letterSlots(
|
||||
question: Question,
|
||||
placements: List<app.bertsatlas.domain.model.LetterPlacement>,
|
||||
tiles: List<app.bertsatlas.domain.model.LetterTile>,
|
||||
revealedTileIds: Set<String>,
|
||||
): List<LetterSlotUi> {
|
||||
val letterBank = question as? Question.LetterBank ?: return emptyList()
|
||||
val tileById = tiles.associateBy { it.id }
|
||||
var editableIndex = 0
|
||||
@@ -249,7 +284,12 @@ private fun letterSlots(question: Question, placements: List<app.bertsatlas.doma
|
||||
if (!character.isLetter()) LetterSlotUi(displayIndex, character.toString(), fixed = true)
|
||||
else {
|
||||
val placement = placements.firstOrNull { it.slotIndex == editableIndex }
|
||||
LetterSlotUi(displayIndex, placement?.let { tileById[it.tileId]?.character?.toString() }, placement?.tileId).also { editableIndex++ }
|
||||
LetterSlotUi(
|
||||
position = displayIndex,
|
||||
character = placement?.let { tileById[it.tileId]?.character?.toString() },
|
||||
tileId = placement?.tileId,
|
||||
fixed = placement?.tileId in revealedTileIds,
|
||||
).also { editableIndex++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +307,8 @@ private fun Long?.formatTime(): String {
|
||||
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private fun Long.formatSeconds(): String = "${((coerceAtLeast(0) + 500) / 1_000)} sec"
|
||||
|
||||
private fun countryNames(controller: AppController, ids: List<String>): List<String> = ids.distinct().mapNotNull { id ->
|
||||
controller.catalog.countries.firstOrNull { it.id == id }?.displayName
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import app.bertsatlas.ui.StampUi
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.TimeSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -85,6 +86,8 @@ data class AtlasAppState(
|
||||
val completedRuns: List<RunEntity> = emptyList(),
|
||||
val streak: Streak = Streak(),
|
||||
val stamps: List<PassportStamp> = emptyList(),
|
||||
val leaveConfirmationRequested: Boolean = false,
|
||||
val resumeRunAfterLeaveCancellation: Boolean = false,
|
||||
)
|
||||
|
||||
class AppController(
|
||||
@@ -97,6 +100,7 @@ class AppController(
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val mutex = Mutex()
|
||||
private var lastRunPersistence: Job? = null
|
||||
private val monotonicOrigin = TimeSource.Monotonic.markNow()
|
||||
private val monotonicClock = MonotonicClock { monotonicOrigin.elapsedNow().inWholeMilliseconds }
|
||||
private val wallClock = WallClock { Clock.System.now().toEpochMilliseconds() }
|
||||
@@ -114,7 +118,9 @@ class AppController(
|
||||
delay(100)
|
||||
val current = _state.value.run
|
||||
if (current?.phase == RunPhase.Answering) {
|
||||
_state.value = _state.value.copy(run = engine.advanceClocks(current))
|
||||
val advanced = engine.advanceClocks(current)
|
||||
if (advanced.sessionExpired) acceptTransition(engine.finishExpiredRun(advanced))
|
||||
else _state.value = _state.value.copy(run = advanced)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,9 +138,11 @@ class AppController(
|
||||
runCatching { engine.restoreRun(json.decodeFromString<ActiveRunSnapshot>(it.snapshotJson)) }.getOrNull()
|
||||
}
|
||||
if (snapshot != null && restored == null) database.runWriter().clearActiveRun()
|
||||
val hasStartedTrivia = preferences.hasStartedTrivia || restored != null || attempts.isNotEmpty() || runs.isNotEmpty()
|
||||
if (hasStartedTrivia && !preferences.hasStartedTrivia) preferencesStore.markTriviaStarted()
|
||||
_state.value = AtlasAppState(
|
||||
destination = if (restored == null) Destination.Home else Destination.Recovery,
|
||||
firstLaunch = !preferences.hasPreviouslyLaunched,
|
||||
firstLaunch = !hasStartedTrivia,
|
||||
preferences = preferences,
|
||||
selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes),
|
||||
run = restored,
|
||||
@@ -144,7 +152,6 @@ class AppController(
|
||||
streak = streak,
|
||||
stamps = stamps,
|
||||
)
|
||||
preferencesStore.markLaunched()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
@@ -184,11 +191,38 @@ class AppController(
|
||||
}
|
||||
}
|
||||
|
||||
fun back() {
|
||||
_state.value = when (val destination = _state.value.destination) {
|
||||
is Destination.Options -> _state.value.copy(destination = if (destination.step == 2) Destination.Options(1) else Destination.Home)
|
||||
Destination.Privacy, Destination.Credits, Destination.Reset, Destination.Debug -> _state.value.copy(destination = Destination.Settings)
|
||||
else -> _state.value.copy(destination = Destination.Home)
|
||||
fun back() { handleBack() }
|
||||
|
||||
fun handleBack(): Boolean {
|
||||
val current = _state.value
|
||||
if (current.leaveConfirmationRequested) {
|
||||
cancelLeave()
|
||||
return true
|
||||
}
|
||||
return when (val destination = current.destination) {
|
||||
Destination.Bootstrap, Destination.Home -> false
|
||||
is Destination.Options -> {
|
||||
_state.value = current.copy(
|
||||
destination = if (destination.step == 2) Destination.Options(1) else Destination.Home,
|
||||
)
|
||||
true
|
||||
}
|
||||
Destination.Recovery -> {
|
||||
requestLeave()
|
||||
true
|
||||
}
|
||||
Destination.Quiz -> {
|
||||
if (current.run?.phase == RunPhase.HintChooser) cancelHint() else requestLeave()
|
||||
true
|
||||
}
|
||||
Destination.Results, Destination.Progress, Destination.Settings -> {
|
||||
_state.value = current.copy(destination = Destination.Home)
|
||||
true
|
||||
}
|
||||
Destination.Privacy, Destination.Credits, Destination.Reset, Destination.Debug -> {
|
||||
_state.value = current.copy(destination = Destination.Settings)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +230,7 @@ class AppController(
|
||||
val now = wallClock.nowEpochMillis()
|
||||
val run = engine.startRun("run-$now", configuration, now xor monotonicClock.nowMillis(), _state.value.mastery)
|
||||
_state.value = _state.value.copy(destination = Destination.Quiz, firstLaunch = false, run = run, result = null)
|
||||
scope.launch { preferencesStore.markTriviaStarted() }
|
||||
persistSnapshot(run)
|
||||
}
|
||||
|
||||
@@ -206,10 +241,12 @@ class AppController(
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
fun answerChoice(choiceId: String) = mutateRun { engine.answerQuestion(it, SubmittedAnswer.ChoiceAnswer(choiceId)) }.also {
|
||||
fun answerChoice(choiceId: String) {
|
||||
if (mutateRun { engine.answerQuestion(it, SubmittedAnswer.ChoiceAnswer(choiceId)) }) {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
fun placeLetter(tileId: String) = mutateRun { engine.placeLetter(it, tileId) }
|
||||
|
||||
@@ -220,14 +257,18 @@ class AppController(
|
||||
|
||||
fun clearLetters() = mutateRun(engine::clearLetters)
|
||||
|
||||
fun submitLetters() = mutateRun(engine::submitLetters).also {
|
||||
fun submitLetters() {
|
||||
if (mutateRun(engine::submitLetters)) {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
fun openHint() = mutateRun(engine::openHintChooser)
|
||||
fun cancelHint() = mutateRun { state -> engine.resumeRun(state.copy(phase = RunPhase.Paused, pausedFromPhase = RunPhase.Answering)) }
|
||||
fun chooseHint(type: HintType) = mutateRun { engine.chooseHint(it, type) }.also { persistSnapshot(_state.value.run) }
|
||||
fun chooseHint(type: HintType) {
|
||||
if (mutateRun { engine.chooseHint(it, type) }) persistSnapshot(_state.value.run)
|
||||
}
|
||||
|
||||
fun continueRun() {
|
||||
val before = _state.value.run ?: return
|
||||
@@ -244,19 +285,67 @@ class AppController(
|
||||
acceptTransition(engine.finishUnlimitedRun(run, _state.value.mastery))
|
||||
}
|
||||
|
||||
fun requestLeave() { abandon() }
|
||||
fun requestLeave() {
|
||||
val current = _state.value
|
||||
val run = current.run ?: return
|
||||
if (run.status != RunStatus.Active || current.leaveConfirmationRequested) return
|
||||
val shouldResumeOnCancel = run.phase != RunPhase.Paused
|
||||
val paused = if (shouldResumeOnCancel) engine.pauseForInterruption(run) else run
|
||||
_state.value = current.copy(
|
||||
run = paused,
|
||||
leaveConfirmationRequested = true,
|
||||
resumeRunAfterLeaveCancellation = shouldResumeOnCancel,
|
||||
)
|
||||
persistSnapshot(paused)
|
||||
}
|
||||
|
||||
fun cancelLeave() {
|
||||
val current = _state.value
|
||||
if (!current.leaveConfirmationRequested) return
|
||||
val run = current.run
|
||||
val resumed = if (
|
||||
current.resumeRunAfterLeaveCancellation &&
|
||||
run?.status == RunStatus.Active &&
|
||||
run.phase == RunPhase.Paused
|
||||
) {
|
||||
engine.resumeRun(run)
|
||||
} else {
|
||||
run
|
||||
}
|
||||
_state.value = current.copy(
|
||||
run = resumed,
|
||||
leaveConfirmationRequested = false,
|
||||
resumeRunAfterLeaveCancellation = false,
|
||||
)
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
fun confirmLeave() { abandon() }
|
||||
|
||||
fun abandon() {
|
||||
val run = _state.value.run ?: return
|
||||
val abandoned = if (run.status == RunStatus.Active) engine.abandonRun(run) else run
|
||||
_state.value = _state.value.copy(destination = Destination.Home, run = null)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
database.runWriter().abandonRun(abandoned.toRunEntity())
|
||||
if (abandoned.status != RunStatus.Abandoned) return
|
||||
val summary = engine.abandonedRunSummary(
|
||||
abandoned,
|
||||
improvedMasteryItems = abandoned.attempts
|
||||
.flatMap { attempt -> attempt.countryIds.map { it to attempt.skill } }
|
||||
.distinct()
|
||||
.size,
|
||||
)
|
||||
_state.value = _state.value.copy(
|
||||
destination = Destination.Results,
|
||||
run = abandoned,
|
||||
result = summary,
|
||||
newPersonalBest = false,
|
||||
leaveConfirmationRequested = false,
|
||||
resumeRunAfterLeaveCancellation = false,
|
||||
)
|
||||
enqueueRunPersistence {
|
||||
database.runWriter().abandonRun(abandoned.toRunEntity(summary))
|
||||
refreshProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseForInterruption() {
|
||||
val run = _state.value.run ?: return
|
||||
@@ -268,16 +357,18 @@ class AppController(
|
||||
|
||||
fun resumeAfterInterruption() {
|
||||
val run = _state.value.run ?: return
|
||||
if (_state.value.leaveConfirmationRequested) return
|
||||
if (_state.value.destination != Destination.Quiz || run.phase != RunPhase.Paused) return
|
||||
val resumed = engine.resumeRun(run)
|
||||
_state.value = _state.value.copy(run = resumed)
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
private fun mutateRun(change: (QuizRunState) -> QuizRunState) {
|
||||
val current = _state.value.run ?: return
|
||||
val next = runCatching { change(current) }.getOrNull() ?: return
|
||||
private fun mutateRun(change: (QuizRunState) -> QuizRunState): Boolean {
|
||||
val current = _state.value.run ?: return false
|
||||
val next = runCatching { change(current) }.getOrNull() ?: return false
|
||||
_state.value = _state.value.copy(destination = destinationFor(next), run = next)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun acceptTransition(next: QuizRunState) {
|
||||
@@ -291,8 +382,7 @@ class AppController(
|
||||
private fun persistAcceptedAnswer() {
|
||||
val run = _state.value.run ?: return
|
||||
val attempt = run.attempts.lastOrNull() ?: return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
enqueueRunPersistence {
|
||||
val before = _state.value.mastery
|
||||
val after = MasteryRules.applyAttemptToCountries(before, attempt)
|
||||
val changed = after.filter { updated -> before.none { it.countryId == updated.countryId && it.skill == updated.skill && it == updated } }
|
||||
@@ -304,7 +394,6 @@ class AppController(
|
||||
_state.value = _state.value.copy(mastery = after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAnswerFeedback() {
|
||||
val run = _state.value.run ?: return
|
||||
@@ -314,18 +403,15 @@ class AppController(
|
||||
|
||||
private fun persistSnapshot(run: QuizRunState?) {
|
||||
if (run == null || run.status != RunStatus.Active) return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
enqueueRunPersistence {
|
||||
database.runWriter().saveActiveRun(run.toSnapshotEntity(engine, json))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun complete(run: QuizRunState) {
|
||||
val result = engine.runResult(run, improvedMasteryItems = run.attempts.map { it.countryIds.firstOrNull() to it.skill }.distinct().size)
|
||||
_state.value = _state.value.copy(destination = Destination.Results, run = run, result = result)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
enqueueRunPersistence {
|
||||
val reader = database.progressReader()
|
||||
val oldBest = reader.personalBestsNow().firstOrNull {
|
||||
it.duration == run.configuration.duration.name && it.topic == run.configuration.topic.name
|
||||
@@ -359,6 +445,13 @@ class AppController(
|
||||
refreshProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueRunPersistence(block: suspend () -> Unit) {
|
||||
val precedingWrite = lastRunPersistence
|
||||
lastRunPersistence = scope.launch {
|
||||
precedingWrite?.join()
|
||||
mutex.withLock { block() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshProgress() {
|
||||
@@ -387,7 +480,7 @@ class AppController(
|
||||
}
|
||||
|
||||
fun resetAll() {
|
||||
scope.launch {
|
||||
enqueueRunPersistence {
|
||||
database.runWriter().resetProgress()
|
||||
preferencesStore.resetAll()
|
||||
_state.value = AtlasAppState(destination = Destination.Home)
|
||||
@@ -398,10 +491,12 @@ class AppController(
|
||||
if (!debugBuild) return
|
||||
when (id) {
|
||||
"reset-first" -> scope.launch { preferencesStore.resetFirstLaunchAndConfiguration(); _state.value = _state.value.copy(firstLaunch = true, destination = Destination.Home) }
|
||||
"reset-progress" -> scope.launch { database.runWriter().resetProgress(); refreshProgress(); _state.value = _state.value.copy(destination = Destination.Home, run = null) }
|
||||
"reset-progress" -> enqueueRunPersistence { database.runWriter().resetProgress(); refreshProgress(); _state.value = _state.value.copy(destination = Destination.Home, run = null) }
|
||||
"reset-all" -> resetAll()
|
||||
"question-zero" -> updateRunDebug { it.copy(activeQuestion = it.activeQuestion.copy(questionRemainingMillis = 0)) }
|
||||
"session-zero" -> updateRunDebug { it.copy(sessionRemainingMillis = 0, sessionExpired = true) }
|
||||
"session-zero" -> updateRunDebug {
|
||||
it.copy(sessionRemainingMillis = 0, sessionExpired = true, runCompletionPending = true)
|
||||
}
|
||||
"score-100" -> updateRunDebug { it.copy(score = 100) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ object CatalogLoader {
|
||||
val similarityGroupsByCountry = source.similarityGroups
|
||||
.flatMap { group -> group.countryIds.map { it to group.id } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
val curatedCapitalCountryIds = source.curatedQuestions
|
||||
.filter { it.id.startsWith("mixed-capital-") }
|
||||
.flatMapTo(mutableSetOf()) { it.countryIds }
|
||||
val domainCountries = source.countries.map { country ->
|
||||
country.toDomain(similarityGroupsByCountry[country.id].orEmpty())
|
||||
}
|
||||
@@ -53,11 +56,10 @@ object CatalogLoader {
|
||||
similarityGroupIds = similarityGroupsByCountry[country.id].orEmpty(),
|
||||
similarityGroups = source.similarityGroups,
|
||||
relatedFacts = factsByCountry[country.id].orEmpty(),
|
||||
hasCuratedCapitalQuestion = country.id in curatedCapitalCountryIds,
|
||||
)
|
||||
}
|
||||
val curatedQuestions = source.curatedQuestions.map { question ->
|
||||
question.toDomain(countriesById)
|
||||
}
|
||||
val curatedQuestions = source.curatedQuestions.map { question -> question.toDomain(countriesById) }
|
||||
return Catalog(
|
||||
schemaVersion = source.schemaVersion,
|
||||
contentVersion = source.catalogVersion,
|
||||
@@ -76,6 +78,7 @@ object CatalogLoader {
|
||||
similarityGroupIds: List<String>,
|
||||
similarityGroups: List<SourceSimilarityGroup>,
|
||||
relatedFacts: List<SourceFact>,
|
||||
hasCuratedCapitalQuestion: Boolean,
|
||||
): List<Question> {
|
||||
val distractors = chooseDistractors(
|
||||
country = country,
|
||||
@@ -94,13 +97,22 @@ object CatalogLoader {
|
||||
require(capitalChoices.size == 4) { "Not enough unique capital choices for ${country.id}" }
|
||||
val difficulty = generatedDifficulty(countryIndex)
|
||||
val continentHint = country.region.toContinent().displayName()
|
||||
val factualClue = relatedFacts.firstOrNull()?.text
|
||||
val factualClue = relatedFacts.firstOrNull()?.text?.withoutAnswer(country)
|
||||
val capitalFactualClue = factualClue?.takeUnless { clue ->
|
||||
country.capital.names.any { capital -> clue.contains(capital, ignoreCase = true) }
|
||||
}
|
||||
val commonHintText = buildMap {
|
||||
put(HintType.RevealContinent, continentHint)
|
||||
factualClue?.let { put(HintType.FunnyFactualClue, it) }
|
||||
}
|
||||
val factualHint = if (factualClue == null) emptySet() else setOf(HintType.FunnyFactualClue)
|
||||
val sharedHints = setOf(HintType.RevealContinent) + factualHint
|
||||
val capitalHintText = buildMap {
|
||||
put(HintType.RevealContinent, continentHint)
|
||||
capitalFactualClue?.let { put(HintType.FunnyFactualClue, it) }
|
||||
}
|
||||
val capitalHints = setOf(HintType.RevealContinent) +
|
||||
if (capitalFactualClue == null) emptySet() else setOf(HintType.FunnyFactualClue)
|
||||
val flagToCountry = Question.MultipleChoice(
|
||||
id = "generated-flag-to-country-${country.id}",
|
||||
prompt = "Which country does this flag belong to?",
|
||||
@@ -125,8 +137,12 @@ object CatalogLoader {
|
||||
explanation = "This is the flag of ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
choices = choices.map {
|
||||
Choice(id = it.id, label = it.displayName, assetId = it.flagAssetId)
|
||||
choices = choices.mapIndexed { choiceIndex, choice ->
|
||||
Choice(
|
||||
id = choice.id,
|
||||
label = "Flag ${('A'.code + choiceIndex).toChar()}",
|
||||
assetId = choice.flagAssetId,
|
||||
)
|
||||
},
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
@@ -155,26 +171,34 @@ object CatalogLoader {
|
||||
skill = Skill.CapitalRecall,
|
||||
topic = QuizTopic.Capitals,
|
||||
difficulty = difficulty,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital answer used for ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital of ${country.displayName}.",
|
||||
supportedHints = capitalHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = capitalHintText,
|
||||
choices = capitalChoices.map { Choice(it.id, it.capital.acceptedAnswer) },
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
val capitalToCountry = Question.MultipleChoice(
|
||||
id = "generated-capital-to-country-${country.id}",
|
||||
prompt = "${country.capital.acceptedAnswer} is the capital answer for which country?",
|
||||
prompt = "${country.capital.acceptedAnswer} is the capital of which country?",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.CapitalRecognition,
|
||||
topic = QuizTopic.Capitals,
|
||||
difficulty = difficulty,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital answer used for ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital of ${country.displayName}.",
|
||||
supportedHints = capitalHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = capitalHintText,
|
||||
choices = choices.map { Choice(it.id, it.displayName) },
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
return listOf(flagToCountry, countryToFlag, letterBank, countryToCapital, capitalToCountry)
|
||||
return buildList {
|
||||
add(flagToCountry)
|
||||
add(countryToFlag)
|
||||
add(letterBank)
|
||||
if (country.capital.questionEligible) {
|
||||
if (!hasCuratedCapitalQuestion) add(countryToCapital)
|
||||
add(capitalToCountry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseDistractors(
|
||||
@@ -209,7 +233,11 @@ object CatalogLoader {
|
||||
val answerIndex = choices.indexOf(answer)
|
||||
require(answerIndex >= 0) { "Curated question $id does not contain its answer" }
|
||||
val primaryCountry = countryIds.firstNotNullOfOrNull(countriesById::get)
|
||||
val mappedHints = supportedHints.mapTo(linkedSetOf()) { it.toHintType() }
|
||||
val mappedHints = supportedHints
|
||||
.mapTo(linkedSetOf()) { it.toHintType() }
|
||||
.apply {
|
||||
if (explanation.contains(answer, ignoreCase = true)) remove(HintType.FunnyFactualClue)
|
||||
}
|
||||
val hintText = buildMap {
|
||||
if (HintType.RevealContinent in mappedHints && primaryCountry != null) {
|
||||
put(HintType.RevealContinent, primaryCountry.region.toContinent().displayName())
|
||||
@@ -335,6 +363,14 @@ object CatalogLoader {
|
||||
|
||||
private const val DECOY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
private val decoder = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun String.withoutAnswer(country: SourceCountry): String {
|
||||
val names = (country.acceptedAliases + country.displayName).sortedByDescending(String::length)
|
||||
val redacted = names.fold(this) { text, name ->
|
||||
text.replace(Regex(Regex.escape(name), RegexOption.IGNORE_CASE), "this country")
|
||||
}
|
||||
return redacted.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -373,6 +409,7 @@ private data class SourceCapital(
|
||||
val kind: String,
|
||||
val names: List<String>,
|
||||
val acceptedAnswer: String,
|
||||
val questionEligible: Boolean,
|
||||
val note: String?,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
data class SavedPreferences(
|
||||
val hasPreviouslyLaunched: Boolean = false,
|
||||
val hasStartedTrivia: Boolean = false,
|
||||
val duration: String = "FiveMinutes",
|
||||
val topic: String = "MostlyFlags",
|
||||
val soundEnabled: Boolean = true,
|
||||
@@ -41,7 +41,7 @@ class PreferencesStore(
|
||||
}
|
||||
.map { preferences ->
|
||||
SavedPreferences(
|
||||
hasPreviouslyLaunched = preferences[Keys.previouslyLaunched] ?: false,
|
||||
hasStartedTrivia = preferences[Keys.hasStartedTrivia] ?: false,
|
||||
duration = preferences[Keys.duration] ?: "FiveMinutes",
|
||||
topic = preferences[Keys.topic] ?: "MostlyFlags",
|
||||
soundEnabled = preferences[Keys.soundEnabled] ?: true,
|
||||
@@ -51,10 +51,10 @@ class PreferencesStore(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun hasLaunchedBefore(): Boolean = values.first().hasPreviouslyLaunched
|
||||
override suspend fun hasStartedTrivia(): Boolean = values.first().hasStartedTrivia
|
||||
|
||||
override suspend fun markLaunched() {
|
||||
dataStore.edit { it[Keys.previouslyLaunched] = true }
|
||||
override suspend fun markTriviaStarted() {
|
||||
dataStore.edit { it[Keys.hasStartedTrivia] = true }
|
||||
}
|
||||
|
||||
override suspend fun loadConfiguration(): QuizConfiguration {
|
||||
@@ -70,8 +70,8 @@ class PreferencesStore(
|
||||
}
|
||||
|
||||
suspend fun saveConfiguration(duration: String, topic: String) {
|
||||
require(QuizDuration.entries.any { it.name == duration }) { "Unknown quiz duration: $duration" }
|
||||
require(QuizTopic.entries.any { it.name == topic }) { "Unknown quiz topic: $topic" }
|
||||
require(QuizDuration.entries.any { it.name == duration }) { "Unknown trivia duration: $duration" }
|
||||
require(QuizTopic.entries.any { it.name == topic }) { "Unknown trivia topic: $topic" }
|
||||
dataStore.edit {
|
||||
it[Keys.duration] = duration
|
||||
it[Keys.topic] = topic
|
||||
@@ -114,7 +114,7 @@ class PreferencesStore(
|
||||
}
|
||||
|
||||
suspend fun resetFirstLaunch() {
|
||||
dataStore.edit { it.remove(Keys.previouslyLaunched) }
|
||||
dataStore.edit { it.remove(Keys.hasStartedTrivia) }
|
||||
}
|
||||
|
||||
suspend fun resetConfiguration() {
|
||||
@@ -135,7 +135,7 @@ class PreferencesStore(
|
||||
|
||||
suspend fun resetFirstLaunchAndConfiguration() {
|
||||
dataStore.edit {
|
||||
it.remove(Keys.previouslyLaunched)
|
||||
it.remove(Keys.hasStartedTrivia)
|
||||
it.remove(Keys.duration)
|
||||
it.remove(Keys.topic)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ class PreferencesStore(
|
||||
}
|
||||
|
||||
private object Keys {
|
||||
val previouslyLaunched = booleanPreferencesKey("previously_launched")
|
||||
val hasStartedTrivia = booleanPreferencesKey("has_started_trivia")
|
||||
val duration = stringPreferencesKey("quiz_duration")
|
||||
val topic = stringPreferencesKey("quiz_topic")
|
||||
val soundEnabled = booleanPreferencesKey("sound_enabled")
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -23,14 +24,22 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -59,12 +68,6 @@ import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ResourceItem
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import berts_atlas.composeapp.generated.resources.Res
|
||||
import io.github.alexzhirkevich.compottie.Compottie
|
||||
import io.github.alexzhirkevich.compottie.LottieCompositionSpec
|
||||
import io.github.alexzhirkevich.compottie.animateLottieCompositionAsState
|
||||
import io.github.alexzhirkevich.compottie.rememberLottieComposition
|
||||
import io.github.alexzhirkevich.compottie.rememberLottiePainter
|
||||
|
||||
private val ScreenShape = RoundedCornerShape(24.dp)
|
||||
private val ControlShape = RoundedCornerShape(16.dp)
|
||||
@@ -77,12 +80,16 @@ fun AtlasScreen(
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(AtlasTheme.colors.parchment),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(AtlasTheme.colors.parchment)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
val contentModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
.fillMaxHeight()
|
||||
.widthIn(max = 720.dp)
|
||||
.padding(contentPadding)
|
||||
.let { if (scrollable) it.verticalScroll(rememberScrollState()) else it }
|
||||
Box(modifier = contentModifier, content = content)
|
||||
@@ -134,12 +141,22 @@ fun AtlasButton(
|
||||
AtlasButtonStyle.Primary, AtlasButtonStyle.Destructive -> colors.white
|
||||
AtlasButtonStyle.Secondary, AtlasButtonStyle.Quiet -> colors.plum
|
||||
}
|
||||
val border = if (style == AtlasButtonStyle.Secondary) Modifier.border(2.dp, colors.atlasBlue, ControlShape) else Modifier
|
||||
val border = when (style) {
|
||||
AtlasButtonStyle.Secondary -> Modifier.border(1.5.dp, colors.atlasBlue.copy(alpha = .75f), ControlShape)
|
||||
AtlasButtonStyle.Destructive -> Modifier.border(1.5.dp, colors.orangeAccent, ControlShape)
|
||||
else -> Modifier
|
||||
}
|
||||
val resolvedBackground = when {
|
||||
!enabled -> colors.outline.copy(alpha = .45f)
|
||||
style == AtlasButtonStyle.Destructive -> colors.paper
|
||||
else -> background
|
||||
}
|
||||
val resolvedForeground = if (style == AtlasButtonStyle.Destructive) colors.error else foreground
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = 52.dp)
|
||||
.then(border)
|
||||
.background(if (enabled) background else colors.outline, ControlShape)
|
||||
.background(resolvedBackground, ControlShape)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
this.contentDescription = contentDescription
|
||||
@@ -149,7 +166,7 @@ fun AtlasButton(
|
||||
.padding(horizontal = 22.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(text, style = AtlasTheme.typography.label, color = foreground, textAlign = TextAlign.Center)
|
||||
AtlasText(text, style = AtlasTheme.typography.label, color = resolvedForeground, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +180,7 @@ fun AtlasCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(AtlasTheme.colors.paper, ScreenShape)
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .45f), ScreenShape)
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .25f), ScreenShape)
|
||||
.then(if (contentDescription == null) Modifier else Modifier.semantics(mergeDescendants = true) { this.contentDescription = contentDescription })
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -180,24 +197,25 @@ fun AtlasChoice(
|
||||
val colors = AtlasTheme.colors
|
||||
val background = when (choice.state) {
|
||||
AnswerState.Available -> colors.paper
|
||||
AnswerState.Selected -> colors.mustard.copy(alpha = .28f)
|
||||
AnswerState.Selected -> colors.seaGlass
|
||||
AnswerState.Correct -> colors.success.copy(alpha = .16f)
|
||||
AnswerState.Incorrect -> colors.error.copy(alpha = .14f)
|
||||
AnswerState.Incorrect -> colors.orangeAccent.copy(alpha = .18f)
|
||||
AnswerState.Eliminated -> colors.outline.copy(alpha = .12f)
|
||||
}
|
||||
val outline = when (choice.state) {
|
||||
AnswerState.Available -> colors.outline
|
||||
AnswerState.Selected -> colors.mustard
|
||||
AnswerState.Correct -> colors.success
|
||||
AnswerState.Incorrect -> colors.error
|
||||
AnswerState.Incorrect -> colors.orangeAccent
|
||||
AnswerState.Eliminated -> colors.outline.copy(alpha = .5f)
|
||||
}
|
||||
val stateText = when (choice.state) {
|
||||
AnswerState.Available -> "Available"
|
||||
AnswerState.Selected -> "Selected"
|
||||
AnswerState.Correct -> "Correct answer"
|
||||
AnswerState.Incorrect -> "Incorrect answer"
|
||||
AnswerState.Eliminated -> "Eliminated"
|
||||
val stateText = when {
|
||||
!choice.enabled && choice.state == AnswerState.Selected -> "Used"
|
||||
choice.state == AnswerState.Available -> "Available"
|
||||
choice.state == AnswerState.Selected -> "Selected"
|
||||
choice.state == AnswerState.Correct -> "Correct answer"
|
||||
choice.state == AnswerState.Incorrect -> "Incorrect answer"
|
||||
else -> "Eliminated"
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -258,12 +276,13 @@ fun FlagImage(
|
||||
),
|
||||
)
|
||||
}
|
||||
val accessibleLabel = if (flag.countryName.startsWith("Flag ", ignoreCase = true)) flag.countryName else "Flag of ${flag.countryName}"
|
||||
val imageModifier = modifier
|
||||
.aspectRatio(3f / 2f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(AtlasTheme.colors.paper, RoundedCornerShape(8.dp))
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .55f), RoundedCornerShape(8.dp))
|
||||
.then(if (accessible) Modifier.semantics { contentDescription = "Flag of ${flag.countryName}" } else Modifier)
|
||||
.then(if (accessible) Modifier.semantics { contentDescription = accessibleLabel } else Modifier)
|
||||
if (validCode) {
|
||||
Image(
|
||||
painter = painterResource(resource),
|
||||
@@ -314,7 +333,7 @@ fun AtlasToggle(setting: SettingToggleUi, onToggle: (String, Boolean) -> Unit) {
|
||||
}
|
||||
Box(
|
||||
Modifier.size(width = 54.dp, height = 32.dp)
|
||||
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.outline, CircleShape)
|
||||
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.seaGlass, CircleShape)
|
||||
.padding(4.dp),
|
||||
contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
) {
|
||||
@@ -351,50 +370,22 @@ fun BertIllustration(
|
||||
StaticBertIllustration(pose, modifier, contentDescription)
|
||||
return
|
||||
}
|
||||
val compositionResult = rememberLottieComposition(pose) {
|
||||
LottieCompositionSpec.JsonString(
|
||||
Res.readBytes("files/${pose.animationFileName}").decodeToString(),
|
||||
val transition = rememberInfiniteTransition(label = "Bert bulb")
|
||||
val rayLength by transition.animateFloat(
|
||||
initialValue = .82f,
|
||||
targetValue = 1.08f,
|
||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||
label = "Bulb ray length",
|
||||
)
|
||||
}
|
||||
val composition = compositionResult.value
|
||||
if (composition == null || compositionResult.isFailure) {
|
||||
StaticBertIllustration(pose, modifier, contentDescription)
|
||||
return
|
||||
}
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = Compottie.IterateForever,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier.size(180.dp).semantics { this.contentDescription = contentDescription },
|
||||
) {
|
||||
StaticBertIllustration(pose, Modifier.matchParentSize(), contentDescription = null)
|
||||
Image(
|
||||
painter = rememberLottiePainter(composition = composition, progress = { progress }),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
StaticBertIllustration(pose, modifier, contentDescription, rayLength)
|
||||
}
|
||||
|
||||
private val BertPose.animationFileName: String
|
||||
get() = when (this) {
|
||||
BertPose.Idle -> "bert_idle.json"
|
||||
BertPose.Correct -> "bert_correct.json"
|
||||
BertPose.Incorrect -> "bert_incorrect.json"
|
||||
BertPose.Overtime -> "bert_overtime.json"
|
||||
BertPose.Hint -> "bert_hint.json"
|
||||
BertPose.Fact -> "bert_fact.json"
|
||||
BertPose.Results -> "bert_results.json"
|
||||
BertPose.Streak -> "bert_streak.json"
|
||||
BertPose.Stamp -> "bert_stamp.json"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaticBertIllustration(
|
||||
pose: BertPose,
|
||||
modifier: Modifier,
|
||||
contentDescription: String?,
|
||||
rayLength: Float = 1f,
|
||||
) {
|
||||
val colors = AtlasTheme.colors
|
||||
val accessibleModifier = if (contentDescription == null) Modifier else Modifier.semantics { this.contentDescription = contentDescription }
|
||||
@@ -437,24 +428,75 @@ private fun StaticBertIllustration(
|
||||
quadraticTo(90f * scale, (if (pose == BertPose.Incorrect) 106f else 130f) * scale, 109f * scale, mouthY * scale)
|
||||
}
|
||||
drawPath(mouth, colors.plum, style = Stroke(4f * scale, cap = StrokeCap.Round))
|
||||
val accent = when (pose) {
|
||||
BertPose.Correct, BertPose.Streak, BertPose.Stamp -> colors.success
|
||||
BertPose.Incorrect -> colors.error
|
||||
BertPose.Overtime, BertPose.Hint -> colors.mustard
|
||||
BertPose.Fact, BertPose.Results, BertPose.Idle -> colors.atlasBlue
|
||||
}
|
||||
val accent = colors.orangeAccent
|
||||
drawCircle(accent, 10f * scale, p(145f, 39f))
|
||||
drawLine(accent, p(145f, 17f), p(145f, 7f), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(161f, 25f), p(169f, 18f), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(145f, 17f), p(145f, 17f - 10f * rayLength), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(161f, 25f), p(161f + 8f * rayLength, 25f - 7f * rayLength), 4f * scale, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenTopBar(title: String, onBack: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (onBack != null) AtlasButton("Back", onBack, style = AtlasButtonStyle.Quiet)
|
||||
AtlasText(title, Modifier.weight(1f).semantics { heading() }, style = AtlasTheme.typography.heading)
|
||||
trailing?.invoke()
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 52.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (onBack != null) AtlasBackButton(onBack) else Spacer(Modifier.size(48.dp))
|
||||
AtlasText(
|
||||
title,
|
||||
Modifier.weight(1f).semantics { heading() },
|
||||
style = AtlasTheme.typography.heading,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (trailing == null) Spacer(Modifier.size(48.dp)) else Box(Modifier.widthIn(min = 48.dp), contentAlignment = Alignment.CenterEnd) { trailing() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasBackButton(onClick: () -> Unit, contentDescription: String = "Back") {
|
||||
val color = AtlasTheme.colors.plum
|
||||
Box(
|
||||
Modifier.size(48.dp)
|
||||
.semantics { role = Role.Button; this.contentDescription = contentDescription }
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(24.dp)) {
|
||||
drawLine(color, Offset(size.width * .7f, size.height * .16f), Offset(size.width * .3f, size.height * .5f), 3.2.dp.toPx(), StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .3f, size.height * .5f), Offset(size.width * .7f, size.height * .84f), 3.2.dp.toPx(), StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasExitButton(onClick: () -> Unit, contentDescription: String = "Leave trivia") {
|
||||
val color = AtlasTheme.colors.plum
|
||||
Box(
|
||||
Modifier.size(48.dp)
|
||||
.semantics { role = Role.Button; this.contentDescription = contentDescription }
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(26.dp)) {
|
||||
val stroke = 2.6.dp.toPx()
|
||||
drawLine(color, Offset(size.width * .18f, size.height * .12f), Offset(size.width * .18f, size.height * .88f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .18f, size.height * .12f), Offset(size.width * .56f, size.height * .12f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .18f, size.height * .88f), Offset(size.width * .56f, size.height * .88f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .48f, size.height * .5f), Offset(size.width * .9f, size.height * .5f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .73f, size.height * .32f), Offset(size.width * .9f, size.height * .5f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .9f, size.height * .5f), Offset(size.width * .73f, size.height * .68f), stroke, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasLinkRow(text: String, onClick: () -> Unit, destructive: Boolean = false) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().heightIn(min = 56.dp)
|
||||
.semantics { role = Role.Button; contentDescription = text }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AtlasText(text, Modifier.weight(1f), style = AtlasTheme.typography.label, color = if (destructive) AtlasTheme.colors.error else AtlasTheme.colors.plum)
|
||||
AtlasText("›", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ data class AtlasColors(
|
||||
val mutedPlum: Color,
|
||||
val atlasBlue: Color,
|
||||
val atlasBluePressed: Color,
|
||||
val seaGlass: Color,
|
||||
val orangeAccent: Color,
|
||||
val coral: Color,
|
||||
val mustard: Color,
|
||||
val success: Color,
|
||||
@@ -42,19 +44,30 @@ data class AtlasTypography(
|
||||
val timer: TextStyle,
|
||||
)
|
||||
|
||||
private val AtlasDeepGreen = Color(0xFF27635B)
|
||||
private val AtlasBlueGray = Color(0xFF778BA5)
|
||||
private val AtlasSeaGlass = Color(0xFFB7CFC8)
|
||||
private val AtlasShell = Color(0xFFF6E9E1)
|
||||
private val AtlasSand = Color(0xFFF1DCC7)
|
||||
private val AtlasPink = Color(0xFFF7BFBE)
|
||||
private val AtlasPeach = Color(0xFFE9AF8B)
|
||||
private val AtlasOrange = Color(0xFFE1814E)
|
||||
|
||||
private val LightAtlasColors = AtlasColors(
|
||||
parchment = Color(0xFFF7F0DF),
|
||||
paper = Color(0xFFFFFBF2),
|
||||
plum = Color(0xFF3E2447),
|
||||
mutedPlum = Color(0xFF69536F),
|
||||
atlasBlue = Color(0xFF17647E),
|
||||
atlasBluePressed = Color(0xFF0E4B60),
|
||||
coral = Color(0xFFE96E5B),
|
||||
mustard = Color(0xFFE3AD35),
|
||||
success = Color(0xFF147568),
|
||||
error = Color(0xFF9C3E32),
|
||||
outline = Color(0xFF8A748E),
|
||||
white = Color(0xFFFFFFFF),
|
||||
parchment = AtlasShell,
|
||||
paper = AtlasSand,
|
||||
plum = AtlasDeepGreen,
|
||||
mutedPlum = AtlasDeepGreen,
|
||||
atlasBlue = AtlasDeepGreen,
|
||||
atlasBluePressed = AtlasDeepGreen,
|
||||
seaGlass = AtlasSeaGlass,
|
||||
orangeAccent = AtlasOrange,
|
||||
coral = AtlasPink,
|
||||
mustard = AtlasPeach,
|
||||
success = AtlasDeepGreen,
|
||||
error = AtlasDeepGreen,
|
||||
outline = AtlasBlueGray,
|
||||
white = AtlasShell,
|
||||
)
|
||||
|
||||
private fun atlasTypography(fontFamily: FontFamily) = AtlasTypography(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -8,10 +10,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -21,6 +31,8 @@ fun HomeScreen(
|
||||
isFirstLaunch: Boolean,
|
||||
streakDays: Int = 0,
|
||||
progressSummary: String = "",
|
||||
totalPoints: Int = 0,
|
||||
hasStartedTrivia: Boolean = !isFirstLaunch,
|
||||
onStart: () -> Unit,
|
||||
onOptions: () -> Unit = {},
|
||||
onProgress: () -> Unit = {},
|
||||
@@ -31,9 +43,16 @@ fun HomeScreen(
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
BertIllustration(BertPose.Idle, contentDescription = "Bert smiles behind oversized glasses")
|
||||
if (hasStartedTrivia) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
PointsBadge(totalPoints, Modifier.weight(1f))
|
||||
SettingsIconButton(onSettings)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(if (hasStartedTrivia) 8.dp else 24.dp))
|
||||
HomeBertIllustration(large = !hasStartedTrivia)
|
||||
AtlasText(
|
||||
"Bert's Atlas",
|
||||
Modifier.semantics { heading() },
|
||||
@@ -41,21 +60,85 @@ fun HomeScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AtlasText("Getting nerdier every day.", color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
AtlasButton("Start", onStart, Modifier.fillMaxWidth().widthIn(max = 420.dp))
|
||||
if (!isFirstLaunch) {
|
||||
if (hasStartedTrivia) {
|
||||
AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary)
|
||||
SectionGap()
|
||||
AtlasCard(Modifier.widthIn(max = 420.dp), "Current streak: $streakDays days. $progressSummary") {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
AtlasText("$streakDays", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
|
||||
AtlasText("✦", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
AtlasText(if (streakDays == 1) "day curious" else "days curious", style = AtlasTheme.typography.label)
|
||||
AtlasText(if (streakDays == 1) "1 curious day" else "$streakDays curious days", style = AtlasTheme.typography.label)
|
||||
AtlasText(progressSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
AtlasButton("Progress", onProgress, style = AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
AtlasButton("Settings", onSettings, style = AtlasButtonStyle.Quiet)
|
||||
AtlasButton("See your progress", onProgress, style = AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeBertIllustration(large: Boolean) {
|
||||
val size = if (large) 270.dp else 200.dp
|
||||
val colors = AtlasTheme.colors
|
||||
androidx.compose.foundation.layout.Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
BertIllustration(BertPose.Idle, Modifier.fillMaxWidth(), contentDescription = "Bert smiles with a bright idea and a folded map")
|
||||
Canvas(Modifier.fillMaxWidth().size(size)) {
|
||||
val w = this.size.width
|
||||
val h = this.size.height
|
||||
val outline = 4.dp.toPx()
|
||||
val map = Path().apply {
|
||||
moveTo(w * .04f, h * .68f)
|
||||
lineTo(w * .14f, h * .63f)
|
||||
lineTo(w * .25f, h * .68f)
|
||||
lineTo(w * .36f, h * .63f)
|
||||
lineTo(w * .36f, h * .88f)
|
||||
lineTo(w * .25f, h * .93f)
|
||||
lineTo(w * .14f, h * .88f)
|
||||
lineTo(w * .04f, h * .93f)
|
||||
close()
|
||||
}
|
||||
drawPath(map, colors.paper)
|
||||
drawPath(map, colors.plum, style = Stroke(outline, join = androidx.compose.ui.graphics.StrokeJoin.Round))
|
||||
drawLine(colors.plum, Offset(w * .14f, h * .63f), Offset(w * .14f, h * .88f), outline)
|
||||
drawLine(colors.plum, Offset(w * .25f, h * .68f), Offset(w * .25f, h * .93f), outline)
|
||||
drawLine(colors.orangeAccent, Offset(w * .06f, h * .83f), Offset(w * .34f, h * .76f), outline * .7f, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PointsBadge(points: Int, modifier: Modifier = Modifier) {
|
||||
val colors = AtlasTheme.colors
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Canvas(Modifier.size(24.dp)) {
|
||||
drawCircle(colors.mustard, size.minDimension * .40f)
|
||||
drawCircle(colors.plum, size.minDimension * .40f, style = Stroke(2.4.dp.toPx()))
|
||||
drawCircle(colors.plum, size.minDimension * .10f)
|
||||
}
|
||||
AtlasText(points.toString(), style = AtlasTheme.typography.heading, color = colors.plum)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsIconButton(onClick: () -> Unit) {
|
||||
val color = AtlasTheme.colors.plum
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.size(48.dp).semantics { role = Role.Button; contentDescription = "Open settings" }.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(26.dp)) {
|
||||
val center = Offset(size.width / 2, size.height / 2)
|
||||
drawCircle(color, size.minDimension * .27f, center, style = Stroke(3.dp.toPx()))
|
||||
drawCircle(color, size.minDimension * .07f, center)
|
||||
repeat(8) { index ->
|
||||
val angle = index * kotlin.math.PI.toFloat() / 4f
|
||||
val start = Offset(center.x + kotlin.math.cos(angle) * size.width * .31f, center.y + kotlin.math.sin(angle) * size.height * .31f)
|
||||
val end = Offset(center.x + kotlin.math.cos(angle) * size.width * .43f, center.y + kotlin.math.sin(angle) * size.height * .43f)
|
||||
drawLine(color, start, end, 3.dp.toPx(), StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +203,7 @@ fun RecoveryScreen(
|
||||
AtlasText(questionSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
AtlasButton("Resume run", onResume, Modifier.fillMaxWidth())
|
||||
AtlasButton("Leave this run", onAbandon, Modifier.fillMaxWidth(), AtlasButtonStyle.Quiet)
|
||||
AtlasButton("Leave this trivia run", onAbandon, Modifier.fillMaxWidth(), AtlasButtonStyle.Quiet)
|
||||
AtlasText("Answered questions still count toward what Bert revisits next.", style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -31,12 +33,12 @@ fun QuizScreen(
|
||||
prompt: String,
|
||||
imageDescription: String?,
|
||||
score: Int,
|
||||
sessionTime: String,
|
||||
questionTime: String,
|
||||
timerExpired: Boolean,
|
||||
choices: List<ChoiceUi> = emptyList(),
|
||||
letterSlots: List<LetterSlotUi> = emptyList(),
|
||||
letterTiles: List<LetterTileUi> = emptyList(),
|
||||
appliedHintMessages: List<String> = emptyList(),
|
||||
hintAvailable: Boolean,
|
||||
unlimited: Boolean,
|
||||
canSubmit: Boolean = false,
|
||||
@@ -50,46 +52,46 @@ fun QuizScreen(
|
||||
onLeave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
promptFlag: FlagUi? = null,
|
||||
runProgress: Float? = null,
|
||||
runProgressDescription: String = "Trivia progress",
|
||||
overtimeMessage: String = "Time's up. Keep going for half points",
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth < 480.dp) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Leave", onLeave, style = AtlasButtonStyle.Quiet)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasScreen(modifier, contentPadding = PaddingValues(0.dp)) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
runProgress?.let { SubtleRunProgress(it, runProgressDescription) }
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric(if (unlimited) "Elapsed" else "Run", sessionTime, Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Leave", onLeave, style = AtlasButtonStyle.Quiet)
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric(if (unlimited) "Elapsed" else "Run", sessionTime, Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
}
|
||||
}
|
||||
AtlasExitButton(onLeave)
|
||||
}
|
||||
if (timerExpired) {
|
||||
AtlasText("Overtime · you can still answer for half points", color = AtlasTheme.colors.error, style = AtlasTheme.typography.label)
|
||||
AtlasText(overtimeMessage, Modifier.fillMaxWidth(), color = AtlasTheme.colors.error, style = AtlasTheme.typography.label, textAlign = TextAlign.Center)
|
||||
}
|
||||
if (promptFlag != null || imageDescription != null) {
|
||||
AtlasCard(contentDescription = imageDescription ?: "Flag of ${promptFlag?.countryName}") {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 116.dp), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().heightIn(min = 148.dp)
|
||||
.semantics { contentDescription = imageDescription ?: "Flag to identify" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.width(230.dp).padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
if (promptFlag != null) {
|
||||
FlagImage(promptFlag, Modifier.fillMaxWidth().heightIn(min = 116.dp))
|
||||
FlagImage(promptFlag, Modifier.fillMaxWidth(), accessible = false, showFallbackLabel = false)
|
||||
} else {
|
||||
AtlasText("⚑", style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
|
||||
}
|
||||
}
|
||||
imageDescription?.let {
|
||||
AtlasText(it, Modifier.fillMaxWidth(), style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
AtlasHeading(prompt, Modifier.fillMaxWidth(), centered = true)
|
||||
appliedHintMessages.forEach { message ->
|
||||
AtlasCard(contentDescription = "Applied hint: $message") {
|
||||
AtlasText("Hint: $message", color = AtlasTheme.colors.atlasBlue)
|
||||
}
|
||||
}
|
||||
AtlasHeading(prompt)
|
||||
if (choices.isNotEmpty()) ResponsiveChoices(choices, onChoice)
|
||||
if (letterSlots.isNotEmpty() || letterTiles.isNotEmpty()) {
|
||||
LetterBank(letterSlots, letterTiles, onLetter, onRemoveLetter)
|
||||
@@ -99,21 +101,42 @@ fun QuizScreen(
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Hint", onHint, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = hintAvailable)
|
||||
AtlasButton("Get a hint", onHint, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = hintAvailable)
|
||||
if (unlimited) AtlasButton("Finish", onFinish, Modifier.weight(1f), AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuizMetric(label: String, value: String, modifier: Modifier, warning: Boolean = false) {
|
||||
Column(modifier.semantics(mergeDescendants = true) { contentDescription = "$label: $value" }, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
AtlasText(value, style = AtlasTheme.typography.timer, color = if (warning) AtlasTheme.colors.error else AtlasTheme.colors.plum, textAlign = TextAlign.Center)
|
||||
Column(
|
||||
modifier.semantics(mergeDescendants = true) { contentDescription = "$label: $value" },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AtlasText(
|
||||
value,
|
||||
style = AtlasTheme.typography.timer,
|
||||
color = if (warning) AtlasTheme.colors.error else AtlasTheme.colors.plum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AtlasText(label, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubtleRunProgress(progress: Float, description: String) {
|
||||
val value = progress.coerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().heightIn(min = 4.dp)
|
||||
.background(AtlasTheme.colors.outline.copy(alpha = .14f), RoundedCornerShape(4.dp))
|
||||
.semantics { contentDescription = description; stateDescription = "${(value * 100).toInt()} percent" },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(value).heightIn(min = 4.dp).background(AtlasTheme.colors.coral, RoundedCornerShape(4.dp)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResponsiveChoices(choices: List<ChoiceUi>, onChoice: (String) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
@@ -144,27 +167,51 @@ private fun LetterBank(
|
||||
val slot = slots[index]
|
||||
val label = slot.character ?: "blank"
|
||||
Box(
|
||||
Modifier.size(52.dp).background(AtlasTheme.colors.paper, RoundedCornerShape(12.dp))
|
||||
.border(2.dp, AtlasTheme.colors.atlasBlue, RoundedCornerShape(12.dp))
|
||||
Modifier.size(48.dp)
|
||||
.semantics { contentDescription = "Answer position ${slot.position + 1}: $label" }
|
||||
.clickable(enabled = slot.tileId != null && !slot.fixed) { slot.tileId?.let(onRemoveLetter) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AtlasText(slot.character ?: "_", style = AtlasTheme.typography.heading) }
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(38.dp)
|
||||
.background(AtlasTheme.colors.paper, RoundedCornerShape(10.dp))
|
||||
.border(2.dp, AtlasTheme.colors.atlasBlue, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(slot.character ?: "_", style = AtlasTheme.typography.heading)
|
||||
}
|
||||
}
|
||||
}
|
||||
WrappingCells(tiles.size) { index ->
|
||||
val tile = tiles[index]
|
||||
val available = tile.enabled && tile.placedSlot == null
|
||||
Box(
|
||||
Modifier.size(52.dp).background(if (available) AtlasTheme.colors.mustard.copy(alpha = .3f) else AtlasTheme.colors.outline.copy(alpha = .12f), RoundedCornerShape(12.dp))
|
||||
.border(1.dp, AtlasTheme.colors.outline, RoundedCornerShape(12.dp))
|
||||
Modifier.size(48.dp)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
contentDescription = "Letter ${tile.letter}"
|
||||
stateDescription = if (available) "Available" else "Placed"
|
||||
stateDescription = when {
|
||||
available -> "Available"
|
||||
!tile.enabled -> "Removed"
|
||||
else -> "Placed"
|
||||
}
|
||||
}
|
||||
.clickable(enabled = available) { onLetter(tile.id) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AtlasText(tile.letter, style = AtlasTheme.typography.label) }
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(44.dp)
|
||||
.background(
|
||||
if (available) AtlasTheme.colors.mustard.copy(alpha = .3f)
|
||||
else AtlasTheme.colors.outline.copy(alpha = .12f),
|
||||
RoundedCornerShape(11.dp),
|
||||
)
|
||||
.border(1.dp, AtlasTheme.colors.outline, RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(tile.letter, style = AtlasTheme.typography.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +219,7 @@ private fun LetterBank(
|
||||
@Composable
|
||||
private fun WrappingCells(count: Int, content: @Composable (Int) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val columns = (maxWidth / 60.dp).toInt().coerceAtLeast(1)
|
||||
val columns = (maxWidth / 56.dp).toInt().coerceAtLeast(1)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(0 until count).chunked(columns).forEach { indices ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -190,15 +237,17 @@ fun HintChooserScreen(
|
||||
onChooseHint: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
introText: String = "Choose the clue that sounds most useful.",
|
||||
chargedHintLabel: String = "−25 points",
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ScreenTopBar("Choose a hint", onCancel)
|
||||
BertIllustration(BertPose.Hint)
|
||||
BertIllustration(BertPose.Hint, Modifier.size(150.dp))
|
||||
AtlasHeading("A little nudge?", Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText("Every hint costs up to 25 points. Your score can never drop below zero.", textAlign = TextAlign.Center)
|
||||
AtlasCard(contentDescription = "Current score $score. Score after hint ${maxOf(0, score - 25)}") {
|
||||
StatRow(listOf(StatUi("Now", score.toString()), StatUi("After hint", maxOf(0, score - 25).toString())))
|
||||
AtlasText(introText, textAlign = TextAlign.Center, color = AtlasTheme.colors.mutedPlum)
|
||||
if (score > 0) {
|
||||
AtlasText(chargedHintLabel, style = AtlasTheme.typography.label, color = AtlasTheme.colors.outline, textAlign = TextAlign.Center)
|
||||
}
|
||||
hints.forEach { AtlasChoice(it, onChooseHint) }
|
||||
}
|
||||
@@ -217,12 +266,14 @@ fun FeedbackScreen(
|
||||
autoAdvanceMessage: String?,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
continueLabel: String = "Continue",
|
||||
) {
|
||||
val pose = when { overtime && correct -> BertPose.Overtime; correct -> BertPose.Correct; else -> BertPose.Incorrect }
|
||||
val status = when { overtime && correct -> "Overtime correct"; correct -> "Correct"; else -> "Incorrect" }
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BertIllustration(pose, contentDescription = "Bert reacts: $status")
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Spacer(Modifier.heightIn(min = 8.dp))
|
||||
BertIllustration(pose, Modifier.size(190.dp), contentDescription = "Bert reacts: $status")
|
||||
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText(
|
||||
if (correct) "+$pointsAwarded points" else "No points this time",
|
||||
@@ -235,7 +286,7 @@ fun FeedbackScreen(
|
||||
explanation?.let { AtlasText(it) }
|
||||
}
|
||||
if (overtime && correct) AtlasText("The clock gave up. You didn't. Half points.", textAlign = TextAlign.Center)
|
||||
AtlasButton("Continue", onContinue, Modifier.fillMaxWidth())
|
||||
AtlasButton(continueLabel, onContinue, Modifier.fillMaxWidth())
|
||||
autoAdvanceMessage?.let { AtlasText(it, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center) }
|
||||
}
|
||||
}
|
||||
@@ -249,14 +300,39 @@ fun FunFactScreen(
|
||||
artDescription: String? = null,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
kicker: String = "Fun fact about",
|
||||
continueLabel: String = "Continue",
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Fact, contentDescription = "Bert has found an oddly specific fact")
|
||||
AtlasText("FIELD NOTE · $relatedTo", style = AtlasTheme.typography.label, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasText("${kicker} $relatedTo", style = AtlasTheme.typography.label, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasCard(contentDescription = artDescription) { AtlasText(fact, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center) }
|
||||
AtlasButton("Back to the atlas", onContinue, Modifier.fillMaxWidth())
|
||||
AtlasButton(continueLabel, onContinue, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveQuizConfirmationScreen(
|
||||
onCancel: () -> Unit,
|
||||
onLeave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Leave this trivia run?",
|
||||
body: String = "Your answered questions will stay in your practice progress. Ending now won't count toward your personal best or daily streak.",
|
||||
) {
|
||||
AtlasScreen(modifier, scrollable = false) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BertIllustration(BertPose.Hint, Modifier.size(160.dp), contentDescription = "Bert keeps your practice progress safe")
|
||||
AtlasHeading(title, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText(body, Modifier.fillMaxWidth(), color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
AtlasButton("Keep playing", onCancel, Modifier.fillMaxWidth())
|
||||
AtlasButton("Leave trivia", onLeave, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,28 @@ fun ResultsScreen(
|
||||
onPlayAgain: () -> Unit,
|
||||
onHome: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
abandoned: Boolean = false,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Results, contentDescription = "Bert celebrates a completed atlas run")
|
||||
AtlasHeading(if (newPersonalBest) "New personal best" else "Run complete", Modifier.fillMaxWidth(), centered = true)
|
||||
BertIllustration(if (abandoned) BertPose.Hint else BertPose.Results, contentDescription = if (abandoned) "Bert keeps your practice progress" else "Bert celebrates completed trivia")
|
||||
AtlasHeading(
|
||||
when {
|
||||
abandoned -> "Trivia ended"
|
||||
newPersonalBest -> "New personal best"
|
||||
else -> "Trivia complete"
|
||||
},
|
||||
Modifier.fillMaxWidth(),
|
||||
centered = true,
|
||||
)
|
||||
if (abandoned) {
|
||||
AtlasText(
|
||||
"Your answers still shaped what you'll practise next. This trivia run didn't count toward your personal best or daily streak.",
|
||||
Modifier.fillMaxWidth(),
|
||||
color = AtlasTheme.colors.mutedPlum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
AtlasText(score.toString(), style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasText("points", color = AtlasTheme.colors.mutedPlum)
|
||||
StatRow(
|
||||
@@ -46,7 +63,7 @@ fun ResultsScreen(
|
||||
),
|
||||
)
|
||||
AtlasCard {
|
||||
AtlasText("Run notes", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Highlights", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Average response · $averageResponseTime")
|
||||
AtlasText("New or improved knowledge · $masteryImproved")
|
||||
}
|
||||
@@ -56,7 +73,7 @@ fun ResultsScreen(
|
||||
AtlasText(revisitCountries.joinToString(" · "), color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
AtlasButton("Play again", onPlayAgain, Modifier.fillMaxWidth())
|
||||
AtlasButton(if (abandoned) "Start new trivia" else "Play again", onPlayAgain, Modifier.fillMaxWidth())
|
||||
AtlasButton("Home", onHome, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,15 @@ fun SettingsScreen(
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ScreenTopBar("Settings", onBack)
|
||||
SectionGap()
|
||||
AtlasCard {
|
||||
settings.forEach { AtlasToggle(it, onToggle) }
|
||||
}
|
||||
AtlasButton("Privacy", onPrivacy, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
AtlasButton("Credits and licenses", onCredits, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
AtlasButton("Reset local data", onReset, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
|
||||
AtlasCard {
|
||||
AtlasLinkRow("Privacy", onPrivacy)
|
||||
AtlasLinkRow("Credits and licenses", onCredits)
|
||||
AtlasLinkRow("Reset local data", onReset, destructive = true)
|
||||
}
|
||||
AtlasText(
|
||||
"Bert's Atlas $version",
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -84,7 +87,7 @@ fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
title = "Privacy",
|
||||
paragraphs = listOf(
|
||||
"Bert's Atlas works entirely offline. It does not ask for an account or personal information.",
|
||||
"Your quiz history, progress, streaks, stamps, and preferences stay on this device. The app has no advertising, analytics, remote configuration, or product telemetry.",
|
||||
"Your trivia history, progress, streaks, stamps, and preferences stay on this device. The app has no advertising, analytics, remote configuration, or product telemetry.",
|
||||
"Clearing app data or deleting the app may permanently remove your progress. Operating-system and store diagnostics are controlled by your platform settings.",
|
||||
),
|
||||
onBack = onBack,
|
||||
|
||||
@@ -16,6 +16,10 @@ The initial draft was normalized from datasets/country-codes at commit `6a595f1a
|
||||
|
||||
The aggregate commit is a reproducibility aid, not an editorial signoff. Capital arrangements with ambiguity or recent change are explicitly marked and removed from one-answer question eligibility.
|
||||
|
||||
Land-border draft facts use the REST Countries V4 data pinned at commit `43f6260db10c54df9972e6e554ee14c2e7c85ef5`. These are concise fallback copy, not inferred lessons about UN regions. Editors must check disputed boundaries and whether overseas territories affect a claim before approval.
|
||||
|
||||
The early-play pool has twelve more varied draft facts sourced directly from public institutions: Geoscience Australia, Brazil's IBGE, the governments of Canada, India, Japan, Mexico, South Africa, New Zealand, Nepal and the Netherlands, Royal Museums Greenwich, and the archived CIA World Factbook. Their exact page URLs and retrieval snapshots are recorded in the catalog. A source link proves provenance; it does not constitute fact or copy approval.
|
||||
|
||||
Primary review locations:
|
||||
|
||||
- United Nations member states: https://www.un.org/en/about-us/member-states
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
|
||||
<title id="title">Bert's Atlas mark</title>
|
||||
<desc id="description">Smiling Bert in green glasses with a small folded paper map at his top right.</desc>
|
||||
<rect width="512" height="512" rx="112" fill="#F6E9E1"/>
|
||||
<g transform="translate(55 85) scale(.66)">
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" stroke-linejoin="round" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<path fill="none" stroke="#E1814E" stroke-width="10" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><path stroke="#27635B" stroke-width="19" stroke-linecap="round" d="M245 245h23"/><circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/><path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
</g>
|
||||
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round">
|
||||
<path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/>
|
||||
<path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/><path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/><path fill="none" d="M332 91v91m28-78v91m30-104v91"/><path fill="none" stroke="#E1814E" stroke-width="7" stroke-linecap="round" d="M308 157c20-18 28 7 46-10 16-14 21-29 38-12 10 12 14 18 22 15"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bert's Atlas splash mark">
|
||||
<rect width="512" height="512" fill="#F6E9E1"/>
|
||||
<g transform="translate(55 85) scale(.66)">
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" stroke-linejoin="round" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/><rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><path stroke="#27635B" stroke-width="19" d="M245 245h23"/><circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/><path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
</g>
|
||||
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round"><path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/><path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/><path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/><path fill="none" d="M332 91v91m28-78v91m30-104v91"/><path fill="none" stroke="#E1814E" stroke-width="7" stroke-linecap="round" d="M308 157c20-18 28 7 46-10 16-14 21-29 38-12 10 12 14 18 22 15"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,24 @@
|
||||
# Feedback voice recommendations
|
||||
|
||||
Duolingo's public product writing consistently puts the answer first, keeps the immediate interaction small, offers explanation as a second layer, and makes the end of a session encouraging while still naming what needs practice. That pattern is useful here, but Bert's Atlas should keep an original travel-and-curiosity voice rather than imitate Duolingo's phrases.
|
||||
|
||||
Research basis:
|
||||
|
||||
- Duolingo, “Explain My Answer is now free for all learners”: https://blog.duolingo.com/explain-my-answer-now-free/
|
||||
- Duolingo, “Introducing Duolingo Flashcards”: https://blog.duolingo.com/duolingo-flashcards/
|
||||
- Duolingo 101: https://blog.duolingo.com/duolingo-101-how-to-learn-a-language-on-duolingo/
|
||||
- Duolingo, “How we improved the streak”: https://blog.duolingo.com/improving-the-streak/
|
||||
|
||||
Recommended original patterns:
|
||||
|
||||
- Correct: “Flag filed. Nice work.”
|
||||
- Incorrect: “That was {answer}. Look for {short distinction}.”
|
||||
- Overtime: “Time's up. You still found it.”
|
||||
- Continent hint: “Map clue: {continent}.”
|
||||
- Choice hint: “Two decoys are leaving.”
|
||||
- Fact hint: “Bert's clue: {answer-safe fact}.”
|
||||
- Results: “Atlas packed: {score} points · {accuracy}%.”
|
||||
- Review: “Worth another look: {countries}.”
|
||||
- Streak: “{days} curious days in a row.”
|
||||
|
||||
Keep feedback to one idea per line. Name the correct answer after a miss, but never include it in a pre-answer clue. Avoid shame, exaggerated celebration, and claims that a streak proves mastery.
|
||||
@@ -55,8 +55,8 @@ data class PlayerSettings(
|
||||
)
|
||||
|
||||
interface LaunchPreferences {
|
||||
suspend fun hasLaunchedBefore(): Boolean
|
||||
suspend fun markLaunched()
|
||||
suspend fun hasStartedTrivia(): Boolean
|
||||
suspend fun markTriviaStarted()
|
||||
}
|
||||
|
||||
interface QuizConfigurationPreferences {
|
||||
|
||||
@@ -27,10 +27,22 @@ class QuestionSelector(
|
||||
val topicQuestions = questions.filter { matchesTopic(it, topic) }
|
||||
require(topicQuestions.isNotEmpty()) { "No questions are available for $topic" }
|
||||
|
||||
val withoutRecentFive = topicQuestions.filter { it.id !in recentQuestionIds.takeLast(5) }
|
||||
val withoutImmediateRepeat = topicQuestions.filter { it.id != recentQuestionIds.lastOrNull() }
|
||||
val priorQuestionIds = recentQuestionIds.takeLast(RECENT_QUESTION_WINDOW).toSet()
|
||||
val priorQuestions = questions.filter { it.id in priorQuestionIds }
|
||||
val immediateQuestion = questions.firstOrNull { it.id == recentQuestionIds.lastOrNull() }
|
||||
val withoutRecentConcepts = topicQuestions.filter { candidate ->
|
||||
candidate.id !in priorQuestionIds && priorQuestions.none { recent -> candidate.repeatsConceptFrom(recent) }
|
||||
}
|
||||
val withoutImmediateConcept = topicQuestions.filter { candidate ->
|
||||
candidate.id != immediateQuestion?.id &&
|
||||
(immediateQuestion == null || !candidate.repeatsConceptFrom(immediateQuestion))
|
||||
}
|
||||
val withoutPriorQuestions = topicQuestions.filter { it.id !in priorQuestionIds }
|
||||
val withoutImmediateRepeat = topicQuestions.filter { it.id != immediateQuestion?.id }
|
||||
val candidates = when {
|
||||
withoutRecentFive.isNotEmpty() -> withoutRecentFive
|
||||
withoutRecentConcepts.isNotEmpty() -> withoutRecentConcepts
|
||||
withoutImmediateConcept.isNotEmpty() -> withoutImmediateConcept
|
||||
withoutPriorQuestions.isNotEmpty() -> withoutPriorQuestions
|
||||
withoutImmediateRepeat.isNotEmpty() -> withoutImmediateRepeat
|
||||
else -> topicQuestions
|
||||
}
|
||||
@@ -41,6 +53,24 @@ class QuestionSelector(
|
||||
return SelectedQuestion(weighted[random.value], random.nextState)
|
||||
}
|
||||
|
||||
private fun Question.repeatsConceptFrom(other: Question): Boolean {
|
||||
if (countryIds.none { it in other.countryIds }) return false
|
||||
val family = skill.selectionFamily()
|
||||
return family != SelectionFamily.Mixed && family == other.skill.selectionFamily()
|
||||
}
|
||||
|
||||
private fun Skill.selectionFamily(): SelectionFamily = when (this) {
|
||||
Skill.FlagRecognition,
|
||||
Skill.CountryToFlag,
|
||||
-> SelectionFamily.Flag
|
||||
Skill.CapitalRecall,
|
||||
Skill.CapitalRecognition,
|
||||
-> SelectionFamily.Capital
|
||||
Skill.MixedGeography,
|
||||
Skill.FlagSymbolism,
|
||||
-> SelectionFamily.Mixed
|
||||
}
|
||||
|
||||
private fun matchesTopic(question: Question, topic: QuizTopic): Boolean = when (topic) {
|
||||
QuizTopic.MostlyFlags -> true
|
||||
QuizTopic.Capitals -> question.skill == Skill.CapitalRecall ||
|
||||
@@ -71,14 +101,14 @@ class QuestionSelector(
|
||||
}
|
||||
val topicWeight = when (topic) {
|
||||
QuizTopic.MostlyFlags -> when (question.skill) {
|
||||
Skill.FlagRecognition -> 4
|
||||
Skill.CountryToFlag -> 2
|
||||
Skill.FlagRecognition -> 8
|
||||
Skill.CountryToFlag -> 6
|
||||
Skill.CapitalRecall,
|
||||
Skill.CapitalRecognition,
|
||||
-> 8
|
||||
-> 1
|
||||
Skill.MixedGeography,
|
||||
Skill.FlagSymbolism,
|
||||
-> 8
|
||||
-> 2
|
||||
}
|
||||
QuizTopic.Capitals -> 1
|
||||
QuizTopic.MixedGeography -> when (question.skill) {
|
||||
@@ -96,3 +126,11 @@ class QuestionSelector(
|
||||
return (difficultyWeight + reviewWeight) * topicWeight
|
||||
}
|
||||
}
|
||||
|
||||
private const val RECENT_QUESTION_WINDOW = 5
|
||||
|
||||
private enum class SelectionFamily {
|
||||
Flag,
|
||||
Capital,
|
||||
Mixed,
|
||||
}
|
||||
@@ -107,13 +107,20 @@ class QuizEngine(
|
||||
require(state.sessionRemainingMillis == null || state.sessionRemainingMillis >= 0) {
|
||||
"A snapshot has an invalid session timer"
|
||||
}
|
||||
val completionPending = state.runCompletionPending ||
|
||||
state.sessionExpired ||
|
||||
(state.configuration.duration != QuizDuration.Unlimited && state.sessionRemainingMillis == 0L)
|
||||
if (state.phase == RunPhase.Paused) {
|
||||
require(state.pausedFromPhase in PAUSABLE_PHASES) { "A paused snapshot has no resumable phase" }
|
||||
return state.copy(tickingSinceMillis = null)
|
||||
return state.copy(
|
||||
runCompletionPending = completionPending,
|
||||
tickingSinceMillis = null,
|
||||
)
|
||||
}
|
||||
return state.copy(
|
||||
phase = RunPhase.Paused,
|
||||
pausedFromPhase = state.phase,
|
||||
runCompletionPending = completionPending,
|
||||
tickingSinceMillis = null,
|
||||
)
|
||||
}
|
||||
@@ -130,7 +137,7 @@ class QuizEngine(
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val hinted = timed.activeQuestion.hint != null
|
||||
val hinted = timed.activeQuestion.appliedHints.isNotEmpty()
|
||||
val overtime = evaluation.correct && timed.activeQuestion.questionRemainingMillis == 0L
|
||||
val attempt = Attempt(
|
||||
id = "${timed.runId}:${timed.attempts.size + 1}",
|
||||
@@ -147,10 +154,11 @@ class QuizEngine(
|
||||
pointsAwarded = points,
|
||||
answeredAtEpochMillis = wallClock.nowEpochMillis(),
|
||||
localEpochDay = localDateProvider.currentEpochDay(),
|
||||
hintType = timed.activeQuestion.hint?.type,
|
||||
hintType = timed.activeQuestion.appliedHints.lastOrNull()?.type,
|
||||
hintPointsDeducted = timed.activeQuestion.hintPointsDeducted,
|
||||
questionWindowMillis = timed.activeQuestion.questionWindowMillis,
|
||||
questionRemainingMillis = timed.activeQuestion.questionRemainingMillis,
|
||||
hintTypes = timed.activeQuestion.usedHintTypes,
|
||||
)
|
||||
val correctStreak = if (evaluation.correct) timed.currentCorrectStreak + 1 else 0
|
||||
return timed.copy(
|
||||
@@ -180,8 +188,9 @@ class QuizEngine(
|
||||
|
||||
fun openHintChooser(state: QuizRunState): QuizRunState {
|
||||
requireAnswering(state)
|
||||
require(state.activeQuestion.hint == null) { "A hint has already been used for this question" }
|
||||
require(state.activeQuestion.question.supportedHints.isNotEmpty()) { "This question does not offer hints" }
|
||||
require(state.activeQuestion.question.supportedHints.any { it !in state.activeQuestion.usedHintTypes }) {
|
||||
"Every available hint has already been used for this question"
|
||||
}
|
||||
return freezeClocks(state).copy(phase = RunPhase.HintChooser)
|
||||
}
|
||||
|
||||
@@ -189,11 +198,14 @@ class QuizEngine(
|
||||
requireActive(state)
|
||||
require(state.phase == RunPhase.HintChooser) { "Hints can only be chosen from the hint chooser" }
|
||||
require(type in state.activeQuestion.question.supportedHints) { "This question does not support $type" }
|
||||
require(state.activeQuestion.hint == null) { "A hint has already been used for this question" }
|
||||
require(type !in state.activeQuestion.usedHintTypes) { "$type has already been used for this question" }
|
||||
|
||||
val scoreAfterHint = Scoring.deductHint(state.score)
|
||||
val hintedQuestion = applyHint(state.activeQuestion, type).copy(
|
||||
hintPointsDeducted = state.score - scoreAfterHint,
|
||||
val appliedQuestion = applyHint(state.activeQuestion, type)
|
||||
val chosenHint = requireNotNull(appliedQuestion.hint)
|
||||
val hintedQuestion = appliedQuestion.copy(
|
||||
usedHints = state.activeQuestion.appliedHints + chosenHint,
|
||||
hintPointsDeducted = state.activeQuestion.hintPointsDeducted + state.score - scoreAfterHint,
|
||||
)
|
||||
return state.copy(
|
||||
phase = RunPhase.Answering,
|
||||
@@ -207,8 +219,9 @@ class QuizEngine(
|
||||
requireActive(state)
|
||||
require(state.phase == RunPhase.Feedback) { "Feedback is not visible" }
|
||||
if (state.runAbandonmentPending) return abandonRun(state)
|
||||
if (state.runCompletionPending || shouldShowScheduledFact(state)) {
|
||||
return showRelatedFact(state, finalFact = state.runCompletionPending, mastery = mastery)
|
||||
val timedRunComplete = state.runCompletionPending || state.sessionExpired
|
||||
if (timedRunComplete || shouldShowScheduledFact(state)) {
|
||||
return showRelatedFact(state, finalFact = timedRunComplete, mastery = mastery)
|
||||
}
|
||||
return moveToNextQuestion(state, mastery)
|
||||
}
|
||||
@@ -292,6 +305,23 @@ class QuizEngine(
|
||||
}
|
||||
}
|
||||
|
||||
fun finishExpiredRun(state: QuizRunState): QuizRunState {
|
||||
requireActive(state)
|
||||
require(state.configuration.duration != QuizDuration.Unlimited) { "Unlimited trivia does not expire" }
|
||||
val frozen = freezeClocks(state)
|
||||
require(frozen.sessionExpired || frozen.sessionRemainingMillis == 0L) { "Trivia time has not expired" }
|
||||
return frozen.copy(
|
||||
phase = RunPhase.Completed,
|
||||
status = RunStatus.Completed,
|
||||
feedback = null,
|
||||
visibleFact = null,
|
||||
finalFactPending = false,
|
||||
runCompletionPending = false,
|
||||
runAbandonmentPending = false,
|
||||
tickingSinceMillis = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun abandonRun(state: QuizRunState): QuizRunState {
|
||||
requireActive(state)
|
||||
return freezeClocks(state).copy(
|
||||
@@ -329,7 +359,11 @@ class QuizEngine(
|
||||
requireAnswering(state)
|
||||
val timed = advanceClocks(state)
|
||||
require(timed.activeQuestion.question is Question.LetterBank) { "The active question is not a letter bank" }
|
||||
require(timed.activeQuestion.placements.any { it.slotIndex == slotIndex }) { "The answer slot is empty" }
|
||||
val placement = timed.activeQuestion.placements.firstOrNull { it.slotIndex == slotIndex }
|
||||
?: error("The answer slot is empty")
|
||||
require(placement.tileId !in timed.activeQuestion.revealedTileIds()) {
|
||||
"A revealed letter cannot be removed"
|
||||
}
|
||||
return timed.copy(
|
||||
activeQuestion = timed.activeQuestion.copy(
|
||||
placements = timed.activeQuestion.placements.filterNot { it.slotIndex == slotIndex },
|
||||
@@ -341,7 +375,12 @@ class QuizEngine(
|
||||
requireAnswering(state)
|
||||
val timed = advanceClocks(state)
|
||||
require(timed.activeQuestion.question is Question.LetterBank) { "The active question is not a letter bank" }
|
||||
return timed.copy(activeQuestion = timed.activeQuestion.copy(placements = emptyList()))
|
||||
val revealedTileIds = timed.activeQuestion.revealedTileIds()
|
||||
return timed.copy(
|
||||
activeQuestion = timed.activeQuestion.copy(
|
||||
placements = timed.activeQuestion.placements.filter { it.tileId in revealedTileIds },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun submitLetters(state: QuizRunState): QuizRunState {
|
||||
@@ -356,6 +395,15 @@ class QuizEngine(
|
||||
|
||||
fun runResult(state: QuizRunState, improvedMasteryItems: Int): RunResult {
|
||||
require(state.status == RunStatus.Completed) { "Only a completed run has results" }
|
||||
return buildRunSummary(state, improvedMasteryItems)
|
||||
}
|
||||
|
||||
fun abandonedRunSummary(state: QuizRunState, improvedMasteryItems: Int): RunResult {
|
||||
require(state.status == RunStatus.Abandoned) { "Only an abandoned run has an abandoned summary" }
|
||||
return buildRunSummary(state, improvedMasteryItems)
|
||||
}
|
||||
|
||||
private fun buildRunSummary(state: QuizRunState, improvedMasteryItems: Int): RunResult {
|
||||
val correct = state.attempts.count { it.correct }
|
||||
val averageResponse = if (state.attempts.isEmpty()) 0 else {
|
||||
state.attempts.sumOf { it.responseMillis } / state.attempts.size
|
||||
@@ -374,6 +422,7 @@ class QuizEngine(
|
||||
.flatMap { it.countryIds }
|
||||
.distinct(),
|
||||
completedAtEpochMillis = wallClock.nowEpochMillis(),
|
||||
status = state.status,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -540,6 +589,9 @@ class QuizEngine(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ActiveQuestion.revealedTileIds(): Set<String> =
|
||||
appliedHints.mapNotNullTo(linkedSetOf()) { it.revealedTileId }
|
||||
|
||||
private fun correctAnswer(question: Question): String = when (question) {
|
||||
is Question.MultipleChoice -> question.choices.first { it.id == question.correctChoiceId }.label
|
||||
is Question.LetterBank -> question.displayAnswer
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package app.bertsatlas.domain
|
||||
|
||||
object Scoring {
|
||||
const val HINT_COST = 25
|
||||
|
||||
fun pointsForCorrectAnswer(remainingMillis: Long, responseWindowMillis: Long): Int {
|
||||
require(responseWindowMillis > 0)
|
||||
if (remainingMillis <= 0) return 50
|
||||
@@ -10,5 +8,7 @@ object Scoring {
|
||||
return (50 + speedPoints).coerceAtMost(100)
|
||||
}
|
||||
|
||||
fun deductHint(score: Int): Int = (score - HINT_COST).coerceAtLeast(0)
|
||||
fun hintCost(score: Int): Int = score.coerceAtLeast(0) / 2
|
||||
|
||||
fun deductHint(score: Int): Int = score.coerceAtLeast(0) - hintCost(score)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ data class Attempt(
|
||||
val hintPointsDeducted: Int = 0,
|
||||
val questionWindowMillis: Long = 0,
|
||||
val questionRemainingMillis: Long = 0,
|
||||
val hintTypes: Set<HintType> = emptySet(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -88,6 +89,7 @@ data class RunResult(
|
||||
val improvedMasteryItems: Int,
|
||||
val revisitCountryIds: List<String>,
|
||||
val completedAtEpochMillis: Long,
|
||||
val status: RunStatus = RunStatus.Completed,
|
||||
) {
|
||||
val accuracyPercent: Int
|
||||
get() = if (answeredQuestions == 0) 0 else (correctAnswers * 100) / answeredQuestions
|
||||
|
||||
@@ -52,10 +52,17 @@ data class ActiveQuestion(
|
||||
val questionRemainingMillis: Long,
|
||||
val questionElapsedMillis: Long = 0,
|
||||
val hint: HintPresentation? = null,
|
||||
val usedHints: List<HintPresentation> = emptyList(),
|
||||
val hintPointsDeducted: Int = 0,
|
||||
val removedChoiceIds: Set<String> = emptySet(),
|
||||
val removedTileIds: Set<String> = emptySet(),
|
||||
)
|
||||
) {
|
||||
val appliedHints: List<HintPresentation>
|
||||
get() = if (usedHints.isEmpty()) listOfNotNull(hint) else usedHints
|
||||
|
||||
val usedHintTypes: Set<HintType>
|
||||
get() = appliedHints.mapTo(linkedSetOf()) { it.type }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class QuizRunState(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
09FED4B446BD58DF150C455A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */; };
|
||||
22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; };
|
||||
307C2654CD5972A25FB7FD87 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668CECA83ACC03A83C85A623 /* iOSApp.swift */; };
|
||||
8799EE5CA40A935F0C1BF0B6 /* ComposeApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE64B79D6154E4181215B83C /* ComposeApp.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -18,6 +19,7 @@
|
||||
C117AD187833E2513BD2F553 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
CE64B79D6154E4181215B83C /* ComposeApp.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ComposeApp.framework; path = ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework; sourceTree = "<group>"; };
|
||||
F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -35,6 +37,7 @@
|
||||
1DB4B685CAEE8BEFEBBAEF94 /* iosApp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F543015C899423D4B01CEB2E /* Assets.xcassets */,
|
||||
C117AD187833E2513BD2F553 /* Info.plist */,
|
||||
668CECA83ACC03A83C85A623 /* iOSApp.swift */,
|
||||
C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */,
|
||||
@@ -125,6 +128,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */,
|
||||
09FED4B446BD58DF150C455A /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xE1",
|
||||
"green" : "0xE9",
|
||||
"red" : "0xF6"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "LaunchLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "[email protected]",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "[email protected]",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -25,7 +25,14 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>LaunchLogo</string>
|
||||
<key>UIImageRespectsSafeAreaInsets</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -102,25 +102,25 @@ The first screen contains:
|
||||
|
||||
`Start` is the only interactive choice on the first-launch screen. System accessibility and silent-mode preferences are honored without requiring setup.
|
||||
|
||||
Tapping `Start` immediately begins a quiz with these defaults:
|
||||
Tapping `Start` immediately begins a trivia run with these defaults:
|
||||
|
||||
- Duration: 5 minutes
|
||||
- Geography: worldwide
|
||||
- Question mix: flags-heavy mixed geography
|
||||
- Difficulty: adaptive
|
||||
|
||||
No configuration questionnaire or modal tutorial appears before the first quiz. The interface teaches mechanics contextually without delaying the first question.
|
||||
No configuration questionnaire or modal tutorial appears before the first trivia run. The interface teaches mechanics contextually without delaying the first question.
|
||||
|
||||
### 6.2 Returning launch
|
||||
|
||||
From the second app launch onward, the home screen contains:
|
||||
|
||||
- `Start`, which immediately uses the most recently saved quiz configuration
|
||||
- `Start`, which immediately uses the most recently saved trivia configuration
|
||||
- `Options`, a secondary action
|
||||
- Current streak and a compact recent-progress summary
|
||||
- Settings
|
||||
|
||||
The app must distinguish first launch from later launches using local state. Completing a quiz is not required for the returning layout to appear on the next launch.
|
||||
The minimal first-use Home remains until the player starts their first trivia run. Merely opening and closing the app does not reveal returning-player controls. Completing the run is not required.
|
||||
|
||||
### 6.3 Options flow
|
||||
|
||||
@@ -138,13 +138,13 @@ The initial V1 sequence is:
|
||||
- Capitals
|
||||
- Mixed geography
|
||||
|
||||
The final answer is saved and immediately starts the quiz. All answers are saved as one configuration. Abandoning the flow leaves the previously saved configuration unchanged.
|
||||
The final answer is saved and immediately starts the trivia. All answers are saved as one configuration. Abandoning the flow leaves the previously saved configuration unchanged.
|
||||
|
||||
Difficulty is always adaptive in V1 and has no player-facing configuration.
|
||||
|
||||
V1 does not ask the player to choose a question count or continent. These controls may be added later only if evidence shows they improve play without adding friction.
|
||||
|
||||
### 6.4 Quiz run
|
||||
### 6.4 Trivia run
|
||||
|
||||
A run repeatedly moves through:
|
||||
|
||||
@@ -258,14 +258,14 @@ Examples for either question format are 100 points at the initial instant, 75 po
|
||||
|
||||
### 8.3 Hints
|
||||
|
||||
Each question permits at most one hint. Opening the hint chooser pauses the question timer. The player chooses among the hint types supported by that question:
|
||||
Each supported hint type may be used once per question. Opening the hint chooser pauses the question timer. Used hints remain visible, disabled, and marked with a check. The player may return for another unused hint among the types supported by that question:
|
||||
|
||||
- Reveal one correct letter
|
||||
- Remove decoy letters or incorrect choices
|
||||
- Reveal the continent
|
||||
- Show a funny factual clue
|
||||
|
||||
All hint types use the same debit rule. When a hint is chosen, the app first deducts up to 25 points from the score already earned in the current run. The score can never become negative: a zero balance makes the hint free, while a balance below 25 is reduced to zero. A subsequent correct answer then awards points using the normal speed or overtime formula. An incorrect hinted answer awards zero.
|
||||
All hint types use the same debit rule. Choosing a hint deducts half of the currently available score using whole points. The chooser displays the exact deduction as a compact negative-points label and hides it at a zero balance. A subsequent correct answer then awards points using the normal speed or overtime formula. An incorrect hinted answer awards zero.
|
||||
|
||||
A hinted correct answer is recorded as correct for accuracy and keeps the item in `learning`, but it does not satisfy the unhinted thresholds for `familiar` or `mastered`. Using a hint never blocks progression or triggers negative copy.
|
||||
|
||||
@@ -561,7 +561,7 @@ V1 is ready only when:
|
||||
- A fresh install can start and finish every quiz configuration in airplane mode on Android and iOS.
|
||||
- First launch shows only the immediate-start experience; later launches show `Start` and `Options`.
|
||||
- `Start` correctly reuses the most recently completed options configuration.
|
||||
- Each timed duration ends after the current question, and unlimited mode ends only by player choice.
|
||||
- Each timed duration moves directly to Results when the overall trivia timer expires, and unlimited mode ends only by player choice.
|
||||
- Question and session clocks pause in every specified non-answering state.
|
||||
- Abandoned and completed runs apply mastery, results, personal-best, and streak rules correctly.
|
||||
- Correct-before-timeout, correct-after-timeout, incorrect, and hinted scoring follow the specified rules.
|
||||
|
||||