From c7259ea645570be4c50e9ece4c520ae494b895b1 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 9 Sep 2026 23:45:39 +0530 Subject: [PATCH] build shared offline app experience --- androidApp/build.gradle.kts | 6 +- androidApp/src/main/AndroidManifest.xml | 2 +- .../kotlin/app/bertsatlas/MainActivity.kt | 23 +- composeApp/build.gradle.kts | 15 + .../1.json | 400 ++++++++++ .../2.json | 423 +++++++++++ .../app/bertsatlas/AndroidDeviceFeedback.kt | 21 + .../app/bertsatlas/data/Database.android.kt | 15 + .../drawable/bert_correct.svg | 7 + .../composeResources/drawable/bert_fact.svg | 7 + .../composeResources/drawable/bert_idle.svg | 11 + .../drawable/bert_incorrect.svg | 7 + .../composeResources/files/bert_correct.json | 1 + .../composeResources/files/bert_fact.json | 1 + .../composeResources/files/bert_hint.json | 1 + .../composeResources/files/bert_idle.json | 1 + .../files/bert_incorrect.json | 1 + .../composeResources/files/bert_overtime.json | 1 + .../composeResources/files/bert_results.json | 1 + .../composeResources/files/bert_stamp.json | 1 + .../composeResources/files/bert_streak.json | 1 + .../atkinson_hyperlegible_next_OFL.txt | 93 +++ .../font/atkinson_hyperlegible_next_bold.ttf | Bin 0 -> 66792 bytes .../atkinson_hyperlegible_next_regular.ttf | Bin 0 -> 65068 bytes .../atkinson_hyperlegible_next_semibold.ttf | Bin 0 -> 66680 bytes .../commonMain/kotlin/app/bertsatlas/App.kt | 300 +++++++- .../kotlin/app/bertsatlas/AppController.kt | 520 +++++++++++++ .../kotlin/app/bertsatlas/DeviceFeedback.kt | 7 + .../app/bertsatlas/catalog/CatalogLoader.kt | 420 +++++++++++ .../kotlin/app/bertsatlas/data/Database.kt | 701 ++++++++++++++++++ .../app/bertsatlas/data/PreferencesStore.kt | 164 ++++ .../app/bertsatlas/ui/AtlasComponents.kt | 488 ++++++++++++ .../kotlin/app/bertsatlas/ui/AtlasTheme.kt | 101 +++ .../bertsatlas/ui/HomeAndOptionsScreens.kt | 127 ++++ .../kotlin/app/bertsatlas/ui/QuizScreens.kt | 262 +++++++ .../ui/ResultsAndProgressScreens.kt | 128 ++++ .../app/bertsatlas/ui/SettingsScreens.kt | 159 ++++ .../kotlin/app/bertsatlas/ui/UiModels.kt | 76 ++ .../app/bertsatlas/IosDeviceFeedback.kt | 13 + .../app/bertsatlas/MainViewController.kt | 31 +- .../app/bertsatlas/data/Database.ios.kt | 21 + iosApp/iosApp.xcodeproj/project.pbxproj | 38 + iosApp/iosApp/PrivacyInfo.xcprivacy | 14 + iosApp/iosApp/iOSApp.swift | 9 + iosApp/project.yml | 6 + 45 files changed, 4584 insertions(+), 40 deletions(-) create mode 100644 composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/1.json create mode 100644 composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/2.json create mode 100644 composeApp/src/androidMain/kotlin/app/bertsatlas/AndroidDeviceFeedback.kt create mode 100644 composeApp/src/androidMain/kotlin/app/bertsatlas/data/Database.android.kt create mode 100644 composeApp/src/commonMain/composeResources/drawable/bert_correct.svg create mode 100644 composeApp/src/commonMain/composeResources/drawable/bert_fact.svg create mode 100644 composeApp/src/commonMain/composeResources/drawable/bert_idle.svg create mode 100644 composeApp/src/commonMain/composeResources/drawable/bert_incorrect.svg create mode 100644 composeApp/src/commonMain/composeResources/files/bert_correct.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_fact.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_hint.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_idle.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_incorrect.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_overtime.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_results.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_stamp.json create mode 100644 composeApp/src/commonMain/composeResources/files/bert_streak.json create mode 100644 composeApp/src/commonMain/composeResources/files/licenses/atkinson_hyperlegible_next_OFL.txt create mode 100644 composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_bold.ttf create mode 100644 composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_regular.ttf create mode 100644 composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_semibold.ttf create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/AppController.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/DeviceFeedback.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/catalog/CatalogLoader.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/data/Database.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/data/PreferencesStore.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasComponents.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasTheme.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/HomeAndOptionsScreens.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/QuizScreens.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/ResultsAndProgressScreens.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/SettingsScreens.kt create mode 100644 composeApp/src/commonMain/kotlin/app/bertsatlas/ui/UiModels.kt create mode 100644 composeApp/src/iosMain/kotlin/app/bertsatlas/IosDeviceFeedback.kt create mode 100644 composeApp/src/iosMain/kotlin/app/bertsatlas/data/Database.ios.kt create mode 100644 iosApp/iosApp/PrivacyInfo.xcprivacy diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index a015e5f..b5dabb6 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -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) } diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 647ed1f..ee39898 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + - diff --git a/androidApp/src/main/kotlin/app/bertsatlas/MainActivity.kt b/androidApp/src/main/kotlin/app/bertsatlas/MainActivity.kt index 65e3b3d..d308a83 100644 --- a/androidApp/src/main/kotlin/app/bertsatlas/MainActivity.kt +++ b/androidApp/src/main/kotlin/app/bertsatlas/MainActivity.kt @@ -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() } } - diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b7b075f..01522bd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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") +} diff --git a/composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/1.json b/composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/1.json new file mode 100644 index 0000000..afbb941 --- /dev/null +++ b/composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/1.json @@ -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')" + ] + } +} diff --git a/composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/2.json b/composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/2.json new file mode 100644 index 0000000..5f66ffe --- /dev/null +++ b/composeApp/schemas/app.bertsatlas.data.BertsAtlasDatabase/2.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/app/bertsatlas/AndroidDeviceFeedback.kt b/composeApp/src/androidMain/kotlin/app/bertsatlas/AndroidDeviceFeedback.kt new file mode 100644 index 0000000..9277a8d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/app/bertsatlas/AndroidDeviceFeedback.kt @@ -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)) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/app/bertsatlas/data/Database.android.kt b/composeApp/src/androidMain/kotlin/app/bertsatlas/data/Database.android.kt new file mode 100644 index 0000000..3029828 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/app/bertsatlas/data/Database.android.kt @@ -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 { + val applicationContext = context.applicationContext + val databaseFile = applicationContext.getDatabasePath("berts-atlas.db") + return Room.databaseBuilder( + context = applicationContext, + name = databaseFile.absolutePath, + ) +} + diff --git a/composeApp/src/commonMain/composeResources/drawable/bert_correct.svg b/composeApp/src/commonMain/composeResources/drawable/bert_correct.svg new file mode 100644 index 0000000..8dd28d2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/bert_correct.svg @@ -0,0 +1,7 @@ + + Bert cheering after a correct answer + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/bert_fact.svg b/composeApp/src/commonMain/composeResources/drawable/bert_fact.svg new file mode 100644 index 0000000..caea540 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/bert_fact.svg @@ -0,0 +1,7 @@ + + Bert delighted by a surprising fact + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/bert_idle.svg b/composeApp/src/commonMain/composeResources/drawable/bert_idle.svg new file mode 100644 index 0000000..1c919c4 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/bert_idle.svg @@ -0,0 +1,11 @@ + + Bert smiling behind oversized glasses + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/bert_incorrect.svg b/composeApp/src/commonMain/composeResources/drawable/bert_incorrect.svg new file mode 100644 index 0000000..8db5061 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/bert_incorrect.svg @@ -0,0 +1,7 @@ + + Bert offering encouragement after a wrong answer + + + + + diff --git a/composeApp/src/commonMain/composeResources/files/bert_correct.json b/composeApp/src/commonMain/composeResources/files/bert_correct.json new file mode 100644 index 0000000..9c9dfa3 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_correct.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_fact.json b/composeApp/src/commonMain/composeResources/files/bert_fact.json new file mode 100644 index 0000000..dd414bc --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_fact.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_hint.json b/composeApp/src/commonMain/composeResources/files/bert_hint.json new file mode 100644 index 0000000..ed18d02 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_hint.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_idle.json b/composeApp/src/commonMain/composeResources/files/bert_idle.json new file mode 100644 index 0000000..ae07e11 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_idle.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_incorrect.json b/composeApp/src/commonMain/composeResources/files/bert_incorrect.json new file mode 100644 index 0000000..5d9b077 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_incorrect.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_overtime.json b/composeApp/src/commonMain/composeResources/files/bert_overtime.json new file mode 100644 index 0000000..2b26a83 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_overtime.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_results.json b/composeApp/src/commonMain/composeResources/files/bert_results.json new file mode 100644 index 0000000..e4ceab1 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_results.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_stamp.json b/composeApp/src/commonMain/composeResources/files/bert_stamp.json new file mode 100644 index 0000000..9c501a9 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_stamp.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/bert_streak.json b/composeApp/src/commonMain/composeResources/files/bert_streak.json new file mode 100644 index 0000000..ed2a1a5 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/bert_streak.json @@ -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":[]} diff --git a/composeApp/src/commonMain/composeResources/files/licenses/atkinson_hyperlegible_next_OFL.txt b/composeApp/src/commonMain/composeResources/files/licenses/atkinson_hyperlegible_next_OFL.txt new file mode 100644 index 0000000..8895573 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/files/licenses/atkinson_hyperlegible_next_OFL.txt @@ -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. diff --git a/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_bold.ttf b/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..668a2d166581035f7f836a700a6cc3f26881ec68 GIT binary patch literal 66792 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_regular.ttf b/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d0982cdce554149011bec58b21ac4a1bf8ee9ff5 GIT binary patch literal 65068 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 literal 0 HcmV?d00001 diff --git a/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_semibold.ttf b/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..44dcac96670c984b947b8a14c7733e7ba06a820d GIT binary patch literal 66680 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

q*lge z!b&NBiFo9m0{so_qLK0xjdt3l?E78VAHw@K+ok}I`{V=APazhq@=A%BjeJzz4HMF% zuqaR%?y-DVo{}HKZwhU29MYiZh4eqo!7r7bAWSacd=N6=0+W0T?j-wx&PY(09Jsw` zgkcxL4auVqToWjaY{C0oBMiAvil-146a!|6R3#UTd{jfL3$Q?*U>9Hupr_zZ&1 zpf3Y1k;HBPTQxccBt@p{x00|B0?tuMf zNO30&7{XkiAf_ROJ2|DVLl470f_38O=>6nV#XcI+!rcQbO9^*;$cvEHRdPS{FUX5I z&fkpOD89tpZxlQR#g{PAw}tJF&~iOc9!QrJr)KrVe=vj?L23U0sbM29J7$9%{AQOP zQVQQHl!a Unit = {}, +) { + val scope = rememberCoroutineScope() + var controller by remember { mutableStateOf(null) } + var loadFailure by remember { mutableStateOf(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, tiles: List): List { + 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): List = ids.distinct().mapNotNull { id -> + controller.catalog.countries.firstOrNull { it.id == id }?.displayName +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/AppController.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/AppController.kt new file mode 100644 index 0000000..a5f2b74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/AppController.kt @@ -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 = emptyList(), + val attempts: List = emptyList(), + val completedRuns: List = emptyList(), + val streak: Streak = Streak(), + val stamps: List = 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 = _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(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 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 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 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 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 = database.progressReader().masteryNow().mapNotNull { entity -> + runCatching { json.decodeFromString(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 > enumValueOrDefault(name: String, fallback: T): T = + enumValues().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" +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/DeviceFeedback.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/DeviceFeedback.kt new file mode 100644 index 0000000..1eac690 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/DeviceFeedback.kt @@ -0,0 +1,7 @@ +package app.bertsatlas + +fun interface DeviceFeedback { + fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean) +} + +val NoDeviceFeedback = DeviceFeedback { _, _, _ -> } diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/catalog/CatalogLoader.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/catalog/CatalogLoader.kt new file mode 100644 index 0000000..5d45cb7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/catalog/CatalogLoader.kt @@ -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(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, + similarityGroupIds: List, + similarityGroups: List, + relatedFacts: List, + ): List { + 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, + similarityGroupIds: List, + similarityGroups: List, + ): List { + 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, + ): 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): 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 = 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, + val countries: List, + val facts: List, + val curatedQuestions: List, + val similarityGroups: List, + val assets: List, +) + +@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, + val region: String, + val capital: SourceCapital, + val flagAssetId: String, +) + +@Serializable +private data class SourceCapital( + val kind: String, + val names: List, + val acceptedAnswer: String, + val note: String?, +) + +@Serializable +private data class SourceFact( + val id: String, + val countryIds: List, + val heading: String, + val text: String, + val sourceIds: List, + 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, + val countryIds: List, + val supportedHints: List, + val explanation: String, +) + +@Serializable +private data class SourceSimilarityGroup( + val id: String, + val countryIds: List, +) + +@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) diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/data/Database.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/data/Database.kt new file mode 100644 index 0000000..fa20db3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/data/Database.kt @@ -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().encodeStringSet(), + unhintedCorrectEpochDaysJson = recentOutcomes.decodeMastery()?.unhintedCorrectEpochDays?.encodeIntSet() ?: emptySet().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) + + @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) + + @Query("SELECT * FROM attempts WHERE runId = :runId ORDER BY answeredAtEpochMillis") + protected abstract suspend fun attemptsForRun(runId: String): List + + @Transaction + open suspend fun recordAcceptedAnswer( + attempt: AttemptEntity, + updatedMastery: List, + 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, + ) { + 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, + ) { + 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> + + @Query("SELECT * FROM mastery ORDER BY countryId, skill") + suspend fun masteryNow(): List + + @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> + + @Query("SELECT * FROM runs WHERE status = 'Completed' ORDER BY completedAtEpochMillis DESC") + suspend fun completedRunsNow(): List + + @Query("SELECT * FROM attempts ORDER BY answeredAtEpochMillis DESC") + fun attempts(): Flow> + + @Query("SELECT * FROM attempts ORDER BY answeredAtEpochMillis DESC") + suspend fun attemptsNow(): List + + @Query("SELECT * FROM attempts WHERE runId = :runId ORDER BY answeredAtEpochMillis") + suspend fun attemptsForRun(runId: String): List + + @Query("SELECT * FROM personal_bests") + fun personalBests(): Flow> + + @Query("SELECT * FROM personal_bests") + suspend fun personalBestsNow(): List + + @Query("SELECT * FROM streak WHERE singletonId = 1") + fun streak(): Flow + + @Query("SELECT * FROM streak WHERE singletonId = 1") + suspend fun streakNow(): StreakEntity? + + @Query("SELECT * FROM stamps ORDER BY earnedAtEpochMillis") + fun stamps(): Flow> + + @Query("SELECT * FROM stamps ORDER BY earnedAtEpochMillis") + suspend fun stampsNow(): List +} + +@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 = reader.masteryNow().mapNotNull { it.toDomain(json) } + + override fun observeProgress(): Flow = 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, + 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, + ) { + 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(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 { + override fun initialize(): BertsAtlasDatabase +} + +fun RoomDatabase.Builder.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 = runCatching { + Json.decodeFromString(SetSerializer(String.serializer()), this) +}.getOrDefault(emptySet()) + +private fun Set.encodeStringSet(): String = + Json.encodeToString(SetSerializer(String.serializer()), this) + +private fun String.decodeIntSet(): Set = runCatching { + Json.decodeFromString(SetSerializer(Int.serializer()), this) +}.getOrDefault(emptySet()) + +private fun Set.encodeIntSet(): String = + Json.encodeToString(SetSerializer(Int.serializer()), this) + +private fun String.decodeMastery(): Mastery? = runCatching { + Json.decodeFromString(this) +}.getOrNull() + +private fun String.decodeActiveRunId(): String = runCatching { + Json.decodeFromString(this).state.runId +}.getOrDefault("") diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/data/PreferencesStore.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/data/PreferencesStore.kt new file mode 100644 index 0000000..e2a5f23 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/data/PreferencesStore.kt @@ -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, +) : LaunchPreferences, QuizConfigurationPreferences, PlayerSettingsRepository { + val values: Flow = 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 = 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) +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasComponents.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasComponents.kt new file mode 100644 index 0000000..1cb5b4f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasComponents.kt @@ -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, 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)) diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasTheme.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasTheme.kt new file mode 100644 index 0000000..e22207e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/AtlasTheme.kt @@ -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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/HomeAndOptionsScreens.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/HomeAndOptionsScreens.kt new file mode 100644 index 0000000..1776015 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/HomeAndOptionsScreens.kt @@ -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, + 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) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/QuizScreens.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/QuizScreens.kt new file mode 100644 index 0000000..b64fdfe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/QuizScreens.kt @@ -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 = emptyList(), + letterSlots: List = emptyList(), + letterTiles: List = 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, 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, + tiles: List, + 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, + 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()) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/ResultsAndProgressScreens.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/ResultsAndProgressScreens.kt new file mode 100644 index 0000000..44367c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/ResultsAndProgressScreens.kt @@ -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, + 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, + strongCountries: List, + revisitCountries: List, + stamps: List, + 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, 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) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/SettingsScreens.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/SettingsScreens.kt new file mode 100644 index 0000000..12f18db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/SettingsScreens.kt @@ -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, + 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, + licenses: List, + 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, 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, + 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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/UiModels.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/UiModels.kt new file mode 100644 index 0000000..de7d64c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/ui/UiModels.kt @@ -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, +) diff --git a/composeApp/src/iosMain/kotlin/app/bertsatlas/IosDeviceFeedback.kt b/composeApp/src/iosMain/kotlin/app/bertsatlas/IosDeviceFeedback.kt new file mode 100644 index 0000000..1d43483 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/app/bertsatlas/IosDeviceFeedback.kt @@ -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) + } +} diff --git a/composeApp/src/iosMain/kotlin/app/bertsatlas/MainViewController.kt b/composeApp/src/iosMain/kotlin/app/bertsatlas/MainViewController.kt index 7967b3c..f187141 100644 --- a/composeApp/src/iosMain/kotlin/app/bertsatlas/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/app/bertsatlas/MainViewController.kt @@ -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() diff --git a/composeApp/src/iosMain/kotlin/app/bertsatlas/data/Database.ios.kt b/composeApp/src/iosMain/kotlin/app/bertsatlas/data/Database.ios.kt new file mode 100644 index 0000000..b047791 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/app/bertsatlas/data/Database.ios.kt @@ -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 { + 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) +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index dd18b00..95196b6 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; CE64B79D6154E4181215B83C /* ComposeApp.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ComposeApp.framework; path = ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -35,6 +37,7 @@ children = ( C117AD187833E2513BD2F553 /* Info.plist */, 668CECA83ACC03A83C85A623 /* iOSApp.swift */, + C6D2DD7FE2E0BF5DB2A96B4B /* PrivacyInfo.xcprivacy */, ); path = iosApp; sourceTree = ""; @@ -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; diff --git a/iosApp/iosApp/PrivacyInfo.xcprivacy b/iosApp/iosApp/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..2009fb7 --- /dev/null +++ b/iosApp/iosApp/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index e027f7e..5621084 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -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() + } + } } } diff --git a/iosApp/project.yml b/iosApp/project.yml index fecdeac..a4c9aa9 100644 --- a/iosApp/project.yml +++ b/iosApp/project.yml @@ -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