build shared offline app experience

This commit is contained in:
pj committed 2026-09-09 23:45:39 +05:30
1 parent e21a2f39e0
commit c7259ea645
45 files changed
+4584 -40

No files matched your search

+5 -1
View File
@@ -15,11 +15,15 @@ android {
versionName = "1.0.0"
}
buildFeatures.compose = true
buildFeatures {
compose = true
buildConfig = true
}
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
dependencies {
implementation(project(":composeApp"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.room.runtime)
}
+1 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:label="Bert's Atlas"
@@ -16,4 +17,3 @@
</activity>
</application>
</manifest>
@@ -3,11 +3,30 @@ package app.bertsatlas
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import app.bertsatlas.data.buildBertsAtlasDatabase
import app.bertsatlas.data.createPreferencesStore
import app.bertsatlas.data.getDatabaseBuilder
class MainActivity : ComponentActivity() {
private var controller: AppController? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App() }
val database = getDatabaseBuilder(this).buildBertsAtlasDatabase()
val preferences = createPreferencesStore(filesDir.resolve("berts-atlas.preferences_pb").absolutePath)
val feedback = AndroidDeviceFeedback(this)
setContent {
App(database, preferences, BuildConfig.DEBUG, feedback) { controller = it }
}
}
override fun onStop() {
controller?.pauseForInterruption()
super.onStop()
}
override fun onStart() {
super.onStart()
controller?.resumeAfterInterruption()
}
}
+15
View File
@@ -6,6 +6,8 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
kotlin {
@@ -33,9 +35,22 @@ kotlin {
implementation(libs.compose.ui)
implementation(libs.compose.resources)
implementation(libs.compottie.lite)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
}
}
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
}
room {
schemaDirectory("$projectDir/schemas")
}
@@ -0,0 +1,400 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "48ec5055c5a39c6bd15884e9405898da",
"entities": [
{
"tableName": "attempts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `runId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `countryIdsJson` TEXT NOT NULL, `skill` TEXT NOT NULL, `correct` INTEGER NOT NULL, `hinted` INTEGER NOT NULL, `overtime` INTEGER NOT NULL, `submittedAnswer` TEXT NOT NULL, `correctAnswer` TEXT NOT NULL, `responseMillis` INTEGER NOT NULL, `pointsAwarded` INTEGER NOT NULL, `answeredAtEpochMillis` INTEGER NOT NULL, `localEpochDay` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "runId",
"columnName": "runId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "questionId",
"columnName": "questionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "countryIdsJson",
"columnName": "countryIdsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "skill",
"columnName": "skill",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correct",
"columnName": "correct",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hinted",
"columnName": "hinted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "overtime",
"columnName": "overtime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "submittedAnswer",
"columnName": "submittedAnswer",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAnswer",
"columnName": "correctAnswer",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseMillis",
"columnName": "responseMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pointsAwarded",
"columnName": "pointsAwarded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "answeredAtEpochMillis",
"columnName": "answeredAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localEpochDay",
"columnName": "localEpochDay",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "mastery",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`countryId` TEXT NOT NULL, `skill` TEXT NOT NULL, `level` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `unhintedCorrectAnswers` INTEGER NOT NULL, `unhintedCorrectRunIdsJson` TEXT NOT NULL, `unhintedCorrectEpochDaysJson` TEXT NOT NULL, `recentOutcomesJson` TEXT NOT NULL, `nextReviewEpochDay` INTEGER, PRIMARY KEY(`countryId`, `skill`))",
"fields": [
{
"fieldPath": "countryId",
"columnName": "countryId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "skill",
"columnName": "skill",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correctAnswers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unhintedCorrectAnswers",
"columnName": "unhintedCorrectAnswers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unhintedCorrectRunIdsJson",
"columnName": "unhintedCorrectRunIdsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unhintedCorrectEpochDaysJson",
"columnName": "unhintedCorrectEpochDaysJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recentOutcomesJson",
"columnName": "recentOutcomesJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextReviewEpochDay",
"columnName": "nextReviewEpochDay",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"countryId",
"skill"
]
}
},
{
"tableName": "runs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `duration` TEXT, `topic` TEXT, `status` TEXT NOT NULL, `score` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `answeredQuestions` INTEGER NOT NULL, `averageResponseMillis` INTEGER NOT NULL, `bestCorrectStreak` INTEGER NOT NULL, `improvedMasteryItems` INTEGER NOT NULL, `revisitCountryIdsJson` TEXT NOT NULL, `completedAtEpochMillis` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "score",
"columnName": "score",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correctAnswers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "answeredQuestions",
"columnName": "answeredQuestions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "averageResponseMillis",
"columnName": "averageResponseMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bestCorrectStreak",
"columnName": "bestCorrectStreak",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "improvedMasteryItems",
"columnName": "improvedMasteryItems",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "revisitCountryIdsJson",
"columnName": "revisitCountryIdsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "completedAtEpochMillis",
"columnName": "completedAtEpochMillis",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "personal_bests",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`duration` TEXT NOT NULL, `topic` TEXT NOT NULL, `score` INTEGER NOT NULL, `runId` TEXT NOT NULL, PRIMARY KEY(`duration`, `topic`))",
"fields": [
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "score",
"columnName": "score",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "runId",
"columnName": "runId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"duration",
"topic"
]
}
},
{
"tableName": "streak",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `currentDays` INTEGER NOT NULL, `bestDays` INTEGER NOT NULL, `lastCompletedEpochDay` INTEGER, PRIMARY KEY(`singletonId`))",
"fields": [
{
"fieldPath": "singletonId",
"columnName": "singletonId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentDays",
"columnName": "currentDays",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bestDays",
"columnName": "bestDays",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCompletedEpochDay",
"columnName": "lastCompletedEpochDay",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"singletonId"
]
}
},
{
"tableName": "stamps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `earnedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "earnedAtEpochMillis",
"columnName": "earnedAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "active_run",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `runId` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `catalogVersion` TEXT NOT NULL, `snapshotJson` TEXT NOT NULL, PRIMARY KEY(`singletonId`))",
"fields": [
{
"fieldPath": "singletonId",
"columnName": "singletonId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "runId",
"columnName": "runId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "schemaVersion",
"columnName": "schemaVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snapshotJson",
"columnName": "snapshotJson",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"singletonId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2eec9fa8f28884d9cb12c2c0ab1e03f5')"
]
}
}
@@ -0,0 +1,423 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "2eec9fa8f28884d9cb12c2c0ab1e03f5",
"entities": [
{
"tableName": "attempts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `runId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `countryIdsJson` TEXT NOT NULL, `skill` TEXT NOT NULL, `correct` INTEGER NOT NULL, `hinted` INTEGER NOT NULL, `overtime` INTEGER NOT NULL, `submittedAnswer` TEXT NOT NULL, `correctAnswer` TEXT NOT NULL, `responseMillis` INTEGER NOT NULL, `pointsAwarded` INTEGER NOT NULL, `answeredAtEpochMillis` INTEGER NOT NULL, `localEpochDay` INTEGER NOT NULL, `hintType` TEXT, `hintPointsDeducted` INTEGER NOT NULL, `questionWindowMillis` INTEGER NOT NULL, `questionRemainingMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "runId",
"columnName": "runId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "questionId",
"columnName": "questionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "countryIdsJson",
"columnName": "countryIdsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "skill",
"columnName": "skill",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correct",
"columnName": "correct",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hinted",
"columnName": "hinted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "overtime",
"columnName": "overtime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "submittedAnswer",
"columnName": "submittedAnswer",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAnswer",
"columnName": "correctAnswer",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseMillis",
"columnName": "responseMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pointsAwarded",
"columnName": "pointsAwarded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "answeredAtEpochMillis",
"columnName": "answeredAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localEpochDay",
"columnName": "localEpochDay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hintType",
"columnName": "hintType",
"affinity": "TEXT"
},
{
"fieldPath": "hintPointsDeducted",
"columnName": "hintPointsDeducted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questionWindowMillis",
"columnName": "questionWindowMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questionRemainingMillis",
"columnName": "questionRemainingMillis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "mastery",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`countryId` TEXT NOT NULL, `skill` TEXT NOT NULL, `level` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `unhintedCorrectAnswers` INTEGER NOT NULL, `unhintedCorrectRunIdsJson` TEXT NOT NULL, `unhintedCorrectEpochDaysJson` TEXT NOT NULL, `recentOutcomesJson` TEXT NOT NULL, `nextReviewEpochDay` INTEGER, PRIMARY KEY(`countryId`, `skill`))",
"fields": [
{
"fieldPath": "countryId",
"columnName": "countryId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "skill",
"columnName": "skill",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correctAnswers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unhintedCorrectAnswers",
"columnName": "unhintedCorrectAnswers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unhintedCorrectRunIdsJson",
"columnName": "unhintedCorrectRunIdsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unhintedCorrectEpochDaysJson",
"columnName": "unhintedCorrectEpochDaysJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recentOutcomesJson",
"columnName": "recentOutcomesJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextReviewEpochDay",
"columnName": "nextReviewEpochDay",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"countryId",
"skill"
]
}
},
{
"tableName": "runs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `duration` TEXT, `topic` TEXT, `status` TEXT NOT NULL, `score` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `answeredQuestions` INTEGER NOT NULL, `averageResponseMillis` INTEGER NOT NULL, `bestCorrectStreak` INTEGER NOT NULL, `improvedMasteryItems` INTEGER NOT NULL, `revisitCountryIdsJson` TEXT NOT NULL, `completedAtEpochMillis` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "score",
"columnName": "score",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correctAnswers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "answeredQuestions",
"columnName": "answeredQuestions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "averageResponseMillis",
"columnName": "averageResponseMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bestCorrectStreak",
"columnName": "bestCorrectStreak",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "improvedMasteryItems",
"columnName": "improvedMasteryItems",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "revisitCountryIdsJson",
"columnName": "revisitCountryIdsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "completedAtEpochMillis",
"columnName": "completedAtEpochMillis",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "personal_bests",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`duration` TEXT NOT NULL, `topic` TEXT NOT NULL, `score` INTEGER NOT NULL, `runId` TEXT NOT NULL, PRIMARY KEY(`duration`, `topic`))",
"fields": [
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "score",
"columnName": "score",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "runId",
"columnName": "runId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"duration",
"topic"
]
}
},
{
"tableName": "streak",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `currentDays` INTEGER NOT NULL, `bestDays` INTEGER NOT NULL, `lastCompletedEpochDay` INTEGER, PRIMARY KEY(`singletonId`))",
"fields": [
{
"fieldPath": "singletonId",
"columnName": "singletonId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentDays",
"columnName": "currentDays",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bestDays",
"columnName": "bestDays",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCompletedEpochDay",
"columnName": "lastCompletedEpochDay",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"singletonId"
]
}
},
{
"tableName": "stamps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `earnedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "earnedAtEpochMillis",
"columnName": "earnedAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "active_run",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `runId` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `catalogVersion` TEXT NOT NULL, `snapshotJson` TEXT NOT NULL, PRIMARY KEY(`singletonId`))",
"fields": [
{
"fieldPath": "singletonId",
"columnName": "singletonId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "runId",
"columnName": "runId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "schemaVersion",
"columnName": "schemaVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snapshotJson",
"columnName": "snapshotJson",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"singletonId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2eec9fa8f28884d9cb12c2c0ab1e03f5')"
]
}
}
@@ -0,0 +1,21 @@
package app.bertsatlas
import android.content.Context
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.VibrationEffect
import android.os.Vibrator
class AndroidDeviceFeedback(context: Context) : DeviceFeedback {
private val vibrator = context.applicationContext.getSystemService(Vibrator::class.java)
private val toneGenerator = ToneGenerator(AudioManager.STREAM_MUSIC, 45)
override fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean) {
if (soundEnabled) {
toneGenerator.startTone(if (correct) ToneGenerator.TONE_PROP_ACK else ToneGenerator.TONE_PROP_NACK, 90)
}
if (hapticsEnabled && vibrator?.hasVibrator() == true) {
vibrator.vibrate(VibrationEffect.createOneShot(if (correct) 35 else 70, VibrationEffect.DEFAULT_AMPLITUDE))
}
}
}
@@ -0,0 +1,15 @@
package app.bertsatlas.data
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<BertsAtlasDatabase> {
val applicationContext = context.applicationContext
val databaseFile = applicationContext.getDatabasePath("berts-atlas.db")
return Room.databaseBuilder(
context = applicationContext,
name = databaseFile.absolutePath,
)
}
@@ -0,0 +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"/>
</svg>

After

Width:  |  Height:  |  Size: 844 B

@@ -0,0 +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"/>
</svg>

After

Width:  |  Height:  |  Size: 978 B

@@ -0,0 +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"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +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"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1 @@
{"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":[]}
@@ -0,0 +1,93 @@
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,42 +1,272 @@
package app.bertsatlas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import app.bertsatlas.catalog.BundledCatalogProvider
import app.bertsatlas.data.BertsAtlasDatabase
import app.bertsatlas.data.PreferencesStore
import app.bertsatlas.domain.model.FeedbackKind
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.ui.AnswerState
import app.bertsatlas.ui.BertsAtlasTheme
import app.bertsatlas.ui.BootstrapScreen
import app.bertsatlas.ui.ChoiceUi
import app.bertsatlas.ui.CreditsScreen
import app.bertsatlas.ui.DebugPanelScreen
import app.bertsatlas.ui.FeedbackScreen
import app.bertsatlas.ui.FlagUi
import app.bertsatlas.ui.FunFactScreen
import app.bertsatlas.ui.HintChooserScreen
import app.bertsatlas.ui.HomeScreen
import app.bertsatlas.ui.LetterSlotUi
import app.bertsatlas.ui.LetterTileUi
import app.bertsatlas.ui.OptionsScreen
import app.bertsatlas.ui.PrivacyScreen
import app.bertsatlas.ui.ProgressScreen
import app.bertsatlas.ui.QuizScreen
import app.bertsatlas.ui.RecoveryScreen
import app.bertsatlas.ui.ResetConfirmationScreen
import app.bertsatlas.ui.ResultsScreen
import app.bertsatlas.ui.SettingsScreen
import kotlinx.coroutines.delay
@Composable
fun App() {
Column(
modifier = Modifier.fillMaxSize().background(Color(0xFFF7F0DF)).padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText("Bert's Atlas", style = TextStyle(color = Color(0xFF3E2447), fontSize = 36.sp))
BasicText(
"Getting nerdier every day.",
modifier = Modifier.padding(vertical = 16.dp),
style = TextStyle(color = Color(0xFF3E2447), fontSize = 18.sp),
)
BasicText(
"Start",
modifier = Modifier
.heightIn(min = 48.dp)
.background(Color(0xFF19647E))
.clickable(onClick = {})
.padding(horizontal = 32.dp, vertical = 14.dp),
style = TextStyle(color = Color.White, fontSize = 18.sp),
)
fun App(
database: BertsAtlasDatabase,
preferences: PreferencesStore,
debugBuild: Boolean,
deviceFeedback: DeviceFeedback = NoDeviceFeedback,
onControllerReady: (AppController) -> Unit = {},
) {
val scope = rememberCoroutineScope()
var controller by remember { mutableStateOf<AppController?>(null) }
var loadFailure by remember { mutableStateOf<String?>(null) }
LaunchedEffect(database, preferences) {
runCatching { BundledCatalogProvider().loadCatalog() }
.onSuccess { catalog -> controller = AppController(catalog, database, preferences, scope, debugBuild, deviceFeedback).also(onControllerReady) }
.onFailure { loadFailure = it.message ?: "The bundled catalog could not be opened." }
}
val activeController = controller
val appState = activeController?.state?.collectAsState()?.value
BertsAtlasTheme(reducedMotion = appState?.preferences?.motionMode == "Reduced") {
if (activeController == null || appState == null) BootstrapScreen(loadFailure ?: "Opening the atlas…")
else AtlasDestination(activeController, appState)
}
}
@Composable
private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
val run = state.run
when (val destination = state.destination) {
Destination.Bootstrap -> BootstrapScreen()
Destination.Home -> HomeScreen(
isFirstLaunch = state.firstLaunch,
streakDays = state.streak.currentDays,
progressSummary = "${state.attempts.size} questions explored",
onStart = controller::start,
onOptions = controller::openOptions,
onProgress = { controller.navigate(Destination.Progress) },
onSettings = { controller.navigate(Destination.Settings) },
)
is Destination.Options -> OptionsScreen(
step = destination.step,
choices = if (destination.step == 1) durationChoices(state.selectedDuration) else topicChoices(),
onChoose = controller::chooseOption,
onBack = controller::back,
)
Destination.Recovery -> if (run == null) BootstrapScreen() else RecoveryScreen(
questionSummary = run.activeQuestion.question.prompt,
score = run.score,
timeRemaining = run.sessionRemainingMillis.formatTime(),
onResume = controller::resume,
onAbandon = controller::abandon,
)
Destination.Quiz -> if (run == null) BootstrapScreen() else RunDestination(controller, state)
Destination.Results -> {
val result = state.result
if (result == null) BootstrapScreen() else ResultsScreen(
score = result.score,
correctAnswers = result.correctAnswers,
answeredQuestions = result.answeredQuestions,
accuracy = "${result.accuracyPercent}%",
averageResponseTime = "${result.averageResponseMillis / 1_000.0}s",
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) },
)
}
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}%",
totalAnswered = state.attempts.size,
masteryGroups = controller.progressGroups,
strongCountries = countryNames(controller, state.mastery.filter { it.level.name in setOf("Familiar", "Mastered") }.map { it.countryId }),
revisitCountries = countryNames(controller, state.mastery.filter { it.level.name == "Learning" }.map { it.countryId }),
stamps = controller.stampUi,
onBack = controller::back,
)
Destination.Settings -> SettingsScreen(
settings = controller.settings,
version = "1.0.0",
debugEntryEnabled = controller.debugBuild,
onToggle = controller::toggleSetting,
onPrivacy = { controller.navigate(Destination.Privacy) },
onCredits = { controller.navigate(Destination.Credits) },
onReset = { controller.navigate(Destination.Reset) },
onOpenDebug = { if (controller.debugBuild) controller.navigate(Destination.Debug) },
onBack = controller::back,
)
Destination.Privacy -> PrivacyScreen(controller::back)
Destination.Credits -> CreditsScreen(
catalogVersion = controller.catalog.contentVersion,
credits = listOf("Country metadata: United Nations and CLDR", "Durable facts: cited per catalog record", "Flags: pinned public-domain country-flags revision"),
licenses = listOf("Atkinson Hyperlegible Next · OFL-1.1", "Compottie · Apache-2.0", "country-flags · public domain"),
onBack = controller::back,
)
Destination.Reset -> ResetConfirmationScreen(controller::resetAll, controller::back)
Destination.Debug -> if (controller.debugBuild) DebugPanelScreen(
buildVersion = "1.0.0 debug",
catalogVersion = controller.catalog.contentVersion,
activeSnapshot = run?.let { "${it.runId} · ${it.phase} · ${it.activeQuestion.question.id} · ${it.score} points" } ?: "No active run",
actions = controller.debugActions,
onAction = controller::debugAction,
onClose = controller::back,
) else HomeScreen(true, onStart = controller::start)
}
}
@Composable
private fun RunDestination(controller: AppController, state: AtlasAppState) {
val run = requireNotNull(state.run)
when (run.phase) {
RunPhase.Answering, RunPhase.Paused -> {
val question = run.activeQuestion.question
val country = question.countryIds.firstOrNull()?.let { id -> controller.catalog.countries.firstOrNull { it.id == id } }
val promptFlag = when (question) {
is Question.LetterBank -> country?.let { FlagUi(it.flagAssetId.removePrefix("flag-"), "mystery country") }
is Question.MultipleChoice -> question.promptAssetId?.let { FlagUi(it.removePrefix("flag-"), "mystery country") }
}
QuizScreen(
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) },
)
}
}.orEmpty(),
letterSlots = letterSlots(question, run.activeQuestion.placements, run.activeQuestion.letterTiles),
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,
unlimited = run.configuration.duration == QuizDuration.Unlimited,
canSubmit = question is Question.LetterBank && run.activeQuestion.placements.size == question.displayAnswer.count { it.isLetter() },
onChoice = controller::answerChoice,
onLetter = controller::placeLetter,
onRemoveLetter = controller::removeLetter,
onClearLetters = controller::clearLetters,
onSubmit = controller::submitLetters,
onHint = controller::openHint,
onFinish = controller::finishUnlimited,
onLeave = controller::requestLeave,
promptFlag = promptFlag,
)
}
RunPhase.HintChooser -> HintChooserScreen(
score = run.score,
hints = run.activeQuestion.question.supportedHints.map { ChoiceUi(it.name, it.displayName()) },
onChooseHint = { id -> controller.chooseHint(HintType.valueOf(id)) },
onCancel = controller::cancelHint,
)
RunPhase.Feedback -> {
val feedback = requireNotNull(run.feedback)
LaunchedEffect(run.attempts.size, state.preferences.autoAdvanceEnabled) {
if (state.preferences.autoAdvanceEnabled) {
delay(2_500)
controller.continueRun()
}
}
FeedbackScreen(
correct = feedback.kind != FeedbackKind.Incorrect,
overtime = feedback.kind == FeedbackKind.OvertimeCorrect,
heading = when (feedback.kind) { FeedbackKind.Correct -> "Exactly right"; FeedbackKind.Incorrect -> "Not quite"; FeedbackKind.OvertimeCorrect -> "Worth the wait" },
selectedAnswer = feedback.submittedAnswer,
correctAnswer = feedback.correctAnswer,
pointsAwarded = feedback.pointsAwarded,
explanation = feedback.explanation,
autoAdvanceMessage = if (state.preferences.autoAdvanceEnabled) "Continuing shortly…" else null,
onContinue = controller::continueRun,
)
}
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…")
}
}
private fun durationChoices(selected: QuizDuration) = QuizDuration.entries.map { duration ->
ChoiceUi(duration.name, when (duration) { QuizDuration.FiveMinutes -> "5 minutes"; QuizDuration.TenMinutes -> "10 minutes"; QuizDuration.TwentyMinutes -> "20 minutes"; QuizDuration.Unlimited -> "Unlimited" }, state = if (duration == selected) AnswerState.Selected else AnswerState.Available)
}
private fun topicChoices() = listOf(
ChoiceUi("MostlyFlags", "Mostly flags", "Recognize flags and spell country names"),
ChoiceUi("Capitals", "Capitals", "Match countries and capitals"),
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> {
val letterBank = question as? Question.LetterBank ?: return emptyList()
val tileById = tiles.associateBy { it.id }
var editableIndex = 0
return letterBank.displayAnswer.mapIndexed { displayIndex, character ->
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++ }
}
}
}
private fun HintType.displayName() = when (this) {
HintType.RevealCorrectLetter -> "Reveal one letter"
HintType.RemoveIncorrectOptions -> "Remove two wrong choices"
HintType.RevealContinent -> "Reveal the region"
HintType.FunnyFactualClue -> "Give me a factual clue"
}
private fun Long?.formatTime(): String {
if (this == null) return "∞"
val seconds = (this.coerceAtLeast(0) + 999) / 1_000
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
private fun countryNames(controller: AppController, ids: List<String>): List<String> = ids.distinct().mapNotNull { id ->
controller.catalog.countries.firstOrNull { it.id == id }?.displayName
}
@@ -0,0 +1,520 @@
package app.bertsatlas
import app.bertsatlas.data.ActiveRunSnapshotEntity
import app.bertsatlas.data.AttemptEntity
import app.bertsatlas.data.BertsAtlasDatabase
import app.bertsatlas.data.MasteryEntity
import app.bertsatlas.data.PersonalBestEntity
import app.bertsatlas.data.PreferencesStore
import app.bertsatlas.data.RunEntity
import app.bertsatlas.data.SavedPreferences
import app.bertsatlas.data.StampEntity
import app.bertsatlas.data.StreakEntity
import app.bertsatlas.domain.LocalDateProvider
import app.bertsatlas.domain.MasteryRules
import app.bertsatlas.domain.MonotonicClock
import app.bertsatlas.domain.PassportEvidence
import app.bertsatlas.domain.PassportRules
import app.bertsatlas.domain.QuizEngine
import app.bertsatlas.domain.StreakRules
import app.bertsatlas.domain.WallClock
import app.bertsatlas.domain.model.ActiveRunSnapshot
import app.bertsatlas.domain.model.Catalog
import app.bertsatlas.domain.model.Continent
import app.bertsatlas.domain.model.HintType
import app.bertsatlas.domain.model.Mastery
import app.bertsatlas.domain.model.MasteryLevel
import app.bertsatlas.domain.model.PassportStamp
import app.bertsatlas.domain.model.PassportStampKind
import app.bertsatlas.domain.model.Question
import app.bertsatlas.domain.model.QuizConfiguration
import app.bertsatlas.domain.model.QuizDuration
import app.bertsatlas.domain.model.QuizRunState
import app.bertsatlas.domain.model.QuizTopic
import app.bertsatlas.domain.model.RunPhase
import app.bertsatlas.domain.model.RunResult
import app.bertsatlas.domain.model.RunStatus
import app.bertsatlas.domain.model.Streak
import app.bertsatlas.domain.model.SubmittedAnswer
import app.bertsatlas.ui.DebugActionUi
import app.bertsatlas.ui.ProgressGroupUi
import app.bertsatlas.ui.SettingToggleUi
import app.bertsatlas.ui.StampUi
import kotlin.time.Clock
import kotlin.time.TimeSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
sealed interface Destination {
data object Bootstrap : Destination
data object Recovery : Destination
data object Home : Destination
data class Options(val step: Int) : Destination
data object Quiz : Destination
data object Results : Destination
data object Progress : Destination
data object Settings : Destination
data object Privacy : Destination
data object Credits : Destination
data object Reset : Destination
data object Debug : Destination
}
data class AtlasAppState(
val destination: Destination = Destination.Bootstrap,
val firstLaunch: Boolean = true,
val preferences: SavedPreferences = SavedPreferences(),
val selectedDuration: QuizDuration = QuizDuration.FiveMinutes,
val run: QuizRunState? = null,
val result: RunResult? = null,
val newPersonalBest: Boolean = false,
val mastery: List<Mastery> = emptyList(),
val attempts: List<AttemptEntity> = emptyList(),
val completedRuns: List<RunEntity> = emptyList(),
val streak: Streak = Streak(),
val stamps: List<PassportStamp> = emptyList(),
)
class AppController(
val catalog: Catalog,
private val database: BertsAtlasDatabase,
private val preferencesStore: PreferencesStore,
private val scope: CoroutineScope,
val debugBuild: Boolean,
private val deviceFeedback: DeviceFeedback = NoDeviceFeedback,
) {
private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex()
private val monotonicOrigin = TimeSource.Monotonic.markNow()
private val monotonicClock = MonotonicClock { monotonicOrigin.elapsedNow().inWholeMilliseconds }
private val wallClock = WallClock { Clock.System.now().toEpochMilliseconds() }
private val localDateProvider = LocalDateProvider {
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toEpochDays().toInt()
}
private val engine = QuizEngine(catalog, monotonicClock, wallClock, localDateProvider, allowUnapprovedContent = debugBuild)
private val _state = MutableStateFlow(AtlasAppState())
val state: StateFlow<AtlasAppState> = _state.asStateFlow()
init {
scope.launch { bootstrap() }
scope.launch {
while (isActive) {
delay(100)
val current = _state.value.run
if (current?.phase == RunPhase.Answering) {
_state.value = _state.value.copy(run = engine.advanceClocks(current))
}
}
}
}
private suspend fun bootstrap() = mutex.withLock {
val preferences = preferencesStore.values.first()
val mastery = readMastery()
val attempts = database.progressReader().attemptsNow()
val runs = database.progressReader().completedRunsNow()
val streak = database.progressReader().streakNow().toDomain()
val stamps = database.progressReader().stampsNow().mapNotNull { it.toDomain() }
val snapshot = database.progressReader().activeRun()
val restored = snapshot?.let {
runCatching { engine.restoreRun(json.decodeFromString<ActiveRunSnapshot>(it.snapshotJson)) }.getOrNull()
}
if (snapshot != null && restored == null) database.runWriter().clearActiveRun()
_state.value = AtlasAppState(
destination = if (restored == null) Destination.Home else Destination.Recovery,
firstLaunch = !preferences.hasPreviouslyLaunched,
preferences = preferences,
selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes),
run = restored,
mastery = mastery,
attempts = attempts,
completedRuns = runs,
streak = streak,
stamps = stamps,
)
preferencesStore.markLaunched()
}
fun start() {
val current = _state.value
if (current.firstLaunch) {
beginRun(QuizConfiguration())
} else {
beginRun(
QuizConfiguration(
enumValueOrDefault(current.preferences.duration, QuizDuration.FiveMinutes),
enumValueOrDefault(current.preferences.topic, QuizTopic.MostlyFlags),
),
)
}
}
fun openOptions() { _state.value = _state.value.copy(destination = Destination.Options(1)) }
fun chooseOption(id: String) {
when (val destination = _state.value.destination) {
is Destination.Options -> if (destination.step == 1) {
_state.value = _state.value.copy(
selectedDuration = enumValueOrDefault(id, QuizDuration.FiveMinutes),
destination = Destination.Options(2),
)
} else {
val topic = enumValueOrDefault(id, QuizTopic.MostlyFlags)
val duration = _state.value.selectedDuration
scope.launch {
preferencesStore.saveConfiguration(duration.name, topic.name)
val saved = preferencesStore.values.first()
_state.value = _state.value.copy(preferences = saved)
beginRun(QuizConfiguration(duration, topic))
}
}
else -> Unit
}
}
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)
}
}
private fun beginRun(configuration: QuizConfiguration) {
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)
persistSnapshot(run)
}
fun resume() {
val run = _state.value.run ?: return
val resumed = engine.resumeRun(run)
_state.value = _state.value.copy(destination = destinationFor(resumed), run = resumed)
persistSnapshot(resumed)
}
fun answerChoice(choiceId: String) = mutateRun { engine.answerQuestion(it, SubmittedAnswer.ChoiceAnswer(choiceId)) }.also {
playAnswerFeedback()
persistAcceptedAnswer()
}
fun placeLetter(tileId: String) = mutateRun { engine.placeLetter(it, tileId) }
fun removeLetter(tileId: String) = mutateRun { state ->
val slot = state.activeQuestion.placements.first { it.tileId == tileId }.slotIndex
engine.removeLetter(state, slot)
}
fun clearLetters() = mutateRun(engine::clearLetters)
fun submitLetters() = mutateRun(engine::submitLetters).also {
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 continueRun() {
val before = _state.value.run ?: return
val next = when (before.phase) {
RunPhase.Feedback -> engine.dismissFeedback(before, _state.value.mastery)
RunPhase.FunFact -> engine.continueFromFact(before, _state.value.mastery)
else -> return
}
acceptTransition(next)
}
fun finishUnlimited() {
val run = _state.value.run ?: return
acceptTransition(engine.finishUnlimitedRun(run, _state.value.mastery))
}
fun requestLeave() { 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())
refreshProgress()
}
}
}
fun pauseForInterruption() {
val run = _state.value.run ?: return
if (run.status != RunStatus.Active || run.phase == RunPhase.Paused) return
val paused = engine.pauseForInterruption(run)
_state.value = _state.value.copy(run = paused)
persistSnapshot(paused)
}
fun resumeAfterInterruption() {
val run = _state.value.run ?: 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
_state.value = _state.value.copy(destination = destinationFor(next), run = next)
}
private fun acceptTransition(next: QuizRunState) {
if (next.status == RunStatus.Completed) complete(next)
else {
_state.value = _state.value.copy(destination = destinationFor(next), run = next)
persistSnapshot(next)
}
}
private fun persistAcceptedAnswer() {
val run = _state.value.run ?: return
val attempt = run.attempts.lastOrNull() ?: return
scope.launch {
mutex.withLock {
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 } }
database.runWriter().recordAcceptedAnswer(
attempt.toEntity(json),
changed.map { it.toEntity() },
run.toSnapshotEntity(engine, json),
)
_state.value = _state.value.copy(mastery = after)
}
}
}
private fun playAnswerFeedback() {
val run = _state.value.run ?: return
val correct = run.feedback?.kind != app.bertsatlas.domain.model.FeedbackKind.Incorrect
deviceFeedback.play(correct, _state.value.preferences.soundEnabled, _state.value.preferences.hapticsEnabled)
}
private fun persistSnapshot(run: QuizRunState?) {
if (run == null || run.status != RunStatus.Active) return
scope.launch {
mutex.withLock {
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 {
val reader = database.progressReader()
val oldBest = reader.personalBestsNow().firstOrNull {
it.duration == run.configuration.duration.name && it.topic == run.configuration.topic.name
}
val isBest = oldBest == null || result.score > oldBest.score
val newStreak = StreakRules.recordCompletedRun(_state.value.streak, localDateProvider.currentEpochDay())
val existingStamps = _state.value.stamps
val attempts = reader.attemptsNow()
val mastery = _state.value.mastery
val flagContinents = mastery.filter { it.skill.name.contains("Flag") && it.correctAnswers > 0 }
.flatMap { masteryItem -> catalog.countries.firstOrNull { it.id == masteryItem.countryId }?.continentIds.orEmpty() }.toSet()
val newlyEarned = PassportRules.newlyEarned(
PassportEvidence(
completedRunCount = reader.completedRunsNow().size + 1,
recognizedFlagContinents = flagContinents,
totalAnswered = attempts.size,
totalCorrect = attempts.count { it.correct },
confusedFlagMastery = emptyList(),
streak = newStreak,
),
existingStamps,
wallClock.nowEpochMillis(),
)
database.runWriter().completeRun(
run.toRunEntity(result),
if (isBest) PersonalBestEntity(run.configuration.duration.name, run.configuration.topic.name, result.score, run.runId) else null,
newStreak.toEntity(),
newlyEarned.map { StampEntity(it.kind.name, it.earnedAtEpochMillis) },
)
_state.value = _state.value.copy(newPersonalBest = isBest, streak = newStreak, stamps = existingStamps + newlyEarned)
refreshProgress()
}
}
}
private suspend fun refreshProgress() {
val reader = database.progressReader()
_state.value = _state.value.copy(
mastery = readMastery(),
attempts = reader.attemptsNow(),
completedRuns = reader.completedRunsNow(),
streak = reader.streakNow().toDomain(),
stamps = reader.stampsNow().mapNotNull { it.toDomain() },
)
}
fun navigate(destination: Destination) { _state.value = _state.value.copy(destination = destination) }
fun toggleSetting(id: String, enabled: Boolean) {
scope.launch {
when (id) {
"sound" -> preferencesStore.setSoundEnabled(enabled)
"haptics" -> preferencesStore.setHapticsEnabled(enabled)
"motion" -> preferencesStore.setMotionMode(if (enabled) "Reduced" else "System")
"auto" -> preferencesStore.setAutoAdvanceEnabled(enabled)
}
_state.value = _state.value.copy(preferences = preferencesStore.values.first())
}
}
fun resetAll() {
scope.launch {
database.runWriter().resetProgress()
preferencesStore.resetAll()
_state.value = AtlasAppState(destination = Destination.Home)
}
}
fun debugAction(id: String) {
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-all" -> resetAll()
"question-zero" -> updateRunDebug { it.copy(activeQuestion = it.activeQuestion.copy(questionRemainingMillis = 0)) }
"session-zero" -> updateRunDebug { it.copy(sessionRemainingMillis = 0, sessionExpired = true) }
"score-100" -> updateRunDebug { it.copy(score = 100) }
}
}
val settings: List<SettingToggleUi> get() = listOf(
SettingToggleUi("sound", "Sound", "Short offline cues", _state.value.preferences.soundEnabled),
SettingToggleUi("haptics", "Haptics", "Touch feedback where supported", _state.value.preferences.hapticsEnabled),
SettingToggleUi("motion", "Reduce motion", "Use Bert's static poses", _state.value.preferences.motionMode == "Reduced"),
SettingToggleUi("auto", "Auto-advance", "Move on after feedback", _state.value.preferences.autoAdvanceEnabled),
)
val debugActions: List<DebugActionUi> get() = listOf(
DebugActionUi("score-100", "Set run score to 100"),
DebugActionUi("question-zero", "Set question timer to zero"),
DebugActionUi("session-zero", "Expire session after this question"),
DebugActionUi("reset-first", "Reset first launch"),
DebugActionUi("reset-progress", "Reset progress", destructive = true),
DebugActionUi("reset-all", "Reset all local data", destructive = true),
)
val progressGroups: List<ProgressGroupUi> get() = MasteryLevel.entries.map { level ->
val count = _state.value.mastery.count { it.level == level }
ProgressGroupUi(level.name, count / (catalog.countries.size * 6f), "$count knowledge items")
}
val stampUi: List<StampUi> get() = PassportStampKind.entries.map { kind ->
StampUi(kind.name, kind.displayName(), kind.description(), _state.value.stamps.any { it.kind == kind })
}
private fun updateRunDebug(change: (QuizRunState) -> QuizRunState) {
val run = _state.value.run ?: return
val updated = change(run)
_state.value = _state.value.copy(run = updated)
persistSnapshot(updated)
}
private suspend fun readMastery(): List<Mastery> = database.progressReader().masteryNow().mapNotNull { entity ->
runCatching { json.decodeFromString<Mastery>(entity.recentOutcomes) }.getOrNull()
}
}
private fun destinationFor(run: QuizRunState): Destination = when (run.phase) {
RunPhase.Answering, RunPhase.Paused -> Destination.Quiz
RunPhase.HintChooser -> Destination.Quiz
RunPhase.Feedback -> Destination.Quiz
RunPhase.FunFact -> Destination.Quiz
RunPhase.Completed -> Destination.Results
}
private fun app.bertsatlas.domain.model.Attempt.toEntity(json: Json) = AttemptEntity(
id = id,
runId = runId,
questionId = questionId,
countryIdsJson = json.encodeToString(countryIds.toSet()),
skill = skill.name,
correct = correct,
hinted = hinted,
overtime = overtime,
submittedAnswer = submittedAnswer,
correctAnswer = correctAnswer,
responseMillis = responseMillis,
pointsAwarded = pointsAwarded,
answeredAtEpochMillis = answeredAtEpochMillis,
localEpochDay = localEpochDay,
hintType = hintType?.name,
hintPointsDeducted = hintPointsDeducted,
questionWindowMillis = questionWindowMillis,
questionRemainingMillis = questionRemainingMillis,
)
private fun Mastery.toEntity() = MasteryEntity(
countryId, skill.name, level.name, attempts, unhintedCorrectAnswers, unhintedCorrectRunIds.size,
unhintedCorrectEpochDays.size, nextReviewEpochDay?.toString().orEmpty(), Json.encodeToString(this),
)
private fun QuizRunState.toSnapshotEntity(engine: QuizEngine, json: Json) = engine.snapshotActiveRun(this).let {
ActiveRunSnapshotEntity(schemaVersion = it.schemaVersion, catalogVersion = it.catalogVersion, snapshotJson = json.encodeToString(it))
}
private fun QuizRunState.toRunEntity(result: RunResult? = null) = RunEntity(
id = runId,
duration = configuration.duration.name,
topic = configuration.topic.name,
status = status.name,
score = score,
correctAnswers = attempts.count { it.correct },
answeredQuestions = attempts.size,
totalResponseMillis = attempts.sumOf { it.responseMillis },
bestCorrectStreak = bestCorrectStreak,
completedAtEpochMillis = result?.completedAtEpochMillis,
completedLocalDate = result?.completedAtEpochMillis?.let { attempts.lastOrNull()?.localEpochDay?.toString() },
completedTimeZoneId = result?.let { TimeZone.currentSystemDefault().id },
)
private fun Streak.toEntity() = StreakEntity(currentDays = currentDays, bestDays = bestDays, lastCompletionDate = lastCompletedEpochDay?.toString(), lastCompletionTimeZoneId = TimeZone.currentSystemDefault().id)
private fun StreakEntity?.toDomain() = this?.let { Streak(currentDays, bestDays, lastCompletionDate?.toIntOrNull()) } ?: Streak()
private fun StampEntity.toDomain() = runCatching { PassportStamp(PassportStampKind.valueOf(id), earnedAtEpochMillis) }.getOrNull()
private inline fun <reified T : Enum<T>> enumValueOrDefault(name: String, fallback: T): T =
enumValues<T>().firstOrNull { it.name == name } ?: fallback
private fun PassportStampKind.displayName() = when (this) {
PassportStampKind.FirstCompletedRun -> "First expedition"
PassportStampKind.FlagsFromEveryContinent -> "Around the world"
PassportStampKind.SustainedAccuracy -> "Sharp atlas"
PassportStampKind.ConfusedFlagsMastered -> "Lookalike detective"
PassportStampKind.SevenActiveDays -> "Seven-day curiosity"
}
private fun PassportStampKind.description() = when (this) {
PassportStampKind.FirstCompletedRun -> "Complete a scored run"
PassportStampKind.FlagsFromEveryContinent -> "Recognize flags from all six regions"
PassportStampKind.SustainedAccuracy -> "Reach 80% over 50 answers"
PassportStampKind.ConfusedFlagsMastered -> "Master the confusing flag groups"
PassportStampKind.SevenActiveDays -> "Complete runs on seven consecutive days"
}
@@ -0,0 +1,7 @@
package app.bertsatlas
fun interface DeviceFeedback {
fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean)
}
val NoDeviceFeedback = DeviceFeedback { _, _, _ -> }
@@ -0,0 +1,420 @@
package app.bertsatlas.catalog
import app.bertsatlas.domain.CatalogProvider
import app.bertsatlas.domain.model.AssetLicenseRecord
import app.bertsatlas.domain.model.CapitalArrangement
import app.bertsatlas.domain.model.Catalog
import app.bertsatlas.domain.model.Choice
import app.bertsatlas.domain.model.Continent
import app.bertsatlas.domain.model.CountryRecord
import app.bertsatlas.domain.model.DifficultyTier
import app.bertsatlas.domain.model.EditorialReviewStatus
import app.bertsatlas.domain.model.FactRecord
import app.bertsatlas.domain.model.HintType
import app.bertsatlas.domain.model.Question
import app.bertsatlas.domain.model.QuizTopic
import app.bertsatlas.domain.model.Skill
import app.bertsatlas.domain.model.SourceRecord
import berts_atlas.composeapp.generated.resources.Res
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
class BundledCatalogProvider(
private val readCatalogText: suspend () -> String = {
Res.readBytes("files/catalog/catalog-v1.json").decodeToString()
},
) : CatalogProvider {
private var loadedCatalog: Catalog? = null
override suspend fun loadCatalog(): Catalog {
loadedCatalog?.let { return it }
return CatalogLoader.load(readCatalogText()).also { loadedCatalog = it }
}
}
object CatalogLoader {
fun load(json: String): Catalog {
val source = decoder.decodeFromString<SourceCatalog>(json)
val countriesById = source.countries.associateBy(SourceCountry::id)
val factsByCountry = source.facts
.flatMap { fact -> fact.countryIds.map { it to fact } }
.groupBy({ it.first }, { it.second })
val similarityGroupsByCountry = source.similarityGroups
.flatMap { group -> group.countryIds.map { it to group.id } }
.groupBy({ it.first }, { it.second })
val domainCountries = source.countries.map { country ->
country.toDomain(similarityGroupsByCountry[country.id].orEmpty())
}
val generatedQuestions = source.countries.flatMapIndexed { index, country ->
generatedQuestions(
country = country,
countryIndex = index,
allCountries = source.countries,
similarityGroupIds = similarityGroupsByCountry[country.id].orEmpty(),
similarityGroups = source.similarityGroups,
relatedFacts = factsByCountry[country.id].orEmpty(),
)
}
val curatedQuestions = source.curatedQuestions.map { question ->
question.toDomain(countriesById)
}
return Catalog(
schemaVersion = source.schemaVersion,
contentVersion = source.catalogVersion,
countries = domainCountries,
questions = generatedQuestions + curatedQuestions,
facts = source.facts.map { it.toDomain() },
sources = source.sources.map { it.toDomain() },
assetLicenses = source.assets.map { it.toDomain() },
)
}
private fun generatedQuestions(
country: SourceCountry,
countryIndex: Int,
allCountries: List<SourceCountry>,
similarityGroupIds: List<String>,
similarityGroups: List<SourceSimilarityGroup>,
relatedFacts: List<SourceFact>,
): List<Question> {
val distractors = chooseDistractors(
country = country,
allCountries = allCountries,
similarityGroupIds = similarityGroupIds,
similarityGroups = similarityGroups,
)
val choices = listOf(country) + distractors
val capitalChoices = buildList {
add(country)
addAll(distractors)
addAll(allCountries)
}
.distinctBy { it.capital.acceptedAnswer.lowercase() }
.take(4)
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 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 flagToCountry = Question.MultipleChoice(
id = "generated-flag-to-country-${country.id}",
prompt = "Which country does this flag belong to?",
countryIds = listOf(country.id),
skill = Skill.FlagRecognition,
topic = QuizTopic.MostlyFlags,
difficulty = difficulty,
explanation = "This is the flag of ${country.displayName}.",
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
hintText = commonHintText,
choices = choices.map { Choice(id = it.id, label = it.displayName) },
correctChoiceId = country.id,
promptAssetId = country.flagAssetId,
)
val countryToFlag = Question.MultipleChoice(
id = "generated-country-to-flag-${country.id}",
prompt = "Which flag belongs to ${country.displayName}?",
countryIds = listOf(country.id),
skill = Skill.CountryToFlag,
topic = QuizTopic.MostlyFlags,
difficulty = difficulty,
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)
},
correctChoiceId = country.id,
)
val letterBank = Question.LetterBank(
id = "generated-letter-bank-${country.id}",
prompt = "Spell the country shown by this flag.",
countryIds = listOf(country.id),
skill = Skill.FlagRecognition,
topic = QuizTopic.MostlyFlags,
difficulty = difficulty,
explanation = "The flag belongs to ${country.displayName}.",
supportedHints = sharedHints + setOf(
HintType.RevealCorrectLetter,
HintType.RemoveIncorrectOptions,
),
hintText = commonHintText,
displayAnswer = country.displayName,
acceptedAliases = country.acceptedAliases,
promptAssetId = country.flagAssetId,
decoyLetters = decoyLetters(country.id, countryIndex),
)
val countryToCapital = Question.MultipleChoice(
id = "generated-country-to-capital-${country.id}",
prompt = "What is the capital of ${country.displayName}?",
countryIds = listOf(country.id),
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,
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?",
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,
choices = choices.map { Choice(it.id, it.displayName) },
correctChoiceId = country.id,
)
return listOf(flagToCountry, countryToFlag, letterBank, countryToCapital, capitalToCountry)
}
private fun chooseDistractors(
country: SourceCountry,
allCountries: List<SourceCountry>,
similarityGroupIds: List<String>,
similarityGroups: List<SourceSimilarityGroup>,
): List<SourceCountry> {
val countryIds = buildList {
similarityGroups
.filter { it.id in similarityGroupIds }
.flatMapTo(this) { it.countryIds }
allCountries.filter { it.region == country.region }.mapTo(this) { it.id }
allCountries.mapTo(this) { it.id }
}
.asSequence()
.filter { it != country.id }
.distinct()
.take(3)
.toList()
val countriesById = allCountries.associateBy(SourceCountry::id)
return countryIds.map { countriesById.getValue(it) }
}
private fun SourceCuratedQuestion.toDomain(
countriesById: Map<String, SourceCountry>,
): Question {
require(format == "multiple_choice") { "Unsupported curated question format: $format" }
val domainChoices = choices.mapIndexed { index, choice ->
Choice(id = "$id:choice:$index", label = choice)
}
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 hintText = buildMap {
if (HintType.RevealContinent in mappedHints && primaryCountry != null) {
put(HintType.RevealContinent, primaryCountry.region.toContinent().displayName())
}
if (HintType.FunnyFactualClue in mappedHints) {
put(HintType.FunnyFactualClue, explanation)
}
}
return Question.MultipleChoice(
id = id,
prompt = prompt,
countryIds = countryIds,
skill = if (topic == "flag_symbolism") Skill.FlagSymbolism else Skill.MixedGeography,
topic = QuizTopic.MixedGeography,
difficulty = difficulty.toDifficulty(),
explanation = explanation,
supportedHints = mappedHints,
hintText = hintText,
choices = domainChoices,
correctChoiceId = domainChoices[answerIndex].id,
)
}
private fun SourceCountry.toDomain(similarityGroupIds: List<String>): CountryRecord = CountryRecord(
id = id,
displayName = displayName,
acceptedAliases = acceptedAliases,
continentIds = listOf(region.toContinent()),
flagAssetId = flagAssetId,
capitalArrangement = capital.toDomain(),
similarityGroupIds = similarityGroupIds,
)
private fun SourceCapital.toDomain(): CapitalArrangement = when {
kind == "single" -> CapitalArrangement.Single(acceptedAnswer)
names.size > 1 -> CapitalArrangement.Multiple(
capitals = names,
explanation = requireNotNull(note) { "An exceptional capital arrangement requires a note" },
)
else -> CapitalArrangement.Exception(
explanation = note ?: "$acceptedAnswer has an exceptional capital arrangement.",
)
}
private fun SourceFact.toDomain() = FactRecord(
id = id,
heading = heading,
body = text,
countryIds = countryIds,
sourceIds = sourceIds,
asOf = asOf,
reviewStatus = review.state.toReviewStatus(),
)
private fun SourceReference.toDomain() = SourceRecord(
id = id,
title = title,
url = url,
revision = revision,
accessedOn = retrievedAt,
)
private fun SourceAsset.toDomain() = AssetLicenseRecord(
assetId = id,
sourceId = sourceId,
licenseName = license,
modified = modified,
checksum = sha256,
)
private fun String.toContinent(): Continent = when (this) {
"africa" -> Continent.Africa
"asia" -> Continent.Asia
"europe" -> Continent.Europe
"north_america" -> Continent.NorthAmerica
"south_america" -> Continent.SouthAmerica
"oceania" -> Continent.Oceania
else -> error("Unknown catalog region: $this")
}
private fun Continent.displayName(): String = when (this) {
Continent.Africa -> "Africa"
Continent.Asia -> "Asia"
Continent.Europe -> "Europe"
Continent.NorthAmerica -> "North America"
Continent.SouthAmerica -> "South America"
Continent.Oceania -> "Oceania"
Continent.Antarctica -> "Antarctica"
}
private fun String.toDifficulty(): DifficultyTier = when (this) {
"casual" -> DifficultyTier.Casual
"tricky" -> DifficultyTier.Tricky
"brutal" -> DifficultyTier.Brutal
else -> error("Unknown question difficulty: $this")
}
private fun String.toHintType(): HintType = when (this) {
"reveal_letter" -> HintType.RevealCorrectLetter
"remove_choices", "remove_decoys" -> HintType.RemoveIncorrectOptions
"reveal_continent" -> HintType.RevealContinent
"factual_clue" -> HintType.FunnyFactualClue
else -> error("Unknown hint type: $this")
}
private fun String.toReviewStatus(): EditorialReviewStatus = when (this) {
"draft" -> EditorialReviewStatus.Draft
"fact_checked" -> EditorialReviewStatus.FactChecked
"copy_checked" -> EditorialReviewStatus.CopyChecked
"approved" -> EditorialReviewStatus.Approved
else -> error("Unknown editorial review state: $this")
}
private fun generatedDifficulty(index: Int): DifficultyTier = when (index % 3) {
0 -> DifficultyTier.Casual
1 -> DifficultyTier.Tricky
else -> DifficultyTier.Brutal
}
private fun decoyLetters(countryId: String, index: Int): List<Char> = List(3) { offset ->
DECOY_ALPHABET[(countryId.hashCode() + index + offset * 11).mod(DECOY_ALPHABET.length)]
}
private const val DECOY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
private val decoder = Json { ignoreUnknownKeys = true }
}
@Serializable
private data class SourceCatalog(
val schemaVersion: Int,
val catalogVersion: String,
val sources: List<SourceReference>,
val countries: List<SourceCountry>,
val facts: List<SourceFact>,
val curatedQuestions: List<SourceCuratedQuestion>,
val similarityGroups: List<SourceSimilarityGroup>,
val assets: List<SourceAsset>,
)
@Serializable
private data class SourceReference(
val id: String,
val title: String,
val url: String,
val retrievedAt: String,
val revision: String,
)
@Serializable
private data class SourceCountry(
val id: String,
val displayName: String,
val acceptedAliases: List<String>,
val region: String,
val capital: SourceCapital,
val flagAssetId: String,
)
@Serializable
private data class SourceCapital(
val kind: String,
val names: List<String>,
val acceptedAnswer: String,
val note: String?,
)
@Serializable
private data class SourceFact(
val id: String,
val countryIds: List<String>,
val heading: String,
val text: String,
val sourceIds: List<String>,
val asOf: String?,
val review: SourceReview,
)
@Serializable
private data class SourceCuratedQuestion(
val id: String,
val topic: String,
val difficulty: String,
val format: String,
val prompt: String,
val answer: String,
val choices: List<String>,
val countryIds: List<String>,
val supportedHints: List<String>,
val explanation: String,
)
@Serializable
private data class SourceSimilarityGroup(
val id: String,
val countryIds: List<String>,
)
@Serializable
private data class SourceAsset(
val id: String,
val sourceId: String,
val license: String,
val sha256: String,
val modified: Boolean,
)
@Serializable
private data class SourceReview(val state: String)
@@ -0,0 +1,701 @@
package app.bertsatlas.data
import androidx.room.ConstructedBy
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.RoomDatabaseConstructor
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.bertsatlas.domain.ActiveRunRepository
import app.bertsatlas.domain.ProgressReader
import app.bertsatlas.domain.ProgressWriter
import app.bertsatlas.domain.model.ActiveRunSnapshot
import app.bertsatlas.domain.model.Attempt
import app.bertsatlas.domain.model.Mastery
import app.bertsatlas.domain.model.MasteryLevel
import app.bertsatlas.domain.model.MasteryOutcome
import app.bertsatlas.domain.model.PassportStamp
import app.bertsatlas.domain.model.PassportStampKind
import app.bertsatlas.domain.model.ProgressSummary
import app.bertsatlas.domain.model.QuizDuration
import app.bertsatlas.domain.model.QuizTopic
import app.bertsatlas.domain.model.RunResult
import app.bertsatlas.domain.model.Skill
import app.bertsatlas.domain.model.Streak
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
@Entity(tableName = "attempts")
data class AttemptEntity(
@PrimaryKey val id: String,
val runId: String,
val questionId: String,
val countryIdsJson: String,
val skill: String,
val correct: Boolean,
val hinted: Boolean,
val overtime: Boolean,
val submittedAnswer: String,
val correctAnswer: String,
val responseMillis: Long,
val pointsAwarded: Int,
val answeredAtEpochMillis: Long,
val localEpochDay: Int,
val hintType: String?,
val hintPointsDeducted: Int,
val questionWindowMillis: Long,
val questionRemainingMillis: Long,
) {
@Ignore
@Suppress("UNUSED_PARAMETER")
constructor(
id: String,
runId: String,
questionOrdinal: Int,
questionId: String,
countryId: String,
skill: String,
answeredAtEpochMillis: Long,
localDate: String,
timeZoneId: String,
answer: String,
correctAnswer: String,
correct: Boolean,
overtime: Boolean,
hintType: String?,
hintDebit: Int,
responseMillis: Long,
responseWindowMillis: Long,
awardedPoints: Int,
scoreAfterAnswer: Int,
) : this(
id = id,
runId = runId,
questionId = questionId,
countryIdsJson = Json.encodeToString(SetSerializer(String.serializer()), setOf(countryId)),
skill = skill,
correct = correct,
hinted = hintType != null,
overtime = overtime,
submittedAnswer = answer,
correctAnswer = correctAnswer,
responseMillis = responseMillis,
pointsAwarded = awardedPoints,
answeredAtEpochMillis = answeredAtEpochMillis,
localEpochDay = localDate.toIntOrNull() ?: 0,
hintType = hintType,
hintPointsDeducted = hintDebit,
questionWindowMillis = responseWindowMillis,
questionRemainingMillis = (responseWindowMillis - responseMillis).coerceAtLeast(0),
)
}
@Entity(tableName = "mastery", primaryKeys = ["countryId", "skill"])
data class MasteryEntity(
val countryId: String,
val skill: String,
val level: String,
val attempts: Int,
val correctAnswers: Int,
val unhintedCorrectAnswers: Int,
val unhintedCorrectRunIdsJson: String,
val unhintedCorrectEpochDaysJson: String,
val recentOutcomesJson: String,
val nextReviewEpochDay: Int?,
) {
@Ignore
@Suppress("UNUSED_PARAMETER")
constructor(
countryId: String,
skill: String,
status: String,
attempts: Int,
unhintedCorrect: Int,
distinctCorrectRuns: Int,
distinctCorrectDays: Int,
nextEligibleDate: String,
recentOutcomes: String,
) : this(
countryId = countryId,
skill = skill,
level = status,
attempts = attempts,
correctAnswers = recentOutcomes.decodeMastery()?.correctAnswers ?: unhintedCorrect,
unhintedCorrectAnswers = unhintedCorrect,
unhintedCorrectRunIdsJson = recentOutcomes.decodeMastery()?.unhintedCorrectRunIds?.encodeStringSet() ?: emptySet<String>().encodeStringSet(),
unhintedCorrectEpochDaysJson = recentOutcomes.decodeMastery()?.unhintedCorrectEpochDays?.encodeIntSet() ?: emptySet<Int>().encodeIntSet(),
recentOutcomesJson = recentOutcomes.decodeMastery()?.recentOutcomes?.let {
Json.encodeToString(ListSerializer(MasteryOutcome.serializer()), it)
} ?: "[]",
nextReviewEpochDay = nextEligibleDate.toIntOrNull(),
)
@get:Ignore val status: String get() = level
@get:Ignore val unhintedCorrect: Int get() = unhintedCorrectAnswers
@get:Ignore val distinctCorrectRuns: Int get() = unhintedCorrectRunIdsJson.decodeStringSet().size
@get:Ignore val distinctCorrectDays: Int get() = unhintedCorrectEpochDaysJson.decodeIntSet().size
@get:Ignore val nextEligibleDate: String get() = nextReviewEpochDay?.toString().orEmpty()
@get:Ignore val recentOutcomes: String
get() = toDomain(Json)?.let { Json.encodeToString(Mastery.serializer(), it) }.orEmpty()
}
@Entity(tableName = "runs")
data class RunEntity(
@PrimaryKey val id: String,
val duration: String?,
val topic: String?,
val status: String,
val score: Int,
val correctAnswers: Int,
val answeredQuestions: Int,
val averageResponseMillis: Long,
val bestCorrectStreak: Int,
val improvedMasteryItems: Int,
val revisitCountryIdsJson: String,
val completedAtEpochMillis: Long?,
) {
@Ignore
@Suppress("UNUSED_PARAMETER")
constructor(
id: String,
duration: String,
topic: String,
status: String,
score: Int,
correctAnswers: Int,
answeredQuestions: Int,
totalResponseMillis: Long,
bestCorrectStreak: Int,
completedAtEpochMillis: Long?,
completedLocalDate: String?,
completedTimeZoneId: String?,
) : this(
id = id,
duration = duration,
topic = topic,
status = status,
score = score,
correctAnswers = correctAnswers,
answeredQuestions = answeredQuestions,
averageResponseMillis = if (answeredQuestions == 0) 0 else totalResponseMillis / answeredQuestions,
bestCorrectStreak = bestCorrectStreak,
improvedMasteryItems = 0,
revisitCountryIdsJson = "[]",
completedAtEpochMillis = completedAtEpochMillis,
)
@get:Ignore val totalResponseMillis: Long get() = averageResponseMillis * answeredQuestions
}
@Entity(tableName = "personal_bests", primaryKeys = ["duration", "topic"])
data class PersonalBestEntity(
val duration: String,
val topic: String,
val score: Int,
val runId: String,
)
@Entity(tableName = "streak")
data class StreakEntity(
@PrimaryKey val singletonId: Int = 1,
val currentDays: Int,
val bestDays: Int,
val lastCompletedEpochDay: Int?,
) {
@Ignore
@Suppress("UNUSED_PARAMETER")
constructor(
singletonId: Int = 1,
currentDays: Int,
bestDays: Int,
lastCompletionDate: String?,
lastCompletionTimeZoneId: String?,
) : this(singletonId, currentDays, bestDays, lastCompletionDate?.toIntOrNull())
@get:Ignore val lastCompletionDate: String? get() = lastCompletedEpochDay?.toString()
}
@Entity(tableName = "stamps")
data class StampEntity(
@PrimaryKey val id: String,
val earnedAtEpochMillis: Long,
)
@Entity(tableName = "active_run")
data class ActiveRunSnapshotEntity(
@PrimaryKey val singletonId: Int = 1,
val runId: String,
val schemaVersion: Int,
val catalogVersion: String,
val snapshotJson: String,
) {
@Ignore
constructor(
singletonId: Int = 1,
schemaVersion: Int,
catalogVersion: String,
snapshotJson: String,
) : this(
singletonId = singletonId,
runId = snapshotJson.decodeActiveRunId(),
schemaVersion = schemaVersion,
catalogVersion = catalogVersion,
snapshotJson = snapshotJson,
)
}
@Dao
abstract class RunWriteDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
protected abstract suspend fun insertAttempt(attempt: AttemptEntity)
@Upsert
protected abstract suspend fun upsertMastery(mastery: List<MasteryEntity>)
@Upsert
abstract suspend fun saveActiveRun(snapshot: ActiveRunSnapshotEntity)
@Query("DELETE FROM active_run WHERE runId = :runId")
abstract suspend fun clearActiveRun(runId: String)
@Query("DELETE FROM active_run")
abstract suspend fun clearActiveRun()
@Query("DELETE FROM attempts")
protected abstract suspend fun clearAttempts()
@Query("DELETE FROM mastery")
protected abstract suspend fun clearMastery()
@Query("DELETE FROM runs")
protected abstract suspend fun clearRuns()
@Query("DELETE FROM personal_bests")
protected abstract suspend fun clearPersonalBests()
@Query("DELETE FROM streak")
protected abstract suspend fun clearStreak()
@Query("DELETE FROM stamps")
protected abstract suspend fun clearStamps()
@Upsert
protected abstract suspend fun upsertRun(run: RunEntity)
@Query("SELECT * FROM personal_bests WHERE duration = :duration AND topic = :topic")
protected abstract suspend fun personalBest(duration: String, topic: String): PersonalBestEntity?
@Upsert
protected abstract suspend fun upsertPersonalBest(personalBest: PersonalBestEntity)
@Upsert
protected abstract suspend fun upsertStreak(streak: StreakEntity)
@Upsert
protected abstract suspend fun upsertStamps(stamps: List<StampEntity>)
@Query("SELECT * FROM attempts WHERE runId = :runId ORDER BY answeredAtEpochMillis")
protected abstract suspend fun attemptsForRun(runId: String): List<AttemptEntity>
@Transaction
open suspend fun recordAcceptedAnswer(
attempt: AttemptEntity,
updatedMastery: List<MasteryEntity>,
snapshot: ActiveRunSnapshotEntity,
) {
require(updatedMastery.isNotEmpty()) { "An accepted answer must update at least one mastery item" }
val countryIds = updatedMastery.mapTo(linkedSetOf()) { it.countryId }
require(countryIds.size == updatedMastery.size) { "An answer cannot update the same mastery item twice" }
require(updatedMastery.all { it.skill == attempt.skill }) {
"Mastery updates must use the attempt's skill"
}
require(snapshot.runId == attempt.runId) { "Attempt and snapshot must belong to the same run" }
insertAttempt(attempt.copy(countryIdsJson = countryIds.encodeStringSet()))
upsertMastery(updatedMastery)
saveActiveRun(snapshot)
}
@Transaction
open suspend fun completeRun(
run: RunEntity,
streak: StreakEntity,
stamps: List<StampEntity>,
) {
require(run.status == "Completed")
val duration = requireNotNull(run.duration)
val topic = requireNotNull(run.topic)
upsertRun(run.withDerivedHistory())
val existingBest = personalBest(duration, topic)
if (existingBest == null || run.score > existingBest.score) {
upsertPersonalBest(PersonalBestEntity(duration, topic, run.score, run.id))
}
upsertStreak(streak)
if (stamps.isNotEmpty()) upsertStamps(stamps)
clearActiveRun(run.id)
}
@Transaction
open suspend fun completeRun(
run: RunEntity,
personalBest: PersonalBestEntity?,
streak: StreakEntity,
stamps: List<StampEntity>,
) {
require(run.status == "Completed")
upsertRun(run.withDerivedHistory())
personalBest?.let {
val existingBest = personalBest(it.duration, it.topic)
if (existingBest == null || it.score > existingBest.score) upsertPersonalBest(it)
}
upsertStreak(streak)
if (stamps.isNotEmpty()) upsertStamps(stamps)
clearActiveRun(run.id)
}
private suspend fun RunEntity.withDerivedHistory(): RunEntity {
val attempts = attemptsForRun(id)
if (attempts.isEmpty()) return this
val revisitCountryIds = attempts
.filter { !it.correct || it.hinted }
.flatMap { it.countryIdsJson.decodeStringSet() }
.distinct()
val touchedMasteryItems = attempts
.flatMap { attempt -> attempt.countryIdsJson.decodeStringSet().map { it to attempt.skill } }
.distinct()
.size
return copy(
improvedMasteryItems = maxOf(improvedMasteryItems, touchedMasteryItems),
revisitCountryIdsJson = Json.encodeToString(ListSerializer(String.serializer()), revisitCountryIds),
)
}
@Transaction
open suspend fun abandonRun(runId: String) {
upsertRun(
RunEntity(
id = runId,
duration = null,
topic = null,
status = "Abandoned",
score = 0,
correctAnswers = 0,
answeredQuestions = 0,
averageResponseMillis = 0,
bestCorrectStreak = 0,
improvedMasteryItems = 0,
revisitCountryIdsJson = "[]",
completedAtEpochMillis = null,
),
)
clearActiveRun(runId)
}
@Transaction
open suspend fun abandonRun(run: RunEntity) {
upsertRun(run.copy(status = "Abandoned", completedAtEpochMillis = null))
clearActiveRun(run.id)
}
@Transaction
open suspend fun resetProgress() {
clearAttempts()
clearMastery()
clearRuns()
clearPersonalBests()
clearStreak()
clearStamps()
clearActiveRun()
}
}
@Dao
interface ProgressReadDao {
@Query("SELECT * FROM active_run WHERE singletonId = 1")
suspend fun activeRun(): ActiveRunSnapshotEntity?
@Query("SELECT * FROM mastery ORDER BY countryId, skill")
fun mastery(): Flow<List<MasteryEntity>>
@Query("SELECT * FROM mastery ORDER BY countryId, skill")
suspend fun masteryNow(): List<MasteryEntity>
@Query("SELECT * FROM mastery WHERE countryId = :countryId AND skill = :skill")
suspend fun mastery(countryId: String, skill: String): MasteryEntity?
@Query("SELECT * FROM runs WHERE status = 'Completed' ORDER BY completedAtEpochMillis DESC")
fun completedRuns(): Flow<List<RunEntity>>
@Query("SELECT * FROM runs WHERE status = 'Completed' ORDER BY completedAtEpochMillis DESC")
suspend fun completedRunsNow(): List<RunEntity>
@Query("SELECT * FROM attempts ORDER BY answeredAtEpochMillis DESC")
fun attempts(): Flow<List<AttemptEntity>>
@Query("SELECT * FROM attempts ORDER BY answeredAtEpochMillis DESC")
suspend fun attemptsNow(): List<AttemptEntity>
@Query("SELECT * FROM attempts WHERE runId = :runId ORDER BY answeredAtEpochMillis")
suspend fun attemptsForRun(runId: String): List<AttemptEntity>
@Query("SELECT * FROM personal_bests")
fun personalBests(): Flow<List<PersonalBestEntity>>
@Query("SELECT * FROM personal_bests")
suspend fun personalBestsNow(): List<PersonalBestEntity>
@Query("SELECT * FROM streak WHERE singletonId = 1")
fun streak(): Flow<StreakEntity?>
@Query("SELECT * FROM streak WHERE singletonId = 1")
suspend fun streakNow(): StreakEntity?
@Query("SELECT * FROM stamps ORDER BY earnedAtEpochMillis")
fun stamps(): Flow<List<StampEntity>>
@Query("SELECT * FROM stamps ORDER BY earnedAtEpochMillis")
suspend fun stampsNow(): List<StampEntity>
}
@Database(
entities = [
AttemptEntity::class,
MasteryEntity::class,
RunEntity::class,
PersonalBestEntity::class,
StreakEntity::class,
StampEntity::class,
ActiveRunSnapshotEntity::class,
],
version = 2,
exportSchema = true,
)
@ConstructedBy(BertsAtlasDatabaseConstructor::class)
abstract class BertsAtlasDatabase : RoomDatabase() {
abstract fun runWriter(): RunWriteDao
abstract fun progressReader(): ProgressReadDao
}
class RoomProgressRepository(
database: BertsAtlasDatabase,
private val json: Json = Json,
) : ProgressReader, ProgressWriter, ActiveRunRepository {
private val writer = database.runWriter()
private val reader = database.progressReader()
override suspend fun mastery(): List<Mastery> = reader.masteryNow().mapNotNull { it.toDomain(json) }
override fun observeProgress(): Flow<ProgressSummary> = combine(
reader.attempts(),
reader.mastery(),
reader.streak(),
reader.stamps(),
) { attempts, mastery, streak, stamps ->
ProgressSummary(
totalAnswered = attempts.size,
totalCorrect = attempts.count { it.correct },
mastery = mastery.mapNotNull { it.toDomain(json) },
streak = streak?.toDomain() ?: Streak(),
stamps = stamps.mapNotNull(StampEntity::toDomain),
)
}
override suspend fun recordAcceptedAnswer(
attempt: Attempt,
updatedMastery: List<Mastery>,
activeRunSnapshot: ActiveRunSnapshot,
) {
require(attempt.countryIds.isNotEmpty())
writer.recordAcceptedAnswer(
attempt = attempt.toEntity(json),
updatedMastery = updatedMastery
.filter { it.countryId in attempt.countryIds && it.skill == attempt.skill }
.map { it.toEntity(json) },
snapshot = activeRunSnapshot.toEntity(json),
)
}
override suspend fun recordCompletedRun(
result: RunResult,
streak: Streak,
newlyEarnedStamps: List<PassportStamp>,
) {
writer.completeRun(result.toEntity(json), streak.toEntity(), newlyEarnedStamps.map(PassportStamp::toEntity))
}
override suspend fun recordAbandonedRun(runId: String) = writer.abandonRun(runId)
override suspend fun resetProgress() = writer.resetProgress()
override suspend fun loadActiveRun(): ActiveRunSnapshot? {
val entity = reader.activeRun() ?: return null
return try {
json.decodeFromString<ActiveRunSnapshot>(entity.snapshotJson).takeIf {
it.schemaVersion == entity.schemaVersion &&
it.catalogVersion == entity.catalogVersion &&
it.state.runId == entity.runId
} ?: error("Active-run snapshot metadata does not match its payload")
} catch (_: Exception) {
writer.clearActiveRun(entity.runId)
null
}
}
override suspend fun saveActiveRun(snapshot: ActiveRunSnapshot) = writer.saveActiveRun(snapshot.toEntity(json))
override suspend fun clearActiveRun(runId: String) = writer.clearActiveRun(runId)
}
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object BertsAtlasDatabaseConstructor : RoomDatabaseConstructor<BertsAtlasDatabase> {
override fun initialize(): BertsAtlasDatabase
}
fun RoomDatabase.Builder<BertsAtlasDatabase>.buildBertsAtlasDatabase(): BertsAtlasDatabase =
addMigrations(Migration1To2)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.Default)
.build()
private object Migration1To2 : Migration(1, 2) {
override fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"""CREATE TABLE IF NOT EXISTS `attempts_new` (`id` TEXT NOT NULL, `runId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `countryIdsJson` TEXT NOT NULL, `skill` TEXT NOT NULL, `correct` INTEGER NOT NULL, `hinted` INTEGER NOT NULL, `overtime` INTEGER NOT NULL, `submittedAnswer` TEXT NOT NULL, `correctAnswer` TEXT NOT NULL, `responseMillis` INTEGER NOT NULL, `pointsAwarded` INTEGER NOT NULL, `answeredAtEpochMillis` INTEGER NOT NULL, `localEpochDay` INTEGER NOT NULL, `hintType` TEXT, `hintPointsDeducted` INTEGER NOT NULL, `questionWindowMillis` INTEGER NOT NULL, `questionRemainingMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))""",
)
connection.execSQL(
"""INSERT INTO `attempts_new` (`id`, `runId`, `questionId`, `countryIdsJson`, `skill`, `correct`, `hinted`, `overtime`, `submittedAnswer`, `correctAnswer`, `responseMillis`, `pointsAwarded`, `answeredAtEpochMillis`, `localEpochDay`, `hintType`, `hintPointsDeducted`, `questionWindowMillis`, `questionRemainingMillis`) SELECT `id`, `runId`, `questionId`, `countryIdsJson`, `skill`, `correct`, `hinted`, `overtime`, `submittedAnswer`, `correctAnswer`, `responseMillis`, `pointsAwarded`, `answeredAtEpochMillis`, `localEpochDay`, NULL, 0, 0, 0 FROM `attempts`""",
)
connection.execSQL("DROP TABLE `attempts`")
connection.execSQL("ALTER TABLE `attempts_new` RENAME TO `attempts`")
}
}
private fun Attempt.toEntity(json: Json) = AttemptEntity(
id = id,
runId = runId,
questionId = questionId,
countryIdsJson = json.encodeToString(SetSerializer(String.serializer()), countryIds.toSet()),
skill = skill.name,
correct = correct,
hinted = hinted,
overtime = overtime,
submittedAnswer = submittedAnswer,
correctAnswer = correctAnswer,
responseMillis = responseMillis,
pointsAwarded = pointsAwarded,
answeredAtEpochMillis = answeredAtEpochMillis,
localEpochDay = localEpochDay,
hintType = hintType?.name,
hintPointsDeducted = hintPointsDeducted,
questionWindowMillis = questionWindowMillis,
questionRemainingMillis = questionRemainingMillis,
)
private fun Mastery.toEntity(json: Json) = MasteryEntity(
countryId = countryId,
skill = skill.name,
level = level.name,
attempts = attempts,
correctAnswers = correctAnswers,
unhintedCorrectAnswers = unhintedCorrectAnswers,
unhintedCorrectRunIdsJson = json.encodeToString(SetSerializer(String.serializer()), unhintedCorrectRunIds),
unhintedCorrectEpochDaysJson = json.encodeToString(SetSerializer(Int.serializer()), unhintedCorrectEpochDays),
recentOutcomesJson = json.encodeToString(ListSerializer(MasteryOutcome.serializer()), recentOutcomes),
nextReviewEpochDay = nextReviewEpochDay,
)
private fun MasteryEntity.toDomain(json: Json): Mastery? {
val parsedSkill = Skill.entries.firstOrNull { it.name == skill } ?: return null
val parsedLevel = MasteryLevel.entries.firstOrNull { it.name == level } ?: return null
return runCatching {
Mastery(
countryId = countryId,
skill = parsedSkill,
level = parsedLevel,
attempts = attempts,
correctAnswers = correctAnswers,
unhintedCorrectAnswers = unhintedCorrectAnswers,
unhintedCorrectRunIds = json.decodeFromString(SetSerializer(String.serializer()), unhintedCorrectRunIdsJson),
unhintedCorrectEpochDays = json.decodeFromString(SetSerializer(Int.serializer()), unhintedCorrectEpochDaysJson),
recentOutcomes = json.decodeFromString(ListSerializer(MasteryOutcome.serializer()), recentOutcomesJson),
nextReviewEpochDay = nextReviewEpochDay,
)
}.getOrNull()
}
private fun ActiveRunSnapshot.toEntity(json: Json) = ActiveRunSnapshotEntity(
runId = state.runId,
schemaVersion = schemaVersion,
catalogVersion = catalogVersion,
snapshotJson = json.encodeToString(this),
)
private fun RunResult.toEntity(json: Json) = RunEntity(
id = runId,
duration = configuration.duration.name,
topic = configuration.topic.name,
status = "Completed",
score = score,
correctAnswers = correctAnswers,
answeredQuestions = answeredQuestions,
averageResponseMillis = averageResponseMillis,
bestCorrectStreak = bestCorrectStreak,
improvedMasteryItems = improvedMasteryItems,
revisitCountryIdsJson = json.encodeToString(ListSerializer(String.serializer()), revisitCountryIds),
completedAtEpochMillis = completedAtEpochMillis,
)
private fun Streak.toEntity() = StreakEntity(
currentDays = currentDays,
bestDays = bestDays,
lastCompletedEpochDay = lastCompletedEpochDay,
)
private fun StreakEntity.toDomain() = Streak(
currentDays = currentDays,
bestDays = bestDays,
lastCompletedEpochDay = lastCompletedEpochDay,
)
private fun PassportStamp.toEntity() = StampEntity(kind.name, earnedAtEpochMillis)
private fun StampEntity.toDomain(): PassportStamp? =
PassportStampKind.entries.firstOrNull { it.name == id }?.let { PassportStamp(it, earnedAtEpochMillis) }
private fun String.decodeStringSet(): Set<String> = runCatching {
Json.decodeFromString(SetSerializer(String.serializer()), this)
}.getOrDefault(emptySet())
private fun Set<String>.encodeStringSet(): String =
Json.encodeToString(SetSerializer(String.serializer()), this)
private fun String.decodeIntSet(): Set<Int> = runCatching {
Json.decodeFromString(SetSerializer(Int.serializer()), this)
}.getOrDefault(emptySet())
private fun Set<Int>.encodeIntSet(): String =
Json.encodeToString(SetSerializer(Int.serializer()), this)
private fun String.decodeMastery(): Mastery? = runCatching {
Json.decodeFromString<Mastery>(this)
}.getOrNull()
private fun String.decodeActiveRunId(): String = runCatching {
Json.decodeFromString<ActiveRunSnapshot>(this).state.runId
}.getOrDefault("")
@@ -0,0 +1,164 @@
package app.bertsatlas.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import app.bertsatlas.domain.LaunchPreferences
import app.bertsatlas.domain.PlayerSettings
import app.bertsatlas.domain.PlayerSettingsRepository
import app.bertsatlas.domain.QuizConfigurationPreferences
import app.bertsatlas.domain.model.QuizConfiguration
import app.bertsatlas.domain.model.QuizDuration
import app.bertsatlas.domain.model.QuizTopic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.CancellationException
import okio.Path.Companion.toPath
data class SavedPreferences(
val hasPreviouslyLaunched: Boolean = false,
val duration: String = "FiveMinutes",
val topic: String = "MostlyFlags",
val soundEnabled: Boolean = true,
val hapticsEnabled: Boolean = true,
val motionMode: String = "System",
val autoAdvanceEnabled: Boolean = true,
)
class PreferencesStore(
private val dataStore: DataStore<Preferences>,
) : LaunchPreferences, QuizConfigurationPreferences, PlayerSettingsRepository {
val values: Flow<SavedPreferences> = dataStore.data
.catch { exception ->
if (exception is CancellationException) throw exception
emit(emptyPreferences())
}
.map { preferences ->
SavedPreferences(
hasPreviouslyLaunched = preferences[Keys.previouslyLaunched] ?: false,
duration = preferences[Keys.duration] ?: "FiveMinutes",
topic = preferences[Keys.topic] ?: "MostlyFlags",
soundEnabled = preferences[Keys.soundEnabled] ?: true,
hapticsEnabled = preferences[Keys.hapticsEnabled] ?: true,
motionMode = preferences[Keys.motionMode] ?: "System",
autoAdvanceEnabled = preferences[Keys.autoAdvanceEnabled] ?: true,
)
}
override suspend fun hasLaunchedBefore(): Boolean = values.first().hasPreviouslyLaunched
override suspend fun markLaunched() {
dataStore.edit { it[Keys.previouslyLaunched] = true }
}
override suspend fun loadConfiguration(): QuizConfiguration {
val preferences = values.first()
return QuizConfiguration(
duration = QuizDuration.entries.firstOrNull { it.name == preferences.duration } ?: QuizDuration.FiveMinutes,
topic = QuizTopic.entries.firstOrNull { it.name == preferences.topic } ?: QuizTopic.MostlyFlags,
)
}
override suspend fun saveConfiguration(duration: QuizDuration, topic: QuizTopic) {
saveConfiguration(duration.name, topic.name)
}
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" }
dataStore.edit {
it[Keys.duration] = duration
it[Keys.topic] = topic
}
}
suspend fun setSoundEnabled(enabled: Boolean) {
dataStore.edit { it[Keys.soundEnabled] = enabled }
}
suspend fun setHapticsEnabled(enabled: Boolean) {
dataStore.edit { it[Keys.hapticsEnabled] = enabled }
}
suspend fun setMotionMode(mode: String) {
require(mode == "System" || mode == "Reduced") { "Unknown motion mode: $mode" }
dataStore.edit { it[Keys.motionMode] = mode }
}
suspend fun setAutoAdvanceEnabled(enabled: Boolean) {
dataStore.edit { it[Keys.autoAdvanceEnabled] = enabled }
}
override fun observeSettings(): Flow<PlayerSettings> = values.map { preferences ->
PlayerSettings(
soundEnabled = preferences.soundEnabled,
hapticsEnabled = preferences.hapticsEnabled,
motionEnabled = preferences.motionMode != "Reduced",
automaticAdvanceEnabled = preferences.autoAdvanceEnabled,
)
}
override suspend fun saveSettings(settings: PlayerSettings) {
dataStore.edit {
it[Keys.soundEnabled] = settings.soundEnabled
it[Keys.hapticsEnabled] = settings.hapticsEnabled
it[Keys.motionMode] = if (settings.motionEnabled) "System" else "Reduced"
it[Keys.autoAdvanceEnabled] = settings.automaticAdvanceEnabled
}
}
suspend fun resetFirstLaunch() {
dataStore.edit { it.remove(Keys.previouslyLaunched) }
}
suspend fun resetConfiguration() {
dataStore.edit {
it.remove(Keys.duration)
it.remove(Keys.topic)
}
}
suspend fun resetSettings() {
dataStore.edit {
it.remove(Keys.soundEnabled)
it.remove(Keys.hapticsEnabled)
it.remove(Keys.motionMode)
it.remove(Keys.autoAdvanceEnabled)
}
}
suspend fun resetFirstLaunchAndConfiguration() {
dataStore.edit {
it.remove(Keys.previouslyLaunched)
it.remove(Keys.duration)
it.remove(Keys.topic)
}
}
suspend fun resetAll() {
dataStore.edit { it.clear() }
}
private object Keys {
val previouslyLaunched = booleanPreferencesKey("previously_launched")
val duration = stringPreferencesKey("quiz_duration")
val topic = stringPreferencesKey("quiz_topic")
val soundEnabled = booleanPreferencesKey("sound_enabled")
val hapticsEnabled = booleanPreferencesKey("haptics_enabled")
val motionMode = stringPreferencesKey("motion_mode")
val autoAdvanceEnabled = booleanPreferencesKey("auto_advance_enabled")
}
}
fun createPreferencesStore(filePath: String): PreferencesStore {
val dataStore = PreferenceDataStoreFactory.createWithPath(
produceFile = { filePath.toPath() },
)
return PreferencesStore(dataStore)
}
@@ -0,0 +1,488 @@
package app.bertsatlas.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxScope
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.aspectRatio
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.math.min
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)
@Composable
fun AtlasScreen(
modifier: Modifier = Modifier,
scrollable: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
content: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier.fillMaxSize().background(AtlasTheme.colors.parchment),
contentAlignment = Alignment.TopCenter,
) {
val contentModifier = Modifier
.fillMaxWidth()
.widthIn(max = 760.dp)
.padding(contentPadding)
.let { if (scrollable) it.verticalScroll(rememberScrollState()) else it }
Box(modifier = contentModifier, content = content)
}
}
@Composable
fun AtlasText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = AtlasTheme.typography.body,
color: Color = AtlasTheme.colors.plum,
textAlign: TextAlign? = null,
) {
BasicText(
text = text,
modifier = modifier,
style = if (textAlign == null) style.copy(color = color) else style.copy(color = color, textAlign = textAlign),
)
}
@Composable
fun AtlasHeading(text: String, modifier: Modifier = Modifier, centered: Boolean = false) {
AtlasText(
text = text,
modifier = modifier.semantics { heading() },
style = AtlasTheme.typography.title,
textAlign = if (centered) TextAlign.Center else null,
)
}
@Composable
fun AtlasButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: AtlasButtonStyle = AtlasButtonStyle.Primary,
enabled: Boolean = true,
contentDescription: String = text,
) {
val colors = AtlasTheme.colors
val background = when (style) {
AtlasButtonStyle.Primary -> colors.atlasBlue
AtlasButtonStyle.Secondary -> colors.paper
AtlasButtonStyle.Quiet -> Color.Transparent
AtlasButtonStyle.Destructive -> colors.error
}
val foreground = when (style) {
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
Box(
modifier = modifier
.heightIn(min = 52.dp)
.then(border)
.background(if (enabled) background else colors.outline, ControlShape)
.semantics {
role = Role.Button
this.contentDescription = contentDescription
if (!enabled) disabled()
}
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 22.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
AtlasText(text, style = AtlasTheme.typography.label, color = foreground, textAlign = TextAlign.Center)
}
}
@Composable
fun AtlasCard(
modifier: Modifier = Modifier,
contentDescription: String? = null,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(AtlasTheme.colors.paper, ScreenShape)
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .45f), ScreenShape)
.then(if (contentDescription == null) Modifier else Modifier.semantics(mergeDescendants = true) { this.contentDescription = contentDescription })
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
content = { content() },
)
}
@Composable
fun AtlasChoice(
choice: ChoiceUi,
onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = AtlasTheme.colors
val background = when (choice.state) {
AnswerState.Available -> colors.paper
AnswerState.Selected -> colors.mustard.copy(alpha = .28f)
AnswerState.Correct -> colors.success.copy(alpha = .16f)
AnswerState.Incorrect -> colors.error.copy(alpha = .14f)
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.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"
}
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.background(background, ControlShape)
.border(2.dp, outline, ControlShape)
.semantics(mergeDescendants = true) {
role = Role.Button
contentDescription = choice.label
stateDescription = stateText
if (!choice.enabled) disabled()
}
.clickable(enabled = choice.enabled) { onClick(choice.id) }
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
choice.flag?.let {
FlagImage(
flag = it,
modifier = Modifier.width(82.dp),
accessible = false,
showFallbackLabel = false,
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (choice.flag == null) AtlasText(choice.label, style = AtlasTheme.typography.label)
if (choice.flag == null) {
choice.supportingText?.let { AtlasText(it, style = AtlasTheme.typography.detail, color = colors.mutedPlum) }
}
if (choice.state == AnswerState.Correct) AtlasText("✓ Correct", style = AtlasTheme.typography.detail, color = colors.success)
if (choice.state == AnswerState.Incorrect) AtlasText("× Your answer", style = AtlasTheme.typography.detail, color = colors.error)
}
}
}
@OptIn(InternalResourceApi::class)
@Composable
fun FlagImage(
flag: FlagUi,
modifier: Modifier = Modifier,
accessible: Boolean = true,
showFallbackLabel: Boolean = true,
) {
val alpha2 = flag.alpha2.lowercase()
val validCode = alpha2.length == 2 && alpha2.all { it in 'a'..'z' }
val resource = remember(alpha2) {
DrawableResource(
id = "flag:$alpha2",
items = setOf(
ResourceItem(
qualifiers = setOf(),
path = "composeResources/berts_atlas.composeapp.generated.resources/files/flag-renders/$alpha2.png",
offset = -1,
size = -1,
),
),
)
}
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)
if (validCode) {
Image(
painter = painterResource(resource),
contentDescription = null,
modifier = imageModifier,
contentScale = ContentScale.Fit,
)
} else {
FlagFallback(flag.countryName, imageModifier, showFallbackLabel)
}
}
@Composable
private fun FlagFallback(countryName: String, modifier: Modifier, showLabel: Boolean) {
Box(modifier, contentAlignment = Alignment.Center) {
AtlasText("⚑", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.atlasBlue)
if (showLabel) {
AtlasText(
countryName,
Modifier.align(Alignment.BottomCenter).padding(3.dp),
style = AtlasTheme.typography.detail,
color = AtlasTheme.colors.mutedPlum,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
fun AtlasToggle(setting: SettingToggleUi, onToggle: (String, Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.semantics(mergeDescendants = true) {
role = Role.Switch
stateDescription = if (setting.checked) "On" else "Off"
if (!setting.enabled) disabled()
}
.clickable(enabled = setting.enabled) { onToggle(setting.id, !setting.checked) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
AtlasText(setting.title, style = AtlasTheme.typography.label)
AtlasText(setting.description, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
}
Box(
Modifier.size(width = 54.dp, height = 32.dp)
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.outline, CircleShape)
.padding(4.dp),
contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(Modifier.size(24.dp).background(AtlasTheme.colors.white, CircleShape))
}
}
}
@Composable
fun AtlasProgress(label: String, progress: Float, detail: String, modifier: Modifier = Modifier) {
val value = progress.coerceIn(0f, 1f)
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
AtlasText(label, style = AtlasTheme.typography.label)
AtlasText(detail, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
}
Box(
Modifier.fillMaxWidth().height(12.dp).background(AtlasTheme.colors.outline.copy(alpha = .24f), CircleShape)
.semantics { progressBarRangeInfo = androidx.compose.ui.semantics.ProgressBarRangeInfo(value, 0f..1f) },
) {
Box(Modifier.fillMaxWidth(value).height(12.dp).background(AtlasTheme.colors.atlasBlue, CircleShape))
}
}
}
@Composable
fun BertIllustration(
pose: BertPose,
modifier: Modifier = Modifier,
contentDescription: String = "Bert, the curious atlas brain",
reducedMotion: Boolean = AtlasTheme.reducedMotion,
) {
if (reducedMotion) {
StaticBertIllustration(pose, modifier, contentDescription)
return
}
val compositionResult = rememberLottieComposition(pose) {
LottieCompositionSpec.JsonString(
Res.readBytes("files/${pose.animationFileName}").decodeToString(),
)
}
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(),
)
}
}
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?,
) {
val colors = AtlasTheme.colors
val accessibleModifier = if (contentDescription == null) Modifier else Modifier.semantics { this.contentDescription = contentDescription }
Canvas(modifier = modifier.size(180.dp).then(accessibleModifier)) {
val scale = min(size.width, size.height) / 180f
fun p(x: Float, y: Float) = Offset(x * scale, y * scale)
val brain = Path().apply {
moveTo(38f * scale, 96f * scale)
cubicTo(18f * scale, 85f * scale, 25f * scale, 55f * scale, 47f * scale, 52f * scale)
cubicTo(48f * scale, 27f * scale, 80f * scale, 20f * scale, 94f * scale, 38f * scale)
cubicTo(113f * scale, 23f * scale, 143f * scale, 38f * scale, 139f * scale, 63f * scale)
cubicTo(159f * scale, 74f * scale, 151f * scale, 105f * scale, 133f * scale, 110f * scale)
cubicTo(127f * scale, 139f * scale, 91f * scale, 146f * scale, 74f * scale, 128f * scale)
cubicTo(53f * scale, 139f * scale, 29f * scale, 119f * scale, 38f * scale, 96f * scale)
close()
}
drawPath(brain, colors.coral)
drawPath(brain, colors.plum, style = Stroke(width = 4f * scale))
listOf(Rect(p(43f, 67f), Size(43f * scale, 34f * scale)), Rect(p(94f, 67f), Size(43f * scale, 34f * scale))).forEach {
drawRoundRect(
color = colors.paper,
topLeft = it.topLeft,
size = it.size,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(10f * scale),
)
drawRoundRect(
color = colors.plum,
topLeft = it.topLeft,
size = it.size,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(10f * scale),
style = Stroke(5f * scale),
)
}
drawLine(colors.plum, p(86f, 82f), p(94f, 82f), 5f * scale, StrokeCap.Round)
drawCircle(colors.plum, 4f * scale, p(65f, 84f))
drawCircle(colors.plum, 4f * scale, p(115f, 84f))
val mouthY = if (pose == BertPose.Incorrect) 121f else 113f
val mouth = Path().apply {
moveTo(72f * scale, mouthY * scale)
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
}
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)
}
}
@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()
}
}
@Composable
fun StatRow(stats: List<StatUi>, modifier: Modifier = Modifier) {
BoxWithConstraints(modifier.fillMaxWidth()) {
if (maxWidth < 440.dp && stats.size > 2) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
stats.forEach { stat -> StatCell(stat, Modifier.fillMaxWidth()) }
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
stats.forEach { stat -> StatCell(stat, Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun StatCell(stat: StatUi, modifier: Modifier) {
Column(
modifier.background(AtlasTheme.colors.paper, ControlShape).padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AtlasText(stat.value, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center)
AtlasText(stat.label, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
}
}
@Composable
fun SectionGap() = Spacer(Modifier.height(18.dp))
@@ -0,0 +1,101 @@
package app.bertsatlas.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import berts_atlas.composeapp.generated.resources.Res
import berts_atlas.composeapp.generated.resources.atkinson_hyperlegible_next_bold
import berts_atlas.composeapp.generated.resources.atkinson_hyperlegible_next_regular
import berts_atlas.composeapp.generated.resources.atkinson_hyperlegible_next_semibold
import org.jetbrains.compose.resources.Font
@Immutable
data class AtlasColors(
val parchment: Color,
val paper: Color,
val plum: Color,
val mutedPlum: Color,
val atlasBlue: Color,
val atlasBluePressed: Color,
val coral: Color,
val mustard: Color,
val success: Color,
val error: Color,
val outline: Color,
val white: Color,
)
@Immutable
data class AtlasTypography(
val display: TextStyle,
val title: TextStyle,
val heading: TextStyle,
val body: TextStyle,
val label: TextStyle,
val detail: TextStyle,
val timer: TextStyle,
)
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),
)
private fun atlasTypography(fontFamily: FontFamily) = AtlasTypography(
display = TextStyle(fontFamily = fontFamily, fontSize = 38.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold),
title = TextStyle(fontFamily = fontFamily, fontSize = 30.sp, lineHeight = 37.sp, fontWeight = FontWeight.Bold),
heading = TextStyle(fontFamily = fontFamily, fontSize = 22.sp, lineHeight = 29.sp, fontWeight = FontWeight.Bold),
body = TextStyle(fontFamily = fontFamily, fontSize = 18.sp, lineHeight = 26.sp),
label = TextStyle(fontFamily = fontFamily, fontSize = 17.sp, lineHeight = 23.sp, fontWeight = FontWeight.SemiBold),
detail = TextStyle(fontFamily = fontFamily, fontSize = 15.sp, lineHeight = 21.sp),
timer = TextStyle(fontFamily = fontFamily, fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold),
)
private val LocalAtlasColors = staticCompositionLocalOf { LightAtlasColors }
private val LocalAtlasTypography = staticCompositionLocalOf { atlasTypography(FontFamily.Default) }
private val LocalReducedMotion = staticCompositionLocalOf { false }
object AtlasTheme {
val colors: AtlasColors
@Composable get() = LocalAtlasColors.current
val typography: AtlasTypography
@Composable get() = LocalAtlasTypography.current
val reducedMotion: Boolean
@Composable get() = LocalReducedMotion.current
}
@Composable
fun BertsAtlasTheme(
reducedMotion: Boolean = false,
content: @Composable () -> Unit,
) {
val fontFamily = FontFamily(
Font(Res.font.atkinson_hyperlegible_next_regular, FontWeight.Normal),
Font(Res.font.atkinson_hyperlegible_next_semibold, FontWeight.SemiBold),
Font(Res.font.atkinson_hyperlegible_next_bold, FontWeight.Bold),
)
CompositionLocalProvider(
LocalAtlasColors provides LightAtlasColors,
LocalAtlasTypography provides atlasTypography(fontFamily),
LocalReducedMotion provides reducedMotion,
content = content,
)
}
@@ -0,0 +1,127 @@
package app.bertsatlas.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun HomeScreen(
isFirstLaunch: Boolean,
streakDays: Int = 0,
progressSummary: String = "",
onStart: () -> Unit,
onOptions: () -> Unit = {},
onProgress: () -> Unit = {},
onSettings: () -> Unit = {},
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
BertIllustration(BertPose.Idle, contentDescription = "Bert smiles behind oversized glasses")
AtlasText(
"Bert's Atlas",
Modifier.semantics { heading() },
AtlasTheme.typography.display,
textAlign = TextAlign.Center,
)
AtlasText("Getting nerdier every day.", color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
Spacer(Modifier.height(12.dp))
AtlasButton("Start", onStart, Modifier.fillMaxWidth().widthIn(max = 420.dp))
if (!isFirstLaunch) {
AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary)
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)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
AtlasText(if (streakDays == 1) "day curious" else "days curious", 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)
}
}
}
}
@Composable
fun OptionsScreen(
step: Int,
choices: List<ChoiceUi>,
onChoose: (String) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val isTimeStep = step <= 1
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar(if (isTimeStep) "Options · 1 of 2" else "Options · 2 of 2", onBack)
SectionGap()
BertIllustration(if (isTimeStep) BertPose.Idle else BertPose.Fact, Modifier.align(Alignment.CenterHorizontally))
AtlasHeading(
if (isTimeStep) "How much time have you got?" else "What are we nerding out about?",
Modifier.fillMaxWidth(),
centered = true,
)
AtlasText(
if (isTimeStep) "The clock only runs while you're answering." else "Difficulty adapts quietly as you play.",
Modifier.fillMaxWidth(),
color = AtlasTheme.colors.mutedPlum,
textAlign = TextAlign.Center,
)
choices.forEach { AtlasChoice(it, onChoose) }
}
}
}
@Composable
fun BootstrapScreen(message: String = "Opening the atlas…", modifier: Modifier = Modifier) {
AtlasScreen(modifier, scrollable = false) {
Column(Modifier.fillMaxWidth().align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
BertIllustration(BertPose.Idle)
AtlasText(message, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center)
}
}
}
@Composable
fun RecoveryScreen(
questionSummary: String,
score: Int,
timeRemaining: String,
onResume: () -> Unit,
onAbandon: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
BertIllustration(BertPose.Hint, contentDescription = "Bert holds your place in the atlas")
AtlasHeading("Your place is still marked", Modifier.fillMaxWidth(), centered = true)
AtlasText("Pick up on the exact question where you left off.", textAlign = TextAlign.Center)
AtlasCard(contentDescription = "Saved run. Score $score. Time remaining $timeRemaining. $questionSummary") {
StatRow(listOf(StatUi("Score", score.toString()), StatUi("Time left", timeRemaining)))
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)
AtlasText("Answered questions still count toward what Bert revisits next.", style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
}
}
}
@@ -0,0 +1,262 @@
package app.bertsatlas.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
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(),
hintAvailable: Boolean,
unlimited: Boolean,
canSubmit: Boolean = false,
onChoice: (String) -> Unit = {},
onLetter: (String) -> Unit = {},
onRemoveLetter: (String) -> Unit = {},
onClearLetters: () -> Unit = {},
onSubmit: () -> Unit = {},
onHint: () -> Unit,
onFinish: () -> Unit = {},
onLeave: () -> Unit,
modifier: Modifier = Modifier,
promptFlag: FlagUi? = null,
) {
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)) {
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)
}
}
}
if (timerExpired) {
AtlasText("Overtime · you can still answer for half points", color = AtlasTheme.colors.error, style = AtlasTheme.typography.label)
}
if (promptFlag != null || imageDescription != null) {
AtlasCard(contentDescription = imageDescription ?: "Flag of ${promptFlag?.countryName}") {
Box(Modifier.fillMaxWidth().heightIn(min = 116.dp), contentAlignment = Alignment.Center) {
if (promptFlag != null) {
FlagImage(promptFlag, Modifier.fillMaxWidth().heightIn(min = 116.dp))
} 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)
if (choices.isNotEmpty()) ResponsiveChoices(choices, onChoice)
if (letterSlots.isNotEmpty() || letterTiles.isNotEmpty()) {
LetterBank(letterSlots, letterTiles, onLetter, onRemoveLetter)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AtlasButton("Clear", onClearLetters, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = letterSlots.any { it.character != null && !it.fixed })
AtlasButton("Submit", onSubmit, Modifier.weight(1f), enabled = canSubmit)
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AtlasButton("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)
AtlasText(label, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
}
}
@Composable
private fun ResponsiveChoices(choices: List<ChoiceUi>, onChoice: (String) -> Unit) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 560.dp) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
choices.chunked(2).forEach { rowChoices ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowChoices.forEach { AtlasChoice(it, onChoice, Modifier.weight(1f)) }
if (rowChoices.size == 1) Box(Modifier.weight(1f))
}
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { choices.forEach { AtlasChoice(it, onChoice) } }
}
}
}
@Composable
private fun LetterBank(
slots: List<LetterSlotUi>,
tiles: List<LetterTileUi>,
onLetter: (String) -> Unit,
onRemoveLetter: (String) -> Unit,
) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
WrappingCells(slots.size) { index ->
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))
.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) }
}
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))
.semantics {
role = Role.Button
contentDescription = "Letter ${tile.letter}"
stateDescription = if (available) "Available" else "Placed"
}
.clickable(enabled = available) { onLetter(tile.id) },
contentAlignment = Alignment.Center,
) { AtlasText(tile.letter, style = AtlasTheme.typography.label) }
}
}
}
@Composable
private fun WrappingCells(count: Int, content: @Composable (Int) -> Unit) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val columns = (maxWidth / 60.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)) {
for (index in indices) content(index)
}
}
}
}
}
@Composable
fun HintChooserScreen(
score: Int,
hints: List<ChoiceUi>,
onChooseHint: (String) -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
ScreenTopBar("Choose a hint", onCancel)
BertIllustration(BertPose.Hint)
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())))
}
hints.forEach { AtlasChoice(it, onChooseHint) }
}
}
}
@Composable
fun FeedbackScreen(
correct: Boolean,
overtime: Boolean,
heading: String,
selectedAnswer: String?,
correctAnswer: String,
pointsAwarded: Int,
explanation: String?,
autoAdvanceMessage: String?,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
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")
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
AtlasText(
if (correct) "+$pointsAwarded points" else "No points this time",
style = AtlasTheme.typography.heading,
color = if (correct) AtlasTheme.colors.success else AtlasTheme.colors.error,
)
AtlasCard(contentDescription = "$status. Correct answer: $correctAnswer") {
if (!correct && selectedAnswer != null) AtlasText("Your answer · $selectedAnswer", color = AtlasTheme.colors.error, style = AtlasTheme.typography.label)
AtlasText("Correct answer · $correctAnswer", color = AtlasTheme.colors.success, style = AtlasTheme.typography.label)
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())
autoAdvanceMessage?.let { AtlasText(it, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center) }
}
}
}
@Composable
fun FunFactScreen(
heading: String,
fact: String,
relatedTo: String,
artDescription: String? = null,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
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)
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())
}
}
}
@@ -0,0 +1,128 @@
package app.bertsatlas.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ResultsScreen(
score: Int,
correctAnswers: Int,
answeredQuestions: Int,
accuracy: String,
averageResponseTime: String,
bestCorrectStreak: Int,
masteryImproved: Int,
revisitCountries: List<String>,
newPersonalBest: Boolean,
onPlayAgain: () -> Unit,
onHome: () -> Unit,
modifier: Modifier = Modifier,
) {
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)
AtlasText(score.toString(), style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
AtlasText("points", color = AtlasTheme.colors.mutedPlum)
StatRow(
listOf(
StatUi("Correct", "$correctAnswers/$answeredQuestions"),
StatUi("Accuracy", accuracy),
StatUi("Best streak", bestCorrectStreak.toString()),
),
)
AtlasCard {
AtlasText("Run notes", style = AtlasTheme.typography.heading)
AtlasText("Average response · $averageResponseTime")
AtlasText("New or improved knowledge · $masteryImproved")
}
if (revisitCountries.isNotEmpty()) {
AtlasCard(contentDescription = "Worth another look: ${revisitCountries.joinToString()}") {
AtlasText("Worth another look", style = AtlasTheme.typography.heading)
AtlasText(revisitCountries.joinToString(" · "), color = AtlasTheme.colors.mutedPlum)
}
}
AtlasButton("Play again", onPlayAgain, Modifier.fillMaxWidth())
AtlasButton("Home", onHome, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
}
}
}
@Composable
fun ProgressScreen(
currentStreak: Int,
bestStreak: Int,
overallAccuracy: String,
totalAnswered: Int,
masteryGroups: List<ProgressGroupUi>,
strongCountries: List<String>,
revisitCountries: List<String>,
stamps: List<StampUi>,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar("Your atlas", onBack)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
BertIllustration(BertPose.Streak, Modifier.weight(.8f), "Bert celebrates your curiosity streak")
Column(Modifier.weight(1.2f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
AtlasText("$currentStreak day streak", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.coral)
AtlasText("Best · $bestStreak days", color = AtlasTheme.colors.mutedPlum)
}
}
StatRow(listOf(StatUi("Accuracy", overallAccuracy), StatUi("Questions", totalAnswered.toString())))
AtlasCard {
AtlasText("Things you're getting to know", style = AtlasTheme.typography.heading)
masteryGroups.forEach { AtlasProgress(it.label, it.progress, it.detail) }
}
CountryNotes("Strong lately", strongCountries, AtlasTheme.colors.success)
CountryNotes("Worth revisiting", revisitCountries, AtlasTheme.colors.mustard)
AtlasText("Passport stamps", style = AtlasTheme.typography.heading)
stamps.forEach { StampCard(it) }
}
}
}
@Composable
private fun CountryNotes(title: String, countries: List<String>, accent: androidx.compose.ui.graphics.Color) {
if (countries.isEmpty()) return
AtlasCard(contentDescription = "$title: ${countries.joinToString()}") {
AtlasText(title, style = AtlasTheme.typography.label, color = accent)
AtlasText(countries.joinToString(" · "))
}
}
@Composable
private fun StampCard(stamp: StampUi) {
val accent = if (stamp.earned) AtlasTheme.colors.atlasBlue else AtlasTheme.colors.outline
Row(
Modifier.fillMaxWidth().heightIn(min = 76.dp)
.background(AtlasTheme.colors.paper, RoundedCornerShape(16.dp))
.border(2.dp, accent, RoundedCornerShape(16.dp))
.semantics(mergeDescendants = true) { contentDescription = "${if (stamp.earned) "Earned" else "Not yet earned"} stamp: ${stamp.title}. ${stamp.description}" }
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
AtlasText(if (stamp.earned) "★" else "◇", style = AtlasTheme.typography.title, color = accent, textAlign = TextAlign.Center)
Column(Modifier.weight(1f)) {
AtlasText(stamp.title, style = AtlasTheme.typography.label)
AtlasText(stamp.description, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
}
}
}
@@ -0,0 +1,159 @@
package app.bertsatlas.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
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
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SettingsScreen(
settings: List<SettingToggleUi>,
version: String,
debugEntryEnabled: Boolean,
onToggle: (String, Boolean) -> Unit,
onPrivacy: () -> Unit,
onCredits: () -> Unit,
onReset: () -> Unit,
onOpenDebug: () -> Unit = {},
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
ScreenTopBar("Settings", onBack)
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)
AtlasText(
"Bert's Atlas $version",
Modifier.fillMaxWidth()
.semantics {
contentDescription = "Bert's Atlas version $version"
if (debugEntryEnabled) role = Role.Button
}
.combinedClickable(
enabled = debugEntryEnabled,
onClick = {},
onLongClick = onOpenDebug,
)
.padding(16.dp),
style = AtlasTheme.typography.detail,
color = AtlasTheme.colors.mutedPlum,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
fun ResetConfirmationScreen(
onReset: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar("Reset local data", onCancel)
BertIllustration(BertPose.Incorrect, Modifier.fillMaxWidth())
AtlasHeading("Clear this atlas?")
AtlasText("This removes your progress, streaks, stamps, settings, and any saved run from this device. It cannot be undone.")
AtlasButton("Reset everything", onReset, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
AtlasButton("Keep my atlas", onCancel, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
}
}
}
@Composable
fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
InformationScreen(
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.",
"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,
modifier = modifier,
)
}
@Composable
fun CreditsScreen(
catalogVersion: String,
credits: List<String>,
licenses: List<String>,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar("Credits and licenses", onBack)
AtlasText("Bert, the writing, and the interface artwork are original to Bert's Atlas.")
AtlasCard {
AtlasText("Content", style = AtlasTheme.typography.heading)
AtlasText("Catalog version $catalogVersion")
credits.forEach { AtlasText(it, style = AtlasTheme.typography.detail) }
}
AtlasCard {
AtlasText("Open-source licenses", style = AtlasTheme.typography.heading)
AtlasText("Atkinson Hyperlegible Next · SIL Open Font License 1.1", style = AtlasTheme.typography.detail)
licenses.forEach { AtlasText(it, style = AtlasTheme.typography.detail) }
}
}
}
}
@Composable
private fun InformationScreen(title: String, paragraphs: List<String>, onBack: () -> Unit, modifier: Modifier = Modifier) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar(title, onBack)
paragraphs.forEach { AtlasText(it) }
}
}
}
@Composable
fun DebugPanelScreen(
buildVersion: String,
catalogVersion: String,
activeSnapshot: String,
actions: List<DebugActionUi>,
onAction: (String) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
ScreenTopBar("Debug QA", onClose)
AtlasCard {
AtlasText("Build $buildVersion", style = AtlasTheme.typography.label)
AtlasText("Catalog $catalogVersion")
AtlasText(activeSnapshot, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
}
actions.forEach { action ->
AtlasButton(
text = action.title + (action.detail?.let { " · $it" } ?: ""),
onClick = { onAction(action.id) },
modifier = Modifier.fillMaxWidth(),
style = if (action.destructive) AtlasButtonStyle.Destructive else AtlasButtonStyle.Secondary,
)
}
}
}
}
@@ -0,0 +1,76 @@
package app.bertsatlas.ui
import androidx.compose.runtime.Immutable
enum class BertPose { Idle, Correct, Incorrect, Overtime, Hint, Fact, Results, Streak, Stamp }
enum class AtlasButtonStyle { Primary, Secondary, Quiet, Destructive }
enum class AnswerState { Available, Selected, Correct, Incorrect, Eliminated }
@Immutable
data class FlagUi(
val alpha2: String,
val countryName: String,
)
@Immutable
data class ChoiceUi(
val id: String,
val label: String,
val supportingText: String? = null,
val state: AnswerState = AnswerState.Available,
val enabled: Boolean = true,
val flag: FlagUi? = null,
)
@Immutable
data class LetterTileUi(
val id: String,
val letter: String,
val placedSlot: Int? = null,
val enabled: Boolean = true,
)
@Immutable
data class LetterSlotUi(
val position: Int,
val character: String? = null,
val tileId: String? = null,
val fixed: Boolean = false,
)
@Immutable
data class StatUi(val label: String, val value: String)
@Immutable
data class ProgressGroupUi(
val label: String,
val progress: Float,
val detail: String,
)
@Immutable
data class StampUi(
val id: String,
val title: String,
val description: String,
val earned: Boolean,
)
@Immutable
data class SettingToggleUi(
val id: String,
val title: String,
val description: String,
val checked: Boolean,
val enabled: Boolean = true,
)
@Immutable
data class DebugActionUi(
val id: String,
val title: String,
val detail: String? = null,
val destructive: Boolean = false,
)
@@ -0,0 +1,13 @@
package app.bertsatlas
import kotlinx.cinterop.ExperimentalForeignApi
import platform.AudioToolbox.AudioServicesPlaySystemSound
import platform.AudioToolbox.kSystemSoundID_Vibrate
@OptIn(ExperimentalForeignApi::class)
class IosDeviceFeedback : DeviceFeedback {
override fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean) {
if (soundEnabled) AudioServicesPlaySystemSound(if (correct) 1104u else 1053u)
if (hapticsEnabled) AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
}
}
@@ -1,6 +1,35 @@
package app.bertsatlas
import androidx.compose.ui.window.ComposeUIViewController
import app.bertsatlas.data.buildBertsAtlasDatabase
import app.bertsatlas.data.createPreferencesStore
import app.bertsatlas.data.getDatabaseBuilder
import kotlin.experimental.ExperimentalNativeApi
import kotlin.native.Platform
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
fun MainViewController() = ComposeUIViewController { App() }
private var activeController: AppController? = null
@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class)
fun MainViewController(): platform.UIKit.UIViewController {
val directory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = true,
error = null,
)
val preferencesPath = requireNotNull(directory?.path) + "/berts-atlas.preferences_pb"
val database = getDatabaseBuilder().buildBertsAtlasDatabase()
val preferences = createPreferencesStore(preferencesPath)
return ComposeUIViewController {
App(database, preferences, debugBuild = Platform.isDebugBinary, deviceFeedback = IosDeviceFeedback()) { activeController = it }
}
}
fun pauseApp() = activeController?.pauseForInterruption()
fun resumeApp() = activeController?.resumeAfterInterruption()
@@ -0,0 +1,21 @@
package app.bertsatlas.data
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
@OptIn(ExperimentalForeignApi::class)
fun getDatabaseBuilder(): RoomDatabase.Builder<BertsAtlasDatabase> {
val directory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = true,
error = null,
)
val databasePath = requireNotNull(directory?.path) + "/berts-atlas.db"
return Room.databaseBuilder(name = databasePath)
}
+38
View File
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
09FED4B446BD58DF150C455A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */; };
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 */
@@ -15,6 +16,7 @@
668CECA83ACC03A83C85A623 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
/* End PBXFileReference section */
@@ -35,6 +37,7 @@
children = (
C117AD187833E2513BD2F553 /* Info.plist */,
668CECA83ACC03A83C85A623 /* iOSApp.swift */,
C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */,
);
path = iosApp;
sourceTree = "<group>";
@@ -71,7 +74,9 @@
isa = PBXNativeTarget;
buildConfigurationList = 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
63B596C0F42DA54670428EA8 /* Sync Compose resources */,
297B59D9CE634556D6007C40 /* Sources */,
1623B02D10928C8101C1F806 /* Resources */,
EF8A6E9BFD470986D9D5C9B5 /* Frameworks */,
);
buildRules = (
@@ -115,6 +120,39 @@
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1623B02D10928C8101C1F806 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
09FED4B446BD58DF150C455A /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
63B596C0F42DA54670428EA8 /* Sync Compose resources */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Sync Compose resources";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "cd \"$SRCROOT/..\"\n./gradlew :composeApp:syncComposeResourcesForIos\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
297B59D9CE634556D6007C40 /* Sources */ = {
isa = PBXSourcesBuildPhase;
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
+9
View File
@@ -3,11 +3,20 @@ import SwiftUI
@main
struct BertAtlasApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ComposeView()
.ignoresSafeArea(.keyboard)
}
.onChange(of: scenePhase) { phase in
if phase == .active {
MainViewControllerKt.resumeApp()
} else {
MainViewControllerKt.pauseApp()
}
}
}
}
+6
View File
@@ -12,6 +12,12 @@ targets:
dependencies:
- framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework
embed: false
preBuildScripts:
- name: Sync Compose resources
script: |
cd "$SRCROOT/.."
./gradlew :composeApp:syncComposeResourcesForIos
basedOnDependencyAnalysis: false
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: app.bertsatlas