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 0000000..668a2d1 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_bold.ttf differ 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 0000000..d0982cd Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_regular.ttf differ 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 0000000..44dcac9 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/atkinson_hyperlegible_next_semibold.ttf differ diff --git a/composeApp/src/commonMain/kotlin/app/bertsatlas/App.kt b/composeApp/src/commonMain/kotlin/app/bertsatlas/App.kt index 2468ba4..6bd0f47 100644 --- a/composeApp/src/commonMain/kotlin/app/bertsatlas/App.kt +++ b/composeApp/src/commonMain/kotlin/app/bertsatlas/App.kt @@ -1,42 +1,272 @@ package app.bertsatlas -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import app.bertsatlas.catalog.BundledCatalogProvider +import app.bertsatlas.data.BertsAtlasDatabase +import app.bertsatlas.data.PreferencesStore +import app.bertsatlas.domain.model.FeedbackKind +import app.bertsatlas.domain.model.HintType +import app.bertsatlas.domain.model.Question +import app.bertsatlas.domain.model.QuizDuration +import app.bertsatlas.domain.model.RunPhase +import app.bertsatlas.ui.AnswerState +import app.bertsatlas.ui.BertsAtlasTheme +import app.bertsatlas.ui.BootstrapScreen +import app.bertsatlas.ui.ChoiceUi +import app.bertsatlas.ui.CreditsScreen +import app.bertsatlas.ui.DebugPanelScreen +import app.bertsatlas.ui.FeedbackScreen +import app.bertsatlas.ui.FlagUi +import app.bertsatlas.ui.FunFactScreen +import app.bertsatlas.ui.HintChooserScreen +import app.bertsatlas.ui.HomeScreen +import app.bertsatlas.ui.LetterSlotUi +import app.bertsatlas.ui.LetterTileUi +import app.bertsatlas.ui.OptionsScreen +import app.bertsatlas.ui.PrivacyScreen +import app.bertsatlas.ui.ProgressScreen +import app.bertsatlas.ui.QuizScreen +import app.bertsatlas.ui.RecoveryScreen +import app.bertsatlas.ui.ResetConfirmationScreen +import app.bertsatlas.ui.ResultsScreen +import app.bertsatlas.ui.SettingsScreen +import kotlinx.coroutines.delay @Composable -fun App() { - Column( - modifier = Modifier.fillMaxSize().background(Color(0xFFF7F0DF)).padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - BasicText("Bert's Atlas", style = TextStyle(color = Color(0xFF3E2447), fontSize = 36.sp)) - BasicText( - "Getting nerdier every day.", - modifier = Modifier.padding(vertical = 16.dp), - style = TextStyle(color = Color(0xFF3E2447), fontSize = 18.sp), - ) - BasicText( - "Start", - modifier = Modifier - .heightIn(min = 48.dp) - .background(Color(0xFF19647E)) - .clickable(onClick = {}) - .padding(horizontal = 32.dp, vertical = 14.dp), - style = TextStyle(color = Color.White, fontSize = 18.sp), - ) +fun App( + database: BertsAtlasDatabase, + preferences: PreferencesStore, + debugBuild: Boolean, + deviceFeedback: DeviceFeedback = NoDeviceFeedback, + onControllerReady: (AppController) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + var controller by remember { mutableStateOf(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