mirror of
https://github.com/priyanshujain/bertsatlas.git
synced 2026-10-02 11:06:59 +00:00
build shared offline app experience
This commit is contained in:
1 parent
e21a2f39e0
commit
c7259ea645
45 files changed
+4584
-40
No files matched your search
@@ -6,6 +6,8 @@ plugins {
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.room)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -33,9 +35,22 @@ kotlin {
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.resources)
|
||||
implementation(libs.compottie.lite)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add("kspAndroid", libs.androidx.room.compiler)
|
||||
add("kspIosArm64", libs.androidx.room.compiler)
|
||||
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "48ec5055c5a39c6bd15884e9405898da",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "attempts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `runId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `countryIdsJson` TEXT NOT NULL, `skill` TEXT NOT NULL, `correct` INTEGER NOT NULL, `hinted` INTEGER NOT NULL, `overtime` INTEGER NOT NULL, `submittedAnswer` TEXT NOT NULL, `correctAnswer` TEXT NOT NULL, `responseMillis` INTEGER NOT NULL, `pointsAwarded` INTEGER NOT NULL, `answeredAtEpochMillis` INTEGER NOT NULL, `localEpochDay` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "runId",
|
||||
"columnName": "runId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "questionId",
|
||||
"columnName": "questionId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "countryIdsJson",
|
||||
"columnName": "countryIdsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "skill",
|
||||
"columnName": "skill",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correct",
|
||||
"columnName": "correct",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hinted",
|
||||
"columnName": "hinted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "overtime",
|
||||
"columnName": "overtime",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "submittedAnswer",
|
||||
"columnName": "submittedAnswer",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correctAnswer",
|
||||
"columnName": "correctAnswer",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "responseMillis",
|
||||
"columnName": "responseMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pointsAwarded",
|
||||
"columnName": "pointsAwarded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "answeredAtEpochMillis",
|
||||
"columnName": "answeredAtEpochMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localEpochDay",
|
||||
"columnName": "localEpochDay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "mastery",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`countryId` TEXT NOT NULL, `skill` TEXT NOT NULL, `level` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `unhintedCorrectAnswers` INTEGER NOT NULL, `unhintedCorrectRunIdsJson` TEXT NOT NULL, `unhintedCorrectEpochDaysJson` TEXT NOT NULL, `recentOutcomesJson` TEXT NOT NULL, `nextReviewEpochDay` INTEGER, PRIMARY KEY(`countryId`, `skill`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "countryId",
|
||||
"columnName": "countryId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "skill",
|
||||
"columnName": "skill",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "level",
|
||||
"columnName": "level",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attempts",
|
||||
"columnName": "attempts",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correctAnswers",
|
||||
"columnName": "correctAnswers",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unhintedCorrectAnswers",
|
||||
"columnName": "unhintedCorrectAnswers",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unhintedCorrectRunIdsJson",
|
||||
"columnName": "unhintedCorrectRunIdsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unhintedCorrectEpochDaysJson",
|
||||
"columnName": "unhintedCorrectEpochDaysJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "recentOutcomesJson",
|
||||
"columnName": "recentOutcomesJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextReviewEpochDay",
|
||||
"columnName": "nextReviewEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"countryId",
|
||||
"skill"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "runs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `duration` TEXT, `topic` TEXT, `status` TEXT NOT NULL, `score` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `answeredQuestions` INTEGER NOT NULL, `averageResponseMillis` INTEGER NOT NULL, `bestCorrectStreak` INTEGER NOT NULL, `improvedMasteryItems` INTEGER NOT NULL, `revisitCountryIdsJson` TEXT NOT NULL, `completedAtEpochMillis` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "score",
|
||||
"columnName": "score",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correctAnswers",
|
||||
"columnName": "correctAnswers",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "answeredQuestions",
|
||||
"columnName": "answeredQuestions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "averageResponseMillis",
|
||||
"columnName": "averageResponseMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bestCorrectStreak",
|
||||
"columnName": "bestCorrectStreak",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "improvedMasteryItems",
|
||||
"columnName": "improvedMasteryItems",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisitCountryIdsJson",
|
||||
"columnName": "revisitCountryIdsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "completedAtEpochMillis",
|
||||
"columnName": "completedAtEpochMillis",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "personal_bests",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`duration` TEXT NOT NULL, `topic` TEXT NOT NULL, `score` INTEGER NOT NULL, `runId` TEXT NOT NULL, PRIMARY KEY(`duration`, `topic`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "score",
|
||||
"columnName": "score",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "runId",
|
||||
"columnName": "runId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"duration",
|
||||
"topic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "streak",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `currentDays` INTEGER NOT NULL, `bestDays` INTEGER NOT NULL, `lastCompletedEpochDay` INTEGER, PRIMARY KEY(`singletonId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "singletonId",
|
||||
"columnName": "singletonId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "currentDays",
|
||||
"columnName": "currentDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bestDays",
|
||||
"columnName": "bestDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastCompletedEpochDay",
|
||||
"columnName": "lastCompletedEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"singletonId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "stamps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `earnedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "earnedAtEpochMillis",
|
||||
"columnName": "earnedAtEpochMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "active_run",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `runId` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `catalogVersion` TEXT NOT NULL, `snapshotJson` TEXT NOT NULL, PRIMARY KEY(`singletonId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "singletonId",
|
||||
"columnName": "singletonId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "runId",
|
||||
"columnName": "runId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "schemaVersion",
|
||||
"columnName": "schemaVersion",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "catalogVersion",
|
||||
"columnName": "catalogVersion",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snapshotJson",
|
||||
"columnName": "snapshotJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"singletonId"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2eec9fa8f28884d9cb12c2c0ab1e03f5')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "2eec9fa8f28884d9cb12c2c0ab1e03f5",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "attempts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `runId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `countryIdsJson` TEXT NOT NULL, `skill` TEXT NOT NULL, `correct` INTEGER NOT NULL, `hinted` INTEGER NOT NULL, `overtime` INTEGER NOT NULL, `submittedAnswer` TEXT NOT NULL, `correctAnswer` TEXT NOT NULL, `responseMillis` INTEGER NOT NULL, `pointsAwarded` INTEGER NOT NULL, `answeredAtEpochMillis` INTEGER NOT NULL, `localEpochDay` INTEGER NOT NULL, `hintType` TEXT, `hintPointsDeducted` INTEGER NOT NULL, `questionWindowMillis` INTEGER NOT NULL, `questionRemainingMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "runId",
|
||||
"columnName": "runId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "questionId",
|
||||
"columnName": "questionId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "countryIdsJson",
|
||||
"columnName": "countryIdsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "skill",
|
||||
"columnName": "skill",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correct",
|
||||
"columnName": "correct",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hinted",
|
||||
"columnName": "hinted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "overtime",
|
||||
"columnName": "overtime",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "submittedAnswer",
|
||||
"columnName": "submittedAnswer",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correctAnswer",
|
||||
"columnName": "correctAnswer",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "responseMillis",
|
||||
"columnName": "responseMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pointsAwarded",
|
||||
"columnName": "pointsAwarded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "answeredAtEpochMillis",
|
||||
"columnName": "answeredAtEpochMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localEpochDay",
|
||||
"columnName": "localEpochDay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hintType",
|
||||
"columnName": "hintType",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hintPointsDeducted",
|
||||
"columnName": "hintPointsDeducted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "questionWindowMillis",
|
||||
"columnName": "questionWindowMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "questionRemainingMillis",
|
||||
"columnName": "questionRemainingMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "mastery",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`countryId` TEXT NOT NULL, `skill` TEXT NOT NULL, `level` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `unhintedCorrectAnswers` INTEGER NOT NULL, `unhintedCorrectRunIdsJson` TEXT NOT NULL, `unhintedCorrectEpochDaysJson` TEXT NOT NULL, `recentOutcomesJson` TEXT NOT NULL, `nextReviewEpochDay` INTEGER, PRIMARY KEY(`countryId`, `skill`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "countryId",
|
||||
"columnName": "countryId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "skill",
|
||||
"columnName": "skill",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "level",
|
||||
"columnName": "level",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attempts",
|
||||
"columnName": "attempts",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correctAnswers",
|
||||
"columnName": "correctAnswers",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unhintedCorrectAnswers",
|
||||
"columnName": "unhintedCorrectAnswers",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unhintedCorrectRunIdsJson",
|
||||
"columnName": "unhintedCorrectRunIdsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unhintedCorrectEpochDaysJson",
|
||||
"columnName": "unhintedCorrectEpochDaysJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "recentOutcomesJson",
|
||||
"columnName": "recentOutcomesJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextReviewEpochDay",
|
||||
"columnName": "nextReviewEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"countryId",
|
||||
"skill"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "runs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `duration` TEXT, `topic` TEXT, `status` TEXT NOT NULL, `score` INTEGER NOT NULL, `correctAnswers` INTEGER NOT NULL, `answeredQuestions` INTEGER NOT NULL, `averageResponseMillis` INTEGER NOT NULL, `bestCorrectStreak` INTEGER NOT NULL, `improvedMasteryItems` INTEGER NOT NULL, `revisitCountryIdsJson` TEXT NOT NULL, `completedAtEpochMillis` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "score",
|
||||
"columnName": "score",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "correctAnswers",
|
||||
"columnName": "correctAnswers",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "answeredQuestions",
|
||||
"columnName": "answeredQuestions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "averageResponseMillis",
|
||||
"columnName": "averageResponseMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bestCorrectStreak",
|
||||
"columnName": "bestCorrectStreak",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "improvedMasteryItems",
|
||||
"columnName": "improvedMasteryItems",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisitCountryIdsJson",
|
||||
"columnName": "revisitCountryIdsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "completedAtEpochMillis",
|
||||
"columnName": "completedAtEpochMillis",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "personal_bests",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`duration` TEXT NOT NULL, `topic` TEXT NOT NULL, `score` INTEGER NOT NULL, `runId` TEXT NOT NULL, PRIMARY KEY(`duration`, `topic`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "score",
|
||||
"columnName": "score",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "runId",
|
||||
"columnName": "runId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"duration",
|
||||
"topic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "streak",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `currentDays` INTEGER NOT NULL, `bestDays` INTEGER NOT NULL, `lastCompletedEpochDay` INTEGER, PRIMARY KEY(`singletonId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "singletonId",
|
||||
"columnName": "singletonId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "currentDays",
|
||||
"columnName": "currentDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bestDays",
|
||||
"columnName": "bestDays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastCompletedEpochDay",
|
||||
"columnName": "lastCompletedEpochDay",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"singletonId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "stamps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `earnedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "earnedAtEpochMillis",
|
||||
"columnName": "earnedAtEpochMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "active_run",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `runId` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `catalogVersion` TEXT NOT NULL, `snapshotJson` TEXT NOT NULL, PRIMARY KEY(`singletonId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "singletonId",
|
||||
"columnName": "singletonId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "runId",
|
||||
"columnName": "runId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "schemaVersion",
|
||||
"columnName": "schemaVersion",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "catalogVersion",
|
||||
"columnName": "catalogVersion",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snapshotJson",
|
||||
"columnName": "snapshotJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"singletonId"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2eec9fa8f28884d9cb12c2c0ab1e03f5')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package app.bertsatlas
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
|
||||
class AndroidDeviceFeedback(context: Context) : DeviceFeedback {
|
||||
private val vibrator = context.applicationContext.getSystemService(Vibrator::class.java)
|
||||
private val toneGenerator = ToneGenerator(AudioManager.STREAM_MUSIC, 45)
|
||||
|
||||
override fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean) {
|
||||
if (soundEnabled) {
|
||||
toneGenerator.startTone(if (correct) ToneGenerator.TONE_PROP_ACK else ToneGenerator.TONE_PROP_NACK, 90)
|
||||
}
|
||||
if (hapticsEnabled && vibrator?.hasVibrator() == true) {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(if (correct) 35 else 70, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.bertsatlas.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<BertsAtlasDatabase> {
|
||||
val applicationContext = context.applicationContext
|
||||
val databaseFile = applicationContext.getDatabasePath("berts-atlas.db")
|
||||
return Room.databaseBuilder(
|
||||
context = applicationContext,
|
||||
name = databaseFile.absolutePath,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert cheering after a correct answer</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<path fill="none" stroke="#3E2447" stroke-width="11" stroke-linecap="round" d="m158 252 21 17 31-34m140 0-31 34-21-17M200 324q57 62 113 0"/>
|
||||
<path fill="#147568" d="m425 61 15 34 37 4-28 25 8 36-32-19-32 19 8-36-28-25 37-4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert delighted by a surprising fact</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="15" fill="#3E2447"/><circle cx="328" cy="251" r="15" fill="#3E2447"/><circle cx="257" cy="342" r="25" fill="#FFFBF2" stroke="#3E2447" stroke-width="10"/>
|
||||
<path fill="#E3AD35" stroke="#3E2447" stroke-width="8" d="M410 45c55 0 74 68 31 96l-2 25h-58l-2-25c-43-28-24-96 31-96z"/><path stroke="#3E2447" stroke-width="9" d="M382 181h57"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 978 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert smiling behind oversized glasses</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<path fill="none" stroke="#C45348" stroke-width="8" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/>
|
||||
<path stroke="#3E2447" stroke-width="15" stroke-linecap="round" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="12" fill="#3E2447"/><circle cx="328" cy="250" r="12" fill="#3E2447"/>
|
||||
<path fill="none" stroke="#3E2447" stroke-width="12" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
<circle cx="418" cy="120" r="27" fill="#17647E"/><path stroke="#17647E" stroke-width="11" stroke-linecap="round" d="M418 72V42m35 44 23-22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert offering encouragement after a wrong answer</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="11" fill="#3E2447"/><circle cx="328" cy="251" r="11" fill="#3E2447"/><path fill="none" stroke="#3E2447" stroke-width="12" stroke-linecap="round" d="M205 350q52-44 104 0"/>
|
||||
<path fill="none" stroke="#17647E" stroke-width="10" stroke-linecap="round" d="M413 80c34 31 7 61-7 81-14-20-41-50-7-81l7-8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 958 B |
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert correct accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"success star","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-12]},{"t":22,"s":[12]},{"t":45,"s":[-12]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[70,70,100]},{"t":22,"s":[110,110,100]},{"t":45,"s":[70,70,100]}]}},"shapes":[{"ty":"sr","sy":1,"pt":{"a":0,"k":5},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"or":{"a":0,"k":100},"os":{"a":0,"k":0},"ir":{"a":0,"k":45},"is":{"a":0,"k":0}},{"ty":"fl","c":{"a":0,"k":[0.08,0.46,0.41,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert fact accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"fact bubble","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[256,270,0]},{"t":30,"s":[256,242,0]},{"t":60,"s":[256,270,0]}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[190,150]}},{"ty":"fl","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert hint accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"hint glow","ks":{"o":{"a":1,"k":[{"t":0,"s":[45]},{"t":30,"s":[100]},{"t":60,"s":[45]}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[180,180]}},{"ty":"fl","c":{"a":0,"k":[0.89,0.68,0.21,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":90,"w":512,"h":512,"nm":"bert idle","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"spark","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-8]},{"t":45,"s":[8]},{"t":90,"s":[-8]}]},"p":{"a":0,"k":[410,105,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[85,85,100]},{"t":45,"s":[110,110,100]},{"t":90,"s":[85,85,100]}]}},"ao":0,"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[48,48]},"nm":"spark circle"},{"ty":"fl","c":{"a":0,"k":[0.09,0.45,0.41,1]},"o":{"a":0,"k":100},"r":1,"nm":"teal fill"}],"ip":0,"op":90,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"bert","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[256,266,0]},{"t":45,"s":[256,258,0]},{"t":90,"s":[256,266,0]}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[310,270]},"nm":"brain silhouette"},{"ty":"fl","c":{"a":0,"k":[0.91,0.43,0.36,1]},"o":{"a":0,"k":100},"r":1,"nm":"coral fill"},{"ty":"st","c":{"a":0,"k":[0.24,0.14,0.28,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":12},"lc":2,"lj":2,"nm":"plum stroke"}],"ip":0,"op":90,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert incorrect accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"kind wobble","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-4]},{"t":15,"s":[4]},{"t":30,"s":[-4]},{"t":45,"s":[0]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[170,170]}},{"ty":"st","c":{"a":0,"k":[0.61,0.24,0.2,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":18},"lc":2,"lj":2}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert overtime accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"clock","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0]},{"t":60,"s":[360]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[190,190]}},{"ty":"st","c":{"a":0,"k":[0.89,0.68,0.21,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert results accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"result seal","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-8]},{"t":22,"s":[8]},{"t":45,"s":[-8]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"sr","sy":1,"pt":{"a":0,"k":12},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"or":{"a":0,"k":110},"os":{"a":0,"k":0},"ir":{"a":0,"k":90},"is":{"a":0,"k":0}},{"ty":"fl","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":30,"w":512,"h":512,"nm":"bert passport stamp accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"stamp","ks":{"o":{"a":1,"k":[{"t":0,"s":[0]},{"t":10,"s":[100]},{"t":30,"s":[100]}]},"r":{"a":1,"k":[{"t":0,"s":[-18]},{"t":10,"s":[3]},{"t":30,"s":[0]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[150,150,100]},{"t":10,"s":[95,95,100]},{"t":30,"s":[100,100,100]}]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[220,220]}},{"ty":"st","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert streak accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"streak ring","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0]},{"t":60,"s":[360]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[210,210]}},{"ty":"st","c":{"a":0,"k":[0.91,0.43,0.36,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":22},"lc":2,"lj":2}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
+93
@@ -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.
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,42 +1,272 @@
|
||||
package app.bertsatlas
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import app.bertsatlas.catalog.BundledCatalogProvider
|
||||
import app.bertsatlas.data.BertsAtlasDatabase
|
||||
import app.bertsatlas.data.PreferencesStore
|
||||
import app.bertsatlas.domain.model.FeedbackKind
|
||||
import app.bertsatlas.domain.model.HintType
|
||||
import app.bertsatlas.domain.model.Question
|
||||
import app.bertsatlas.domain.model.QuizDuration
|
||||
import app.bertsatlas.domain.model.RunPhase
|
||||
import app.bertsatlas.ui.AnswerState
|
||||
import app.bertsatlas.ui.BertsAtlasTheme
|
||||
import app.bertsatlas.ui.BootstrapScreen
|
||||
import app.bertsatlas.ui.ChoiceUi
|
||||
import app.bertsatlas.ui.CreditsScreen
|
||||
import app.bertsatlas.ui.DebugPanelScreen
|
||||
import app.bertsatlas.ui.FeedbackScreen
|
||||
import app.bertsatlas.ui.FlagUi
|
||||
import app.bertsatlas.ui.FunFactScreen
|
||||
import app.bertsatlas.ui.HintChooserScreen
|
||||
import app.bertsatlas.ui.HomeScreen
|
||||
import app.bertsatlas.ui.LetterSlotUi
|
||||
import app.bertsatlas.ui.LetterTileUi
|
||||
import app.bertsatlas.ui.OptionsScreen
|
||||
import app.bertsatlas.ui.PrivacyScreen
|
||||
import app.bertsatlas.ui.ProgressScreen
|
||||
import app.bertsatlas.ui.QuizScreen
|
||||
import app.bertsatlas.ui.RecoveryScreen
|
||||
import app.bertsatlas.ui.ResetConfirmationScreen
|
||||
import app.bertsatlas.ui.ResultsScreen
|
||||
import app.bertsatlas.ui.SettingsScreen
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFF7F0DF)).padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BasicText("Bert's Atlas", style = TextStyle(color = Color(0xFF3E2447), fontSize = 36.sp))
|
||||
BasicText(
|
||||
"Getting nerdier every day.",
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
style = TextStyle(color = Color(0xFF3E2447), fontSize = 18.sp),
|
||||
)
|
||||
BasicText(
|
||||
"Start",
|
||||
modifier = Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.background(Color(0xFF19647E))
|
||||
.clickable(onClick = {})
|
||||
.padding(horizontal = 32.dp, vertical = 14.dp),
|
||||
style = TextStyle(color = Color.White, fontSize = 18.sp),
|
||||
)
|
||||
fun App(
|
||||
database: BertsAtlasDatabase,
|
||||
preferences: PreferencesStore,
|
||||
debugBuild: Boolean,
|
||||
deviceFeedback: DeviceFeedback = NoDeviceFeedback,
|
||||
onControllerReady: (AppController) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var controller by remember { mutableStateOf<AppController?>(null) }
|
||||
var loadFailure by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(database, preferences) {
|
||||
runCatching { BundledCatalogProvider().loadCatalog() }
|
||||
.onSuccess { catalog -> controller = AppController(catalog, database, preferences, scope, debugBuild, deviceFeedback).also(onControllerReady) }
|
||||
.onFailure { loadFailure = it.message ?: "The bundled catalog could not be opened." }
|
||||
}
|
||||
val activeController = controller
|
||||
val appState = activeController?.state?.collectAsState()?.value
|
||||
BertsAtlasTheme(reducedMotion = appState?.preferences?.motionMode == "Reduced") {
|
||||
if (activeController == null || appState == null) BootstrapScreen(loadFailure ?: "Opening the atlas…")
|
||||
else AtlasDestination(activeController, appState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
val run = state.run
|
||||
when (val destination = state.destination) {
|
||||
Destination.Bootstrap -> BootstrapScreen()
|
||||
Destination.Home -> HomeScreen(
|
||||
isFirstLaunch = state.firstLaunch,
|
||||
streakDays = state.streak.currentDays,
|
||||
progressSummary = "${state.attempts.size} questions explored",
|
||||
onStart = controller::start,
|
||||
onOptions = controller::openOptions,
|
||||
onProgress = { controller.navigate(Destination.Progress) },
|
||||
onSettings = { controller.navigate(Destination.Settings) },
|
||||
)
|
||||
is Destination.Options -> OptionsScreen(
|
||||
step = destination.step,
|
||||
choices = if (destination.step == 1) durationChoices(state.selectedDuration) else topicChoices(),
|
||||
onChoose = controller::chooseOption,
|
||||
onBack = controller::back,
|
||||
)
|
||||
Destination.Recovery -> if (run == null) BootstrapScreen() else RecoveryScreen(
|
||||
questionSummary = run.activeQuestion.question.prompt,
|
||||
score = run.score,
|
||||
timeRemaining = run.sessionRemainingMillis.formatTime(),
|
||||
onResume = controller::resume,
|
||||
onAbandon = controller::abandon,
|
||||
)
|
||||
Destination.Quiz -> if (run == null) BootstrapScreen() else RunDestination(controller, state)
|
||||
Destination.Results -> {
|
||||
val result = state.result
|
||||
if (result == null) BootstrapScreen() else ResultsScreen(
|
||||
score = result.score,
|
||||
correctAnswers = result.correctAnswers,
|
||||
answeredQuestions = result.answeredQuestions,
|
||||
accuracy = "${result.accuracyPercent}%",
|
||||
averageResponseTime = "${result.averageResponseMillis / 1_000.0}s",
|
||||
bestCorrectStreak = result.bestCorrectStreak,
|
||||
masteryImproved = result.improvedMasteryItems,
|
||||
revisitCountries = result.revisitCountryIds.mapNotNull { id -> controller.catalog.countries.firstOrNull { it.id == id }?.displayName },
|
||||
newPersonalBest = state.newPersonalBest,
|
||||
onPlayAgain = controller::start,
|
||||
onHome = { controller.navigate(Destination.Home) },
|
||||
)
|
||||
}
|
||||
Destination.Progress -> ProgressScreen(
|
||||
currentStreak = state.streak.currentDays,
|
||||
bestStreak = state.streak.bestDays,
|
||||
overallAccuracy = if (state.attempts.isEmpty()) "—" else "${state.attempts.count { it.correct } * 100 / state.attempts.size}%",
|
||||
totalAnswered = state.attempts.size,
|
||||
masteryGroups = controller.progressGroups,
|
||||
strongCountries = countryNames(controller, state.mastery.filter { it.level.name in setOf("Familiar", "Mastered") }.map { it.countryId }),
|
||||
revisitCountries = countryNames(controller, state.mastery.filter { it.level.name == "Learning" }.map { it.countryId }),
|
||||
stamps = controller.stampUi,
|
||||
onBack = controller::back,
|
||||
)
|
||||
Destination.Settings -> SettingsScreen(
|
||||
settings = controller.settings,
|
||||
version = "1.0.0",
|
||||
debugEntryEnabled = controller.debugBuild,
|
||||
onToggle = controller::toggleSetting,
|
||||
onPrivacy = { controller.navigate(Destination.Privacy) },
|
||||
onCredits = { controller.navigate(Destination.Credits) },
|
||||
onReset = { controller.navigate(Destination.Reset) },
|
||||
onOpenDebug = { if (controller.debugBuild) controller.navigate(Destination.Debug) },
|
||||
onBack = controller::back,
|
||||
)
|
||||
Destination.Privacy -> PrivacyScreen(controller::back)
|
||||
Destination.Credits -> CreditsScreen(
|
||||
catalogVersion = controller.catalog.contentVersion,
|
||||
credits = listOf("Country metadata: United Nations and CLDR", "Durable facts: cited per catalog record", "Flags: pinned public-domain country-flags revision"),
|
||||
licenses = listOf("Atkinson Hyperlegible Next · OFL-1.1", "Compottie · Apache-2.0", "country-flags · public domain"),
|
||||
onBack = controller::back,
|
||||
)
|
||||
Destination.Reset -> ResetConfirmationScreen(controller::resetAll, controller::back)
|
||||
Destination.Debug -> if (controller.debugBuild) DebugPanelScreen(
|
||||
buildVersion = "1.0.0 debug",
|
||||
catalogVersion = controller.catalog.contentVersion,
|
||||
activeSnapshot = run?.let { "${it.runId} · ${it.phase} · ${it.activeQuestion.question.id} · ${it.score} points" } ?: "No active run",
|
||||
actions = controller.debugActions,
|
||||
onAction = controller::debugAction,
|
||||
onClose = controller::back,
|
||||
) else HomeScreen(true, onStart = controller::start)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
val run = requireNotNull(state.run)
|
||||
when (run.phase) {
|
||||
RunPhase.Answering, RunPhase.Paused -> {
|
||||
val question = run.activeQuestion.question
|
||||
val country = question.countryIds.firstOrNull()?.let { id -> controller.catalog.countries.firstOrNull { it.id == id } }
|
||||
val promptFlag = when (question) {
|
||||
is Question.LetterBank -> country?.let { FlagUi(it.flagAssetId.removePrefix("flag-"), "mystery country") }
|
||||
is Question.MultipleChoice -> question.promptAssetId?.let { FlagUi(it.removePrefix("flag-"), "mystery country") }
|
||||
}
|
||||
QuizScreen(
|
||||
prompt = question.prompt,
|
||||
imageDescription = if (promptFlag == null) null else "A flag to identify",
|
||||
score = run.score,
|
||||
sessionTime = if (run.configuration.duration == QuizDuration.Unlimited) {
|
||||
(run.attempts.sumOf { it.responseMillis } + run.activeQuestion.questionElapsedMillis).formatTime()
|
||||
} else run.sessionRemainingMillis.formatTime(),
|
||||
questionTime = run.activeQuestion.questionRemainingMillis.formatTime(),
|
||||
timerExpired = run.activeQuestion.questionRemainingMillis == 0L,
|
||||
choices = (question as? Question.MultipleChoice)?.let {
|
||||
run.activeQuestion.orderedChoices.map { choice ->
|
||||
val choiceCountry = controller.catalog.countries.firstOrNull { it.id == choice.id }
|
||||
ChoiceUi(
|
||||
id = choice.id,
|
||||
label = choice.label,
|
||||
state = if (choice.id in run.activeQuestion.removedChoiceIds) AnswerState.Eliminated else AnswerState.Available,
|
||||
enabled = choice.id !in run.activeQuestion.removedChoiceIds && run.phase == RunPhase.Answering,
|
||||
flag = choice.assetId?.let { FlagUi(it.removePrefix("flag-"), choiceCountry?.displayName ?: choice.label) },
|
||||
)
|
||||
}
|
||||
}.orEmpty(),
|
||||
letterSlots = letterSlots(question, run.activeQuestion.placements, run.activeQuestion.letterTiles),
|
||||
letterTiles = run.activeQuestion.letterTiles.map { tile ->
|
||||
LetterTileUi(tile.id, tile.character.toString(), run.activeQuestion.placements.firstOrNull { it.tileId == tile.id }?.slotIndex, tile.id !in run.activeQuestion.removedTileIds)
|
||||
},
|
||||
hintAvailable = run.activeQuestion.hint == null && run.phase == RunPhase.Answering,
|
||||
unlimited = run.configuration.duration == QuizDuration.Unlimited,
|
||||
canSubmit = question is Question.LetterBank && run.activeQuestion.placements.size == question.displayAnswer.count { it.isLetter() },
|
||||
onChoice = controller::answerChoice,
|
||||
onLetter = controller::placeLetter,
|
||||
onRemoveLetter = controller::removeLetter,
|
||||
onClearLetters = controller::clearLetters,
|
||||
onSubmit = controller::submitLetters,
|
||||
onHint = controller::openHint,
|
||||
onFinish = controller::finishUnlimited,
|
||||
onLeave = controller::requestLeave,
|
||||
promptFlag = promptFlag,
|
||||
)
|
||||
}
|
||||
RunPhase.HintChooser -> HintChooserScreen(
|
||||
score = run.score,
|
||||
hints = run.activeQuestion.question.supportedHints.map { ChoiceUi(it.name, it.displayName()) },
|
||||
onChooseHint = { id -> controller.chooseHint(HintType.valueOf(id)) },
|
||||
onCancel = controller::cancelHint,
|
||||
)
|
||||
RunPhase.Feedback -> {
|
||||
val feedback = requireNotNull(run.feedback)
|
||||
LaunchedEffect(run.attempts.size, state.preferences.autoAdvanceEnabled) {
|
||||
if (state.preferences.autoAdvanceEnabled) {
|
||||
delay(2_500)
|
||||
controller.continueRun()
|
||||
}
|
||||
}
|
||||
FeedbackScreen(
|
||||
correct = feedback.kind != FeedbackKind.Incorrect,
|
||||
overtime = feedback.kind == FeedbackKind.OvertimeCorrect,
|
||||
heading = when (feedback.kind) { FeedbackKind.Correct -> "Exactly right"; FeedbackKind.Incorrect -> "Not quite"; FeedbackKind.OvertimeCorrect -> "Worth the wait" },
|
||||
selectedAnswer = feedback.submittedAnswer,
|
||||
correctAnswer = feedback.correctAnswer,
|
||||
pointsAwarded = feedback.pointsAwarded,
|
||||
explanation = feedback.explanation,
|
||||
autoAdvanceMessage = if (state.preferences.autoAdvanceEnabled) "Continuing shortly…" else null,
|
||||
onContinue = controller::continueRun,
|
||||
)
|
||||
}
|
||||
RunPhase.FunFact -> requireNotNull(run.visibleFact).let { fact ->
|
||||
FunFactScreen(fact.heading, fact.body, countryNames(controller, fact.countryIds).joinToString(), onContinue = controller::continueRun)
|
||||
}
|
||||
RunPhase.Completed -> BootstrapScreen("Finishing your field notes…")
|
||||
}
|
||||
}
|
||||
|
||||
private fun durationChoices(selected: QuizDuration) = QuizDuration.entries.map { duration ->
|
||||
ChoiceUi(duration.name, when (duration) { QuizDuration.FiveMinutes -> "5 minutes"; QuizDuration.TenMinutes -> "10 minutes"; QuizDuration.TwentyMinutes -> "20 minutes"; QuizDuration.Unlimited -> "Unlimited" }, state = if (duration == selected) AnswerState.Selected else AnswerState.Available)
|
||||
}
|
||||
|
||||
private fun topicChoices() = listOf(
|
||||
ChoiceUi("MostlyFlags", "Mostly flags", "Recognize flags and spell country names"),
|
||||
ChoiceUi("Capitals", "Capitals", "Match countries and capitals"),
|
||||
ChoiceUi("MixedGeography", "Mixed geography", "A broader grab bag"),
|
||||
)
|
||||
|
||||
private fun letterSlots(question: Question, placements: List<app.bertsatlas.domain.model.LetterPlacement>, tiles: List<app.bertsatlas.domain.model.LetterTile>): List<LetterSlotUi> {
|
||||
val letterBank = question as? Question.LetterBank ?: return emptyList()
|
||||
val tileById = tiles.associateBy { it.id }
|
||||
var editableIndex = 0
|
||||
return letterBank.displayAnswer.mapIndexed { displayIndex, character ->
|
||||
if (!character.isLetter()) LetterSlotUi(displayIndex, character.toString(), fixed = true)
|
||||
else {
|
||||
val placement = placements.firstOrNull { it.slotIndex == editableIndex }
|
||||
LetterSlotUi(displayIndex, placement?.let { tileById[it.tileId]?.character?.toString() }, placement?.tileId).also { editableIndex++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun HintType.displayName() = when (this) {
|
||||
HintType.RevealCorrectLetter -> "Reveal one letter"
|
||||
HintType.RemoveIncorrectOptions -> "Remove two wrong choices"
|
||||
HintType.RevealContinent -> "Reveal the region"
|
||||
HintType.FunnyFactualClue -> "Give me a factual clue"
|
||||
}
|
||||
|
||||
private fun Long?.formatTime(): String {
|
||||
if (this == null) return "∞"
|
||||
val seconds = (this.coerceAtLeast(0) + 999) / 1_000
|
||||
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private fun countryNames(controller: AppController, ids: List<String>): List<String> = ids.distinct().mapNotNull { id ->
|
||||
controller.catalog.countries.firstOrNull { it.id == id }?.displayName
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package app.bertsatlas
|
||||
|
||||
import app.bertsatlas.data.ActiveRunSnapshotEntity
|
||||
import app.bertsatlas.data.AttemptEntity
|
||||
import app.bertsatlas.data.BertsAtlasDatabase
|
||||
import app.bertsatlas.data.MasteryEntity
|
||||
import app.bertsatlas.data.PersonalBestEntity
|
||||
import app.bertsatlas.data.PreferencesStore
|
||||
import app.bertsatlas.data.RunEntity
|
||||
import app.bertsatlas.data.SavedPreferences
|
||||
import app.bertsatlas.data.StampEntity
|
||||
import app.bertsatlas.data.StreakEntity
|
||||
import app.bertsatlas.domain.LocalDateProvider
|
||||
import app.bertsatlas.domain.MasteryRules
|
||||
import app.bertsatlas.domain.MonotonicClock
|
||||
import app.bertsatlas.domain.PassportEvidence
|
||||
import app.bertsatlas.domain.PassportRules
|
||||
import app.bertsatlas.domain.QuizEngine
|
||||
import app.bertsatlas.domain.StreakRules
|
||||
import app.bertsatlas.domain.WallClock
|
||||
import app.bertsatlas.domain.model.ActiveRunSnapshot
|
||||
import app.bertsatlas.domain.model.Catalog
|
||||
import app.bertsatlas.domain.model.Continent
|
||||
import app.bertsatlas.domain.model.HintType
|
||||
import app.bertsatlas.domain.model.Mastery
|
||||
import app.bertsatlas.domain.model.MasteryLevel
|
||||
import app.bertsatlas.domain.model.PassportStamp
|
||||
import app.bertsatlas.domain.model.PassportStampKind
|
||||
import app.bertsatlas.domain.model.Question
|
||||
import app.bertsatlas.domain.model.QuizConfiguration
|
||||
import app.bertsatlas.domain.model.QuizDuration
|
||||
import app.bertsatlas.domain.model.QuizRunState
|
||||
import app.bertsatlas.domain.model.QuizTopic
|
||||
import app.bertsatlas.domain.model.RunPhase
|
||||
import app.bertsatlas.domain.model.RunResult
|
||||
import app.bertsatlas.domain.model.RunStatus
|
||||
import app.bertsatlas.domain.model.Streak
|
||||
import app.bertsatlas.domain.model.SubmittedAnswer
|
||||
import app.bertsatlas.ui.DebugActionUi
|
||||
import app.bertsatlas.ui.ProgressGroupUi
|
||||
import app.bertsatlas.ui.SettingToggleUi
|
||||
import app.bertsatlas.ui.StampUi
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.TimeSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
sealed interface Destination {
|
||||
data object Bootstrap : Destination
|
||||
data object Recovery : Destination
|
||||
data object Home : Destination
|
||||
data class Options(val step: Int) : Destination
|
||||
data object Quiz : Destination
|
||||
data object Results : Destination
|
||||
data object Progress : Destination
|
||||
data object Settings : Destination
|
||||
data object Privacy : Destination
|
||||
data object Credits : Destination
|
||||
data object Reset : Destination
|
||||
data object Debug : Destination
|
||||
}
|
||||
|
||||
data class AtlasAppState(
|
||||
val destination: Destination = Destination.Bootstrap,
|
||||
val firstLaunch: Boolean = true,
|
||||
val preferences: SavedPreferences = SavedPreferences(),
|
||||
val selectedDuration: QuizDuration = QuizDuration.FiveMinutes,
|
||||
val run: QuizRunState? = null,
|
||||
val result: RunResult? = null,
|
||||
val newPersonalBest: Boolean = false,
|
||||
val mastery: List<Mastery> = emptyList(),
|
||||
val attempts: List<AttemptEntity> = emptyList(),
|
||||
val completedRuns: List<RunEntity> = emptyList(),
|
||||
val streak: Streak = Streak(),
|
||||
val stamps: List<PassportStamp> = emptyList(),
|
||||
)
|
||||
|
||||
class AppController(
|
||||
val catalog: Catalog,
|
||||
private val database: BertsAtlasDatabase,
|
||||
private val preferencesStore: PreferencesStore,
|
||||
private val scope: CoroutineScope,
|
||||
val debugBuild: Boolean,
|
||||
private val deviceFeedback: DeviceFeedback = NoDeviceFeedback,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val mutex = Mutex()
|
||||
private val monotonicOrigin = TimeSource.Monotonic.markNow()
|
||||
private val monotonicClock = MonotonicClock { monotonicOrigin.elapsedNow().inWholeMilliseconds }
|
||||
private val wallClock = WallClock { Clock.System.now().toEpochMilliseconds() }
|
||||
private val localDateProvider = LocalDateProvider {
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toEpochDays().toInt()
|
||||
}
|
||||
private val engine = QuizEngine(catalog, monotonicClock, wallClock, localDateProvider, allowUnapprovedContent = debugBuild)
|
||||
private val _state = MutableStateFlow(AtlasAppState())
|
||||
val state: StateFlow<AtlasAppState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { bootstrap() }
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(100)
|
||||
val current = _state.value.run
|
||||
if (current?.phase == RunPhase.Answering) {
|
||||
_state.value = _state.value.copy(run = engine.advanceClocks(current))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bootstrap() = mutex.withLock {
|
||||
val preferences = preferencesStore.values.first()
|
||||
val mastery = readMastery()
|
||||
val attempts = database.progressReader().attemptsNow()
|
||||
val runs = database.progressReader().completedRunsNow()
|
||||
val streak = database.progressReader().streakNow().toDomain()
|
||||
val stamps = database.progressReader().stampsNow().mapNotNull { it.toDomain() }
|
||||
val snapshot = database.progressReader().activeRun()
|
||||
val restored = snapshot?.let {
|
||||
runCatching { engine.restoreRun(json.decodeFromString<ActiveRunSnapshot>(it.snapshotJson)) }.getOrNull()
|
||||
}
|
||||
if (snapshot != null && restored == null) database.runWriter().clearActiveRun()
|
||||
_state.value = AtlasAppState(
|
||||
destination = if (restored == null) Destination.Home else Destination.Recovery,
|
||||
firstLaunch = !preferences.hasPreviouslyLaunched,
|
||||
preferences = preferences,
|
||||
selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes),
|
||||
run = restored,
|
||||
mastery = mastery,
|
||||
attempts = attempts,
|
||||
completedRuns = runs,
|
||||
streak = streak,
|
||||
stamps = stamps,
|
||||
)
|
||||
preferencesStore.markLaunched()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val current = _state.value
|
||||
if (current.firstLaunch) {
|
||||
beginRun(QuizConfiguration())
|
||||
} else {
|
||||
beginRun(
|
||||
QuizConfiguration(
|
||||
enumValueOrDefault(current.preferences.duration, QuizDuration.FiveMinutes),
|
||||
enumValueOrDefault(current.preferences.topic, QuizTopic.MostlyFlags),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openOptions() { _state.value = _state.value.copy(destination = Destination.Options(1)) }
|
||||
|
||||
fun chooseOption(id: String) {
|
||||
when (val destination = _state.value.destination) {
|
||||
is Destination.Options -> if (destination.step == 1) {
|
||||
_state.value = _state.value.copy(
|
||||
selectedDuration = enumValueOrDefault(id, QuizDuration.FiveMinutes),
|
||||
destination = Destination.Options(2),
|
||||
)
|
||||
} else {
|
||||
val topic = enumValueOrDefault(id, QuizTopic.MostlyFlags)
|
||||
val duration = _state.value.selectedDuration
|
||||
scope.launch {
|
||||
preferencesStore.saveConfiguration(duration.name, topic.name)
|
||||
val saved = preferencesStore.values.first()
|
||||
_state.value = _state.value.copy(preferences = saved)
|
||||
beginRun(QuizConfiguration(duration, topic))
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun back() {
|
||||
_state.value = when (val destination = _state.value.destination) {
|
||||
is Destination.Options -> _state.value.copy(destination = if (destination.step == 2) Destination.Options(1) else Destination.Home)
|
||||
Destination.Privacy, Destination.Credits, Destination.Reset, Destination.Debug -> _state.value.copy(destination = Destination.Settings)
|
||||
else -> _state.value.copy(destination = Destination.Home)
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginRun(configuration: QuizConfiguration) {
|
||||
val now = wallClock.nowEpochMillis()
|
||||
val run = engine.startRun("run-$now", configuration, now xor monotonicClock.nowMillis(), _state.value.mastery)
|
||||
_state.value = _state.value.copy(destination = Destination.Quiz, firstLaunch = false, run = run, result = null)
|
||||
persistSnapshot(run)
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
val run = _state.value.run ?: return
|
||||
val resumed = engine.resumeRun(run)
|
||||
_state.value = _state.value.copy(destination = destinationFor(resumed), run = resumed)
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
fun answerChoice(choiceId: String) = mutateRun { engine.answerQuestion(it, SubmittedAnswer.ChoiceAnswer(choiceId)) }.also {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
}
|
||||
|
||||
fun placeLetter(tileId: String) = mutateRun { engine.placeLetter(it, tileId) }
|
||||
|
||||
fun removeLetter(tileId: String) = mutateRun { state ->
|
||||
val slot = state.activeQuestion.placements.first { it.tileId == tileId }.slotIndex
|
||||
engine.removeLetter(state, slot)
|
||||
}
|
||||
|
||||
fun clearLetters() = mutateRun(engine::clearLetters)
|
||||
|
||||
fun submitLetters() = mutateRun(engine::submitLetters).also {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
}
|
||||
|
||||
fun openHint() = mutateRun(engine::openHintChooser)
|
||||
fun cancelHint() = mutateRun { state -> engine.resumeRun(state.copy(phase = RunPhase.Paused, pausedFromPhase = RunPhase.Answering)) }
|
||||
fun chooseHint(type: HintType) = mutateRun { engine.chooseHint(it, type) }.also { persistSnapshot(_state.value.run) }
|
||||
|
||||
fun continueRun() {
|
||||
val before = _state.value.run ?: return
|
||||
val next = when (before.phase) {
|
||||
RunPhase.Feedback -> engine.dismissFeedback(before, _state.value.mastery)
|
||||
RunPhase.FunFact -> engine.continueFromFact(before, _state.value.mastery)
|
||||
else -> return
|
||||
}
|
||||
acceptTransition(next)
|
||||
}
|
||||
|
||||
fun finishUnlimited() {
|
||||
val run = _state.value.run ?: return
|
||||
acceptTransition(engine.finishUnlimitedRun(run, _state.value.mastery))
|
||||
}
|
||||
|
||||
fun requestLeave() { abandon() }
|
||||
|
||||
fun abandon() {
|
||||
val run = _state.value.run ?: return
|
||||
val abandoned = if (run.status == RunStatus.Active) engine.abandonRun(run) else run
|
||||
_state.value = _state.value.copy(destination = Destination.Home, run = null)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
database.runWriter().abandonRun(abandoned.toRunEntity())
|
||||
refreshProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseForInterruption() {
|
||||
val run = _state.value.run ?: return
|
||||
if (run.status != RunStatus.Active || run.phase == RunPhase.Paused) return
|
||||
val paused = engine.pauseForInterruption(run)
|
||||
_state.value = _state.value.copy(run = paused)
|
||||
persistSnapshot(paused)
|
||||
}
|
||||
|
||||
fun resumeAfterInterruption() {
|
||||
val run = _state.value.run ?: return
|
||||
if (_state.value.destination != Destination.Quiz || run.phase != RunPhase.Paused) return
|
||||
val resumed = engine.resumeRun(run)
|
||||
_state.value = _state.value.copy(run = resumed)
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
private fun mutateRun(change: (QuizRunState) -> QuizRunState) {
|
||||
val current = _state.value.run ?: return
|
||||
val next = runCatching { change(current) }.getOrNull() ?: return
|
||||
_state.value = _state.value.copy(destination = destinationFor(next), run = next)
|
||||
}
|
||||
|
||||
private fun acceptTransition(next: QuizRunState) {
|
||||
if (next.status == RunStatus.Completed) complete(next)
|
||||
else {
|
||||
_state.value = _state.value.copy(destination = destinationFor(next), run = next)
|
||||
persistSnapshot(next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistAcceptedAnswer() {
|
||||
val run = _state.value.run ?: return
|
||||
val attempt = run.attempts.lastOrNull() ?: return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val before = _state.value.mastery
|
||||
val after = MasteryRules.applyAttemptToCountries(before, attempt)
|
||||
val changed = after.filter { updated -> before.none { it.countryId == updated.countryId && it.skill == updated.skill && it == updated } }
|
||||
database.runWriter().recordAcceptedAnswer(
|
||||
attempt.toEntity(json),
|
||||
changed.map { it.toEntity() },
|
||||
run.toSnapshotEntity(engine, json),
|
||||
)
|
||||
_state.value = _state.value.copy(mastery = after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAnswerFeedback() {
|
||||
val run = _state.value.run ?: return
|
||||
val correct = run.feedback?.kind != app.bertsatlas.domain.model.FeedbackKind.Incorrect
|
||||
deviceFeedback.play(correct, _state.value.preferences.soundEnabled, _state.value.preferences.hapticsEnabled)
|
||||
}
|
||||
|
||||
private fun persistSnapshot(run: QuizRunState?) {
|
||||
if (run == null || run.status != RunStatus.Active) return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
database.runWriter().saveActiveRun(run.toSnapshotEntity(engine, json))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun complete(run: QuizRunState) {
|
||||
val result = engine.runResult(run, improvedMasteryItems = run.attempts.map { it.countryIds.firstOrNull() to it.skill }.distinct().size)
|
||||
_state.value = _state.value.copy(destination = Destination.Results, run = run, result = result)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val reader = database.progressReader()
|
||||
val oldBest = reader.personalBestsNow().firstOrNull {
|
||||
it.duration == run.configuration.duration.name && it.topic == run.configuration.topic.name
|
||||
}
|
||||
val isBest = oldBest == null || result.score > oldBest.score
|
||||
val newStreak = StreakRules.recordCompletedRun(_state.value.streak, localDateProvider.currentEpochDay())
|
||||
val existingStamps = _state.value.stamps
|
||||
val attempts = reader.attemptsNow()
|
||||
val mastery = _state.value.mastery
|
||||
val flagContinents = mastery.filter { it.skill.name.contains("Flag") && it.correctAnswers > 0 }
|
||||
.flatMap { masteryItem -> catalog.countries.firstOrNull { it.id == masteryItem.countryId }?.continentIds.orEmpty() }.toSet()
|
||||
val newlyEarned = PassportRules.newlyEarned(
|
||||
PassportEvidence(
|
||||
completedRunCount = reader.completedRunsNow().size + 1,
|
||||
recognizedFlagContinents = flagContinents,
|
||||
totalAnswered = attempts.size,
|
||||
totalCorrect = attempts.count { it.correct },
|
||||
confusedFlagMastery = emptyList(),
|
||||
streak = newStreak,
|
||||
),
|
||||
existingStamps,
|
||||
wallClock.nowEpochMillis(),
|
||||
)
|
||||
database.runWriter().completeRun(
|
||||
run.toRunEntity(result),
|
||||
if (isBest) PersonalBestEntity(run.configuration.duration.name, run.configuration.topic.name, result.score, run.runId) else null,
|
||||
newStreak.toEntity(),
|
||||
newlyEarned.map { StampEntity(it.kind.name, it.earnedAtEpochMillis) },
|
||||
)
|
||||
_state.value = _state.value.copy(newPersonalBest = isBest, streak = newStreak, stamps = existingStamps + newlyEarned)
|
||||
refreshProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshProgress() {
|
||||
val reader = database.progressReader()
|
||||
_state.value = _state.value.copy(
|
||||
mastery = readMastery(),
|
||||
attempts = reader.attemptsNow(),
|
||||
completedRuns = reader.completedRunsNow(),
|
||||
streak = reader.streakNow().toDomain(),
|
||||
stamps = reader.stampsNow().mapNotNull { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
fun navigate(destination: Destination) { _state.value = _state.value.copy(destination = destination) }
|
||||
|
||||
fun toggleSetting(id: String, enabled: Boolean) {
|
||||
scope.launch {
|
||||
when (id) {
|
||||
"sound" -> preferencesStore.setSoundEnabled(enabled)
|
||||
"haptics" -> preferencesStore.setHapticsEnabled(enabled)
|
||||
"motion" -> preferencesStore.setMotionMode(if (enabled) "Reduced" else "System")
|
||||
"auto" -> preferencesStore.setAutoAdvanceEnabled(enabled)
|
||||
}
|
||||
_state.value = _state.value.copy(preferences = preferencesStore.values.first())
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAll() {
|
||||
scope.launch {
|
||||
database.runWriter().resetProgress()
|
||||
preferencesStore.resetAll()
|
||||
_state.value = AtlasAppState(destination = Destination.Home)
|
||||
}
|
||||
}
|
||||
|
||||
fun debugAction(id: String) {
|
||||
if (!debugBuild) return
|
||||
when (id) {
|
||||
"reset-first" -> scope.launch { preferencesStore.resetFirstLaunchAndConfiguration(); _state.value = _state.value.copy(firstLaunch = true, destination = Destination.Home) }
|
||||
"reset-progress" -> scope.launch { database.runWriter().resetProgress(); refreshProgress(); _state.value = _state.value.copy(destination = Destination.Home, run = null) }
|
||||
"reset-all" -> resetAll()
|
||||
"question-zero" -> updateRunDebug { it.copy(activeQuestion = it.activeQuestion.copy(questionRemainingMillis = 0)) }
|
||||
"session-zero" -> updateRunDebug { it.copy(sessionRemainingMillis = 0, sessionExpired = true) }
|
||||
"score-100" -> updateRunDebug { it.copy(score = 100) }
|
||||
}
|
||||
}
|
||||
|
||||
val settings: List<SettingToggleUi> get() = listOf(
|
||||
SettingToggleUi("sound", "Sound", "Short offline cues", _state.value.preferences.soundEnabled),
|
||||
SettingToggleUi("haptics", "Haptics", "Touch feedback where supported", _state.value.preferences.hapticsEnabled),
|
||||
SettingToggleUi("motion", "Reduce motion", "Use Bert's static poses", _state.value.preferences.motionMode == "Reduced"),
|
||||
SettingToggleUi("auto", "Auto-advance", "Move on after feedback", _state.value.preferences.autoAdvanceEnabled),
|
||||
)
|
||||
|
||||
val debugActions: List<DebugActionUi> get() = listOf(
|
||||
DebugActionUi("score-100", "Set run score to 100"),
|
||||
DebugActionUi("question-zero", "Set question timer to zero"),
|
||||
DebugActionUi("session-zero", "Expire session after this question"),
|
||||
DebugActionUi("reset-first", "Reset first launch"),
|
||||
DebugActionUi("reset-progress", "Reset progress", destructive = true),
|
||||
DebugActionUi("reset-all", "Reset all local data", destructive = true),
|
||||
)
|
||||
|
||||
val progressGroups: List<ProgressGroupUi> get() = MasteryLevel.entries.map { level ->
|
||||
val count = _state.value.mastery.count { it.level == level }
|
||||
ProgressGroupUi(level.name, count / (catalog.countries.size * 6f), "$count knowledge items")
|
||||
}
|
||||
|
||||
val stampUi: List<StampUi> get() = PassportStampKind.entries.map { kind ->
|
||||
StampUi(kind.name, kind.displayName(), kind.description(), _state.value.stamps.any { it.kind == kind })
|
||||
}
|
||||
|
||||
private fun updateRunDebug(change: (QuizRunState) -> QuizRunState) {
|
||||
val run = _state.value.run ?: return
|
||||
val updated = change(run)
|
||||
_state.value = _state.value.copy(run = updated)
|
||||
persistSnapshot(updated)
|
||||
}
|
||||
|
||||
private suspend fun readMastery(): List<Mastery> = database.progressReader().masteryNow().mapNotNull { entity ->
|
||||
runCatching { json.decodeFromString<Mastery>(entity.recentOutcomes) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun destinationFor(run: QuizRunState): Destination = when (run.phase) {
|
||||
RunPhase.Answering, RunPhase.Paused -> Destination.Quiz
|
||||
RunPhase.HintChooser -> Destination.Quiz
|
||||
RunPhase.Feedback -> Destination.Quiz
|
||||
RunPhase.FunFact -> Destination.Quiz
|
||||
RunPhase.Completed -> Destination.Results
|
||||
}
|
||||
|
||||
private fun app.bertsatlas.domain.model.Attempt.toEntity(json: Json) = AttemptEntity(
|
||||
id = id,
|
||||
runId = runId,
|
||||
questionId = questionId,
|
||||
countryIdsJson = json.encodeToString(countryIds.toSet()),
|
||||
skill = skill.name,
|
||||
correct = correct,
|
||||
hinted = hinted,
|
||||
overtime = overtime,
|
||||
submittedAnswer = submittedAnswer,
|
||||
correctAnswer = correctAnswer,
|
||||
responseMillis = responseMillis,
|
||||
pointsAwarded = pointsAwarded,
|
||||
answeredAtEpochMillis = answeredAtEpochMillis,
|
||||
localEpochDay = localEpochDay,
|
||||
hintType = hintType?.name,
|
||||
hintPointsDeducted = hintPointsDeducted,
|
||||
questionWindowMillis = questionWindowMillis,
|
||||
questionRemainingMillis = questionRemainingMillis,
|
||||
)
|
||||
|
||||
private fun Mastery.toEntity() = MasteryEntity(
|
||||
countryId, skill.name, level.name, attempts, unhintedCorrectAnswers, unhintedCorrectRunIds.size,
|
||||
unhintedCorrectEpochDays.size, nextReviewEpochDay?.toString().orEmpty(), Json.encodeToString(this),
|
||||
)
|
||||
|
||||
private fun QuizRunState.toSnapshotEntity(engine: QuizEngine, json: Json) = engine.snapshotActiveRun(this).let {
|
||||
ActiveRunSnapshotEntity(schemaVersion = it.schemaVersion, catalogVersion = it.catalogVersion, snapshotJson = json.encodeToString(it))
|
||||
}
|
||||
|
||||
private fun QuizRunState.toRunEntity(result: RunResult? = null) = RunEntity(
|
||||
id = runId,
|
||||
duration = configuration.duration.name,
|
||||
topic = configuration.topic.name,
|
||||
status = status.name,
|
||||
score = score,
|
||||
correctAnswers = attempts.count { it.correct },
|
||||
answeredQuestions = attempts.size,
|
||||
totalResponseMillis = attempts.sumOf { it.responseMillis },
|
||||
bestCorrectStreak = bestCorrectStreak,
|
||||
completedAtEpochMillis = result?.completedAtEpochMillis,
|
||||
completedLocalDate = result?.completedAtEpochMillis?.let { attempts.lastOrNull()?.localEpochDay?.toString() },
|
||||
completedTimeZoneId = result?.let { TimeZone.currentSystemDefault().id },
|
||||
)
|
||||
|
||||
private fun Streak.toEntity() = StreakEntity(currentDays = currentDays, bestDays = bestDays, lastCompletionDate = lastCompletedEpochDay?.toString(), lastCompletionTimeZoneId = TimeZone.currentSystemDefault().id)
|
||||
private fun StreakEntity?.toDomain() = this?.let { Streak(currentDays, bestDays, lastCompletionDate?.toIntOrNull()) } ?: Streak()
|
||||
private fun StampEntity.toDomain() = runCatching { PassportStamp(PassportStampKind.valueOf(id), earnedAtEpochMillis) }.getOrNull()
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumValueOrDefault(name: String, fallback: T): T =
|
||||
enumValues<T>().firstOrNull { it.name == name } ?: fallback
|
||||
|
||||
private fun PassportStampKind.displayName() = when (this) {
|
||||
PassportStampKind.FirstCompletedRun -> "First expedition"
|
||||
PassportStampKind.FlagsFromEveryContinent -> "Around the world"
|
||||
PassportStampKind.SustainedAccuracy -> "Sharp atlas"
|
||||
PassportStampKind.ConfusedFlagsMastered -> "Lookalike detective"
|
||||
PassportStampKind.SevenActiveDays -> "Seven-day curiosity"
|
||||
}
|
||||
|
||||
private fun PassportStampKind.description() = when (this) {
|
||||
PassportStampKind.FirstCompletedRun -> "Complete a scored run"
|
||||
PassportStampKind.FlagsFromEveryContinent -> "Recognize flags from all six regions"
|
||||
PassportStampKind.SustainedAccuracy -> "Reach 80% over 50 answers"
|
||||
PassportStampKind.ConfusedFlagsMastered -> "Master the confusing flag groups"
|
||||
PassportStampKind.SevenActiveDays -> "Complete runs on seven consecutive days"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package app.bertsatlas
|
||||
|
||||
fun interface DeviceFeedback {
|
||||
fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean)
|
||||
}
|
||||
|
||||
val NoDeviceFeedback = DeviceFeedback { _, _, _ -> }
|
||||
@@ -0,0 +1,420 @@
|
||||
package app.bertsatlas.catalog
|
||||
|
||||
import app.bertsatlas.domain.CatalogProvider
|
||||
import app.bertsatlas.domain.model.AssetLicenseRecord
|
||||
import app.bertsatlas.domain.model.CapitalArrangement
|
||||
import app.bertsatlas.domain.model.Catalog
|
||||
import app.bertsatlas.domain.model.Choice
|
||||
import app.bertsatlas.domain.model.Continent
|
||||
import app.bertsatlas.domain.model.CountryRecord
|
||||
import app.bertsatlas.domain.model.DifficultyTier
|
||||
import app.bertsatlas.domain.model.EditorialReviewStatus
|
||||
import app.bertsatlas.domain.model.FactRecord
|
||||
import app.bertsatlas.domain.model.HintType
|
||||
import app.bertsatlas.domain.model.Question
|
||||
import app.bertsatlas.domain.model.QuizTopic
|
||||
import app.bertsatlas.domain.model.Skill
|
||||
import app.bertsatlas.domain.model.SourceRecord
|
||||
import berts_atlas.composeapp.generated.resources.Res
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class BundledCatalogProvider(
|
||||
private val readCatalogText: suspend () -> String = {
|
||||
Res.readBytes("files/catalog/catalog-v1.json").decodeToString()
|
||||
},
|
||||
) : CatalogProvider {
|
||||
private var loadedCatalog: Catalog? = null
|
||||
|
||||
override suspend fun loadCatalog(): Catalog {
|
||||
loadedCatalog?.let { return it }
|
||||
return CatalogLoader.load(readCatalogText()).also { loadedCatalog = it }
|
||||
}
|
||||
}
|
||||
|
||||
object CatalogLoader {
|
||||
fun load(json: String): Catalog {
|
||||
val source = decoder.decodeFromString<SourceCatalog>(json)
|
||||
val countriesById = source.countries.associateBy(SourceCountry::id)
|
||||
val factsByCountry = source.facts
|
||||
.flatMap { fact -> fact.countryIds.map { it to fact } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
val similarityGroupsByCountry = source.similarityGroups
|
||||
.flatMap { group -> group.countryIds.map { it to group.id } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
val domainCountries = source.countries.map { country ->
|
||||
country.toDomain(similarityGroupsByCountry[country.id].orEmpty())
|
||||
}
|
||||
val generatedQuestions = source.countries.flatMapIndexed { index, country ->
|
||||
generatedQuestions(
|
||||
country = country,
|
||||
countryIndex = index,
|
||||
allCountries = source.countries,
|
||||
similarityGroupIds = similarityGroupsByCountry[country.id].orEmpty(),
|
||||
similarityGroups = source.similarityGroups,
|
||||
relatedFacts = factsByCountry[country.id].orEmpty(),
|
||||
)
|
||||
}
|
||||
val curatedQuestions = source.curatedQuestions.map { question ->
|
||||
question.toDomain(countriesById)
|
||||
}
|
||||
return Catalog(
|
||||
schemaVersion = source.schemaVersion,
|
||||
contentVersion = source.catalogVersion,
|
||||
countries = domainCountries,
|
||||
questions = generatedQuestions + curatedQuestions,
|
||||
facts = source.facts.map { it.toDomain() },
|
||||
sources = source.sources.map { it.toDomain() },
|
||||
assetLicenses = source.assets.map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun generatedQuestions(
|
||||
country: SourceCountry,
|
||||
countryIndex: Int,
|
||||
allCountries: List<SourceCountry>,
|
||||
similarityGroupIds: List<String>,
|
||||
similarityGroups: List<SourceSimilarityGroup>,
|
||||
relatedFacts: List<SourceFact>,
|
||||
): List<Question> {
|
||||
val distractors = chooseDistractors(
|
||||
country = country,
|
||||
allCountries = allCountries,
|
||||
similarityGroupIds = similarityGroupIds,
|
||||
similarityGroups = similarityGroups,
|
||||
)
|
||||
val choices = listOf(country) + distractors
|
||||
val capitalChoices = buildList {
|
||||
add(country)
|
||||
addAll(distractors)
|
||||
addAll(allCountries)
|
||||
}
|
||||
.distinctBy { it.capital.acceptedAnswer.lowercase() }
|
||||
.take(4)
|
||||
require(capitalChoices.size == 4) { "Not enough unique capital choices for ${country.id}" }
|
||||
val difficulty = generatedDifficulty(countryIndex)
|
||||
val continentHint = country.region.toContinent().displayName()
|
||||
val factualClue = relatedFacts.firstOrNull()?.text
|
||||
val commonHintText = buildMap {
|
||||
put(HintType.RevealContinent, continentHint)
|
||||
factualClue?.let { put(HintType.FunnyFactualClue, it) }
|
||||
}
|
||||
val factualHint = if (factualClue == null) emptySet() else setOf(HintType.FunnyFactualClue)
|
||||
val sharedHints = setOf(HintType.RevealContinent) + factualHint
|
||||
val flagToCountry = Question.MultipleChoice(
|
||||
id = "generated-flag-to-country-${country.id}",
|
||||
prompt = "Which country does this flag belong to?",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.FlagRecognition,
|
||||
topic = QuizTopic.MostlyFlags,
|
||||
difficulty = difficulty,
|
||||
explanation = "This is the flag of ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
choices = choices.map { Choice(id = it.id, label = it.displayName) },
|
||||
correctChoiceId = country.id,
|
||||
promptAssetId = country.flagAssetId,
|
||||
)
|
||||
val countryToFlag = Question.MultipleChoice(
|
||||
id = "generated-country-to-flag-${country.id}",
|
||||
prompt = "Which flag belongs to ${country.displayName}?",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.CountryToFlag,
|
||||
topic = QuizTopic.MostlyFlags,
|
||||
difficulty = difficulty,
|
||||
explanation = "This is the flag of ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
choices = choices.map {
|
||||
Choice(id = it.id, label = it.displayName, assetId = it.flagAssetId)
|
||||
},
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
val letterBank = Question.LetterBank(
|
||||
id = "generated-letter-bank-${country.id}",
|
||||
prompt = "Spell the country shown by this flag.",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.FlagRecognition,
|
||||
topic = QuizTopic.MostlyFlags,
|
||||
difficulty = difficulty,
|
||||
explanation = "The flag belongs to ${country.displayName}.",
|
||||
supportedHints = sharedHints + setOf(
|
||||
HintType.RevealCorrectLetter,
|
||||
HintType.RemoveIncorrectOptions,
|
||||
),
|
||||
hintText = commonHintText,
|
||||
displayAnswer = country.displayName,
|
||||
acceptedAliases = country.acceptedAliases,
|
||||
promptAssetId = country.flagAssetId,
|
||||
decoyLetters = decoyLetters(country.id, countryIndex),
|
||||
)
|
||||
val countryToCapital = Question.MultipleChoice(
|
||||
id = "generated-country-to-capital-${country.id}",
|
||||
prompt = "What is the capital of ${country.displayName}?",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.CapitalRecall,
|
||||
topic = QuizTopic.Capitals,
|
||||
difficulty = difficulty,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital answer used for ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
choices = capitalChoices.map { Choice(it.id, it.capital.acceptedAnswer) },
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
val capitalToCountry = Question.MultipleChoice(
|
||||
id = "generated-capital-to-country-${country.id}",
|
||||
prompt = "${country.capital.acceptedAnswer} is the capital answer for which country?",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.CapitalRecognition,
|
||||
topic = QuizTopic.Capitals,
|
||||
difficulty = difficulty,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital answer used for ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
choices = choices.map { Choice(it.id, it.displayName) },
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
return listOf(flagToCountry, countryToFlag, letterBank, countryToCapital, capitalToCountry)
|
||||
}
|
||||
|
||||
private fun chooseDistractors(
|
||||
country: SourceCountry,
|
||||
allCountries: List<SourceCountry>,
|
||||
similarityGroupIds: List<String>,
|
||||
similarityGroups: List<SourceSimilarityGroup>,
|
||||
): List<SourceCountry> {
|
||||
val countryIds = buildList {
|
||||
similarityGroups
|
||||
.filter { it.id in similarityGroupIds }
|
||||
.flatMapTo(this) { it.countryIds }
|
||||
allCountries.filter { it.region == country.region }.mapTo(this) { it.id }
|
||||
allCountries.mapTo(this) { it.id }
|
||||
}
|
||||
.asSequence()
|
||||
.filter { it != country.id }
|
||||
.distinct()
|
||||
.take(3)
|
||||
.toList()
|
||||
val countriesById = allCountries.associateBy(SourceCountry::id)
|
||||
return countryIds.map { countriesById.getValue(it) }
|
||||
}
|
||||
|
||||
private fun SourceCuratedQuestion.toDomain(
|
||||
countriesById: Map<String, SourceCountry>,
|
||||
): Question {
|
||||
require(format == "multiple_choice") { "Unsupported curated question format: $format" }
|
||||
val domainChoices = choices.mapIndexed { index, choice ->
|
||||
Choice(id = "$id:choice:$index", label = choice)
|
||||
}
|
||||
val answerIndex = choices.indexOf(answer)
|
||||
require(answerIndex >= 0) { "Curated question $id does not contain its answer" }
|
||||
val primaryCountry = countryIds.firstNotNullOfOrNull(countriesById::get)
|
||||
val mappedHints = supportedHints.mapTo(linkedSetOf()) { it.toHintType() }
|
||||
val hintText = buildMap {
|
||||
if (HintType.RevealContinent in mappedHints && primaryCountry != null) {
|
||||
put(HintType.RevealContinent, primaryCountry.region.toContinent().displayName())
|
||||
}
|
||||
if (HintType.FunnyFactualClue in mappedHints) {
|
||||
put(HintType.FunnyFactualClue, explanation)
|
||||
}
|
||||
}
|
||||
return Question.MultipleChoice(
|
||||
id = id,
|
||||
prompt = prompt,
|
||||
countryIds = countryIds,
|
||||
skill = if (topic == "flag_symbolism") Skill.FlagSymbolism else Skill.MixedGeography,
|
||||
topic = QuizTopic.MixedGeography,
|
||||
difficulty = difficulty.toDifficulty(),
|
||||
explanation = explanation,
|
||||
supportedHints = mappedHints,
|
||||
hintText = hintText,
|
||||
choices = domainChoices,
|
||||
correctChoiceId = domainChoices[answerIndex].id,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SourceCountry.toDomain(similarityGroupIds: List<String>): CountryRecord = CountryRecord(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
acceptedAliases = acceptedAliases,
|
||||
continentIds = listOf(region.toContinent()),
|
||||
flagAssetId = flagAssetId,
|
||||
capitalArrangement = capital.toDomain(),
|
||||
similarityGroupIds = similarityGroupIds,
|
||||
)
|
||||
|
||||
private fun SourceCapital.toDomain(): CapitalArrangement = when {
|
||||
kind == "single" -> CapitalArrangement.Single(acceptedAnswer)
|
||||
names.size > 1 -> CapitalArrangement.Multiple(
|
||||
capitals = names,
|
||||
explanation = requireNotNull(note) { "An exceptional capital arrangement requires a note" },
|
||||
)
|
||||
else -> CapitalArrangement.Exception(
|
||||
explanation = note ?: "$acceptedAnswer has an exceptional capital arrangement.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun SourceFact.toDomain() = FactRecord(
|
||||
id = id,
|
||||
heading = heading,
|
||||
body = text,
|
||||
countryIds = countryIds,
|
||||
sourceIds = sourceIds,
|
||||
asOf = asOf,
|
||||
reviewStatus = review.state.toReviewStatus(),
|
||||
)
|
||||
|
||||
private fun SourceReference.toDomain() = SourceRecord(
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
revision = revision,
|
||||
accessedOn = retrievedAt,
|
||||
)
|
||||
|
||||
private fun SourceAsset.toDomain() = AssetLicenseRecord(
|
||||
assetId = id,
|
||||
sourceId = sourceId,
|
||||
licenseName = license,
|
||||
modified = modified,
|
||||
checksum = sha256,
|
||||
)
|
||||
|
||||
private fun String.toContinent(): Continent = when (this) {
|
||||
"africa" -> Continent.Africa
|
||||
"asia" -> Continent.Asia
|
||||
"europe" -> Continent.Europe
|
||||
"north_america" -> Continent.NorthAmerica
|
||||
"south_america" -> Continent.SouthAmerica
|
||||
"oceania" -> Continent.Oceania
|
||||
else -> error("Unknown catalog region: $this")
|
||||
}
|
||||
|
||||
private fun Continent.displayName(): String = when (this) {
|
||||
Continent.Africa -> "Africa"
|
||||
Continent.Asia -> "Asia"
|
||||
Continent.Europe -> "Europe"
|
||||
Continent.NorthAmerica -> "North America"
|
||||
Continent.SouthAmerica -> "South America"
|
||||
Continent.Oceania -> "Oceania"
|
||||
Continent.Antarctica -> "Antarctica"
|
||||
}
|
||||
|
||||
private fun String.toDifficulty(): DifficultyTier = when (this) {
|
||||
"casual" -> DifficultyTier.Casual
|
||||
"tricky" -> DifficultyTier.Tricky
|
||||
"brutal" -> DifficultyTier.Brutal
|
||||
else -> error("Unknown question difficulty: $this")
|
||||
}
|
||||
|
||||
private fun String.toHintType(): HintType = when (this) {
|
||||
"reveal_letter" -> HintType.RevealCorrectLetter
|
||||
"remove_choices", "remove_decoys" -> HintType.RemoveIncorrectOptions
|
||||
"reveal_continent" -> HintType.RevealContinent
|
||||
"factual_clue" -> HintType.FunnyFactualClue
|
||||
else -> error("Unknown hint type: $this")
|
||||
}
|
||||
|
||||
private fun String.toReviewStatus(): EditorialReviewStatus = when (this) {
|
||||
"draft" -> EditorialReviewStatus.Draft
|
||||
"fact_checked" -> EditorialReviewStatus.FactChecked
|
||||
"copy_checked" -> EditorialReviewStatus.CopyChecked
|
||||
"approved" -> EditorialReviewStatus.Approved
|
||||
else -> error("Unknown editorial review state: $this")
|
||||
}
|
||||
|
||||
private fun generatedDifficulty(index: Int): DifficultyTier = when (index % 3) {
|
||||
0 -> DifficultyTier.Casual
|
||||
1 -> DifficultyTier.Tricky
|
||||
else -> DifficultyTier.Brutal
|
||||
}
|
||||
|
||||
private fun decoyLetters(countryId: String, index: Int): List<Char> = List(3) { offset ->
|
||||
DECOY_ALPHABET[(countryId.hashCode() + index + offset * 11).mod(DECOY_ALPHABET.length)]
|
||||
}
|
||||
|
||||
private const val DECOY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
private val decoder = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SourceCatalog(
|
||||
val schemaVersion: Int,
|
||||
val catalogVersion: String,
|
||||
val sources: List<SourceReference>,
|
||||
val countries: List<SourceCountry>,
|
||||
val facts: List<SourceFact>,
|
||||
val curatedQuestions: List<SourceCuratedQuestion>,
|
||||
val similarityGroups: List<SourceSimilarityGroup>,
|
||||
val assets: List<SourceAsset>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceReference(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val retrievedAt: String,
|
||||
val revision: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceCountry(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val acceptedAliases: List<String>,
|
||||
val region: String,
|
||||
val capital: SourceCapital,
|
||||
val flagAssetId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceCapital(
|
||||
val kind: String,
|
||||
val names: List<String>,
|
||||
val acceptedAnswer: String,
|
||||
val note: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceFact(
|
||||
val id: String,
|
||||
val countryIds: List<String>,
|
||||
val heading: String,
|
||||
val text: String,
|
||||
val sourceIds: List<String>,
|
||||
val asOf: String?,
|
||||
val review: SourceReview,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceCuratedQuestion(
|
||||
val id: String,
|
||||
val topic: String,
|
||||
val difficulty: String,
|
||||
val format: String,
|
||||
val prompt: String,
|
||||
val answer: String,
|
||||
val choices: List<String>,
|
||||
val countryIds: List<String>,
|
||||
val supportedHints: List<String>,
|
||||
val explanation: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceSimilarityGroup(
|
||||
val id: String,
|
||||
val countryIds: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceAsset(
|
||||
val id: String,
|
||||
val sourceId: String,
|
||||
val license: String,
|
||||
val sha256: String,
|
||||
val modified: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SourceReview(val state: String)
|
||||
@@ -0,0 +1,701 @@
|
||||
package app.bertsatlas.data
|
||||
|
||||
import androidx.room.ConstructedBy
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.RoomDatabaseConstructor
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import app.bertsatlas.domain.ActiveRunRepository
|
||||
import app.bertsatlas.domain.ProgressReader
|
||||
import app.bertsatlas.domain.ProgressWriter
|
||||
import app.bertsatlas.domain.model.ActiveRunSnapshot
|
||||
import app.bertsatlas.domain.model.Attempt
|
||||
import app.bertsatlas.domain.model.Mastery
|
||||
import app.bertsatlas.domain.model.MasteryLevel
|
||||
import app.bertsatlas.domain.model.MasteryOutcome
|
||||
import app.bertsatlas.domain.model.PassportStamp
|
||||
import app.bertsatlas.domain.model.PassportStampKind
|
||||
import app.bertsatlas.domain.model.ProgressSummary
|
||||
import app.bertsatlas.domain.model.QuizDuration
|
||||
import app.bertsatlas.domain.model.QuizTopic
|
||||
import app.bertsatlas.domain.model.RunResult
|
||||
import app.bertsatlas.domain.model.Skill
|
||||
import app.bertsatlas.domain.model.Streak
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.SetSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Entity(tableName = "attempts")
|
||||
data class AttemptEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val runId: String,
|
||||
val questionId: String,
|
||||
val countryIdsJson: String,
|
||||
val skill: String,
|
||||
val correct: Boolean,
|
||||
val hinted: Boolean,
|
||||
val overtime: Boolean,
|
||||
val submittedAnswer: String,
|
||||
val correctAnswer: String,
|
||||
val responseMillis: Long,
|
||||
val pointsAwarded: Int,
|
||||
val answeredAtEpochMillis: Long,
|
||||
val localEpochDay: Int,
|
||||
val hintType: String?,
|
||||
val hintPointsDeducted: Int,
|
||||
val questionWindowMillis: Long,
|
||||
val questionRemainingMillis: Long,
|
||||
) {
|
||||
@Ignore
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
constructor(
|
||||
id: String,
|
||||
runId: String,
|
||||
questionOrdinal: Int,
|
||||
questionId: String,
|
||||
countryId: String,
|
||||
skill: String,
|
||||
answeredAtEpochMillis: Long,
|
||||
localDate: String,
|
||||
timeZoneId: String,
|
||||
answer: String,
|
||||
correctAnswer: String,
|
||||
correct: Boolean,
|
||||
overtime: Boolean,
|
||||
hintType: String?,
|
||||
hintDebit: Int,
|
||||
responseMillis: Long,
|
||||
responseWindowMillis: Long,
|
||||
awardedPoints: Int,
|
||||
scoreAfterAnswer: Int,
|
||||
) : this(
|
||||
id = id,
|
||||
runId = runId,
|
||||
questionId = questionId,
|
||||
countryIdsJson = Json.encodeToString(SetSerializer(String.serializer()), setOf(countryId)),
|
||||
skill = skill,
|
||||
correct = correct,
|
||||
hinted = hintType != null,
|
||||
overtime = overtime,
|
||||
submittedAnswer = answer,
|
||||
correctAnswer = correctAnswer,
|
||||
responseMillis = responseMillis,
|
||||
pointsAwarded = awardedPoints,
|
||||
answeredAtEpochMillis = answeredAtEpochMillis,
|
||||
localEpochDay = localDate.toIntOrNull() ?: 0,
|
||||
hintType = hintType,
|
||||
hintPointsDeducted = hintDebit,
|
||||
questionWindowMillis = responseWindowMillis,
|
||||
questionRemainingMillis = (responseWindowMillis - responseMillis).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
@Entity(tableName = "mastery", primaryKeys = ["countryId", "skill"])
|
||||
data class MasteryEntity(
|
||||
val countryId: String,
|
||||
val skill: String,
|
||||
val level: String,
|
||||
val attempts: Int,
|
||||
val correctAnswers: Int,
|
||||
val unhintedCorrectAnswers: Int,
|
||||
val unhintedCorrectRunIdsJson: String,
|
||||
val unhintedCorrectEpochDaysJson: String,
|
||||
val recentOutcomesJson: String,
|
||||
val nextReviewEpochDay: Int?,
|
||||
) {
|
||||
@Ignore
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
constructor(
|
||||
countryId: String,
|
||||
skill: String,
|
||||
status: String,
|
||||
attempts: Int,
|
||||
unhintedCorrect: Int,
|
||||
distinctCorrectRuns: Int,
|
||||
distinctCorrectDays: Int,
|
||||
nextEligibleDate: String,
|
||||
recentOutcomes: String,
|
||||
) : this(
|
||||
countryId = countryId,
|
||||
skill = skill,
|
||||
level = status,
|
||||
attempts = attempts,
|
||||
correctAnswers = recentOutcomes.decodeMastery()?.correctAnswers ?: unhintedCorrect,
|
||||
unhintedCorrectAnswers = unhintedCorrect,
|
||||
unhintedCorrectRunIdsJson = recentOutcomes.decodeMastery()?.unhintedCorrectRunIds?.encodeStringSet() ?: emptySet<String>().encodeStringSet(),
|
||||
unhintedCorrectEpochDaysJson = recentOutcomes.decodeMastery()?.unhintedCorrectEpochDays?.encodeIntSet() ?: emptySet<Int>().encodeIntSet(),
|
||||
recentOutcomesJson = recentOutcomes.decodeMastery()?.recentOutcomes?.let {
|
||||
Json.encodeToString(ListSerializer(MasteryOutcome.serializer()), it)
|
||||
} ?: "[]",
|
||||
nextReviewEpochDay = nextEligibleDate.toIntOrNull(),
|
||||
)
|
||||
|
||||
@get:Ignore val status: String get() = level
|
||||
@get:Ignore val unhintedCorrect: Int get() = unhintedCorrectAnswers
|
||||
@get:Ignore val distinctCorrectRuns: Int get() = unhintedCorrectRunIdsJson.decodeStringSet().size
|
||||
@get:Ignore val distinctCorrectDays: Int get() = unhintedCorrectEpochDaysJson.decodeIntSet().size
|
||||
@get:Ignore val nextEligibleDate: String get() = nextReviewEpochDay?.toString().orEmpty()
|
||||
@get:Ignore val recentOutcomes: String
|
||||
get() = toDomain(Json)?.let { Json.encodeToString(Mastery.serializer(), it) }.orEmpty()
|
||||
}
|
||||
|
||||
@Entity(tableName = "runs")
|
||||
data class RunEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val duration: String?,
|
||||
val topic: String?,
|
||||
val status: String,
|
||||
val score: Int,
|
||||
val correctAnswers: Int,
|
||||
val answeredQuestions: Int,
|
||||
val averageResponseMillis: Long,
|
||||
val bestCorrectStreak: Int,
|
||||
val improvedMasteryItems: Int,
|
||||
val revisitCountryIdsJson: String,
|
||||
val completedAtEpochMillis: Long?,
|
||||
) {
|
||||
@Ignore
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
constructor(
|
||||
id: String,
|
||||
duration: String,
|
||||
topic: String,
|
||||
status: String,
|
||||
score: Int,
|
||||
correctAnswers: Int,
|
||||
answeredQuestions: Int,
|
||||
totalResponseMillis: Long,
|
||||
bestCorrectStreak: Int,
|
||||
completedAtEpochMillis: Long?,
|
||||
completedLocalDate: String?,
|
||||
completedTimeZoneId: String?,
|
||||
) : this(
|
||||
id = id,
|
||||
duration = duration,
|
||||
topic = topic,
|
||||
status = status,
|
||||
score = score,
|
||||
correctAnswers = correctAnswers,
|
||||
answeredQuestions = answeredQuestions,
|
||||
averageResponseMillis = if (answeredQuestions == 0) 0 else totalResponseMillis / answeredQuestions,
|
||||
bestCorrectStreak = bestCorrectStreak,
|
||||
improvedMasteryItems = 0,
|
||||
revisitCountryIdsJson = "[]",
|
||||
completedAtEpochMillis = completedAtEpochMillis,
|
||||
)
|
||||
|
||||
@get:Ignore val totalResponseMillis: Long get() = averageResponseMillis * answeredQuestions
|
||||
}
|
||||
|
||||
@Entity(tableName = "personal_bests", primaryKeys = ["duration", "topic"])
|
||||
data class PersonalBestEntity(
|
||||
val duration: String,
|
||||
val topic: String,
|
||||
val score: Int,
|
||||
val runId: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "streak")
|
||||
data class StreakEntity(
|
||||
@PrimaryKey val singletonId: Int = 1,
|
||||
val currentDays: Int,
|
||||
val bestDays: Int,
|
||||
val lastCompletedEpochDay: Int?,
|
||||
) {
|
||||
@Ignore
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
constructor(
|
||||
singletonId: Int = 1,
|
||||
currentDays: Int,
|
||||
bestDays: Int,
|
||||
lastCompletionDate: String?,
|
||||
lastCompletionTimeZoneId: String?,
|
||||
) : this(singletonId, currentDays, bestDays, lastCompletionDate?.toIntOrNull())
|
||||
|
||||
@get:Ignore val lastCompletionDate: String? get() = lastCompletedEpochDay?.toString()
|
||||
}
|
||||
|
||||
@Entity(tableName = "stamps")
|
||||
data class StampEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val earnedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "active_run")
|
||||
data class ActiveRunSnapshotEntity(
|
||||
@PrimaryKey val singletonId: Int = 1,
|
||||
val runId: String,
|
||||
val schemaVersion: Int,
|
||||
val catalogVersion: String,
|
||||
val snapshotJson: String,
|
||||
) {
|
||||
@Ignore
|
||||
constructor(
|
||||
singletonId: Int = 1,
|
||||
schemaVersion: Int,
|
||||
catalogVersion: String,
|
||||
snapshotJson: String,
|
||||
) : this(
|
||||
singletonId = singletonId,
|
||||
runId = snapshotJson.decodeActiveRunId(),
|
||||
schemaVersion = schemaVersion,
|
||||
catalogVersion = catalogVersion,
|
||||
snapshotJson = snapshotJson,
|
||||
)
|
||||
}
|
||||
|
||||
@Dao
|
||||
abstract class RunWriteDao {
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
protected abstract suspend fun insertAttempt(attempt: AttemptEntity)
|
||||
|
||||
@Upsert
|
||||
protected abstract suspend fun upsertMastery(mastery: List<MasteryEntity>)
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun saveActiveRun(snapshot: ActiveRunSnapshotEntity)
|
||||
|
||||
@Query("DELETE FROM active_run WHERE runId = :runId")
|
||||
abstract suspend fun clearActiveRun(runId: String)
|
||||
|
||||
@Query("DELETE FROM active_run")
|
||||
abstract suspend fun clearActiveRun()
|
||||
|
||||
@Query("DELETE FROM attempts")
|
||||
protected abstract suspend fun clearAttempts()
|
||||
|
||||
@Query("DELETE FROM mastery")
|
||||
protected abstract suspend fun clearMastery()
|
||||
|
||||
@Query("DELETE FROM runs")
|
||||
protected abstract suspend fun clearRuns()
|
||||
|
||||
@Query("DELETE FROM personal_bests")
|
||||
protected abstract suspend fun clearPersonalBests()
|
||||
|
||||
@Query("DELETE FROM streak")
|
||||
protected abstract suspend fun clearStreak()
|
||||
|
||||
@Query("DELETE FROM stamps")
|
||||
protected abstract suspend fun clearStamps()
|
||||
|
||||
@Upsert
|
||||
protected abstract suspend fun upsertRun(run: RunEntity)
|
||||
|
||||
@Query("SELECT * FROM personal_bests WHERE duration = :duration AND topic = :topic")
|
||||
protected abstract suspend fun personalBest(duration: String, topic: String): PersonalBestEntity?
|
||||
|
||||
@Upsert
|
||||
protected abstract suspend fun upsertPersonalBest(personalBest: PersonalBestEntity)
|
||||
|
||||
@Upsert
|
||||
protected abstract suspend fun upsertStreak(streak: StreakEntity)
|
||||
|
||||
@Upsert
|
||||
protected abstract suspend fun upsertStamps(stamps: List<StampEntity>)
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE runId = :runId ORDER BY answeredAtEpochMillis")
|
||||
protected abstract suspend fun attemptsForRun(runId: String): List<AttemptEntity>
|
||||
|
||||
@Transaction
|
||||
open suspend fun recordAcceptedAnswer(
|
||||
attempt: AttemptEntity,
|
||||
updatedMastery: List<MasteryEntity>,
|
||||
snapshot: ActiveRunSnapshotEntity,
|
||||
) {
|
||||
require(updatedMastery.isNotEmpty()) { "An accepted answer must update at least one mastery item" }
|
||||
val countryIds = updatedMastery.mapTo(linkedSetOf()) { it.countryId }
|
||||
require(countryIds.size == updatedMastery.size) { "An answer cannot update the same mastery item twice" }
|
||||
require(updatedMastery.all { it.skill == attempt.skill }) {
|
||||
"Mastery updates must use the attempt's skill"
|
||||
}
|
||||
require(snapshot.runId == attempt.runId) { "Attempt and snapshot must belong to the same run" }
|
||||
insertAttempt(attempt.copy(countryIdsJson = countryIds.encodeStringSet()))
|
||||
upsertMastery(updatedMastery)
|
||||
saveActiveRun(snapshot)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun completeRun(
|
||||
run: RunEntity,
|
||||
streak: StreakEntity,
|
||||
stamps: List<StampEntity>,
|
||||
) {
|
||||
require(run.status == "Completed")
|
||||
val duration = requireNotNull(run.duration)
|
||||
val topic = requireNotNull(run.topic)
|
||||
upsertRun(run.withDerivedHistory())
|
||||
val existingBest = personalBest(duration, topic)
|
||||
if (existingBest == null || run.score > existingBest.score) {
|
||||
upsertPersonalBest(PersonalBestEntity(duration, topic, run.score, run.id))
|
||||
}
|
||||
upsertStreak(streak)
|
||||
if (stamps.isNotEmpty()) upsertStamps(stamps)
|
||||
clearActiveRun(run.id)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun completeRun(
|
||||
run: RunEntity,
|
||||
personalBest: PersonalBestEntity?,
|
||||
streak: StreakEntity,
|
||||
stamps: List<StampEntity>,
|
||||
) {
|
||||
require(run.status == "Completed")
|
||||
upsertRun(run.withDerivedHistory())
|
||||
personalBest?.let {
|
||||
val existingBest = personalBest(it.duration, it.topic)
|
||||
if (existingBest == null || it.score > existingBest.score) upsertPersonalBest(it)
|
||||
}
|
||||
upsertStreak(streak)
|
||||
if (stamps.isNotEmpty()) upsertStamps(stamps)
|
||||
clearActiveRun(run.id)
|
||||
}
|
||||
|
||||
private suspend fun RunEntity.withDerivedHistory(): RunEntity {
|
||||
val attempts = attemptsForRun(id)
|
||||
if (attempts.isEmpty()) return this
|
||||
val revisitCountryIds = attempts
|
||||
.filter { !it.correct || it.hinted }
|
||||
.flatMap { it.countryIdsJson.decodeStringSet() }
|
||||
.distinct()
|
||||
val touchedMasteryItems = attempts
|
||||
.flatMap { attempt -> attempt.countryIdsJson.decodeStringSet().map { it to attempt.skill } }
|
||||
.distinct()
|
||||
.size
|
||||
return copy(
|
||||
improvedMasteryItems = maxOf(improvedMasteryItems, touchedMasteryItems),
|
||||
revisitCountryIdsJson = Json.encodeToString(ListSerializer(String.serializer()), revisitCountryIds),
|
||||
)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun abandonRun(runId: String) {
|
||||
upsertRun(
|
||||
RunEntity(
|
||||
id = runId,
|
||||
duration = null,
|
||||
topic = null,
|
||||
status = "Abandoned",
|
||||
score = 0,
|
||||
correctAnswers = 0,
|
||||
answeredQuestions = 0,
|
||||
averageResponseMillis = 0,
|
||||
bestCorrectStreak = 0,
|
||||
improvedMasteryItems = 0,
|
||||
revisitCountryIdsJson = "[]",
|
||||
completedAtEpochMillis = null,
|
||||
),
|
||||
)
|
||||
clearActiveRun(runId)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun abandonRun(run: RunEntity) {
|
||||
upsertRun(run.copy(status = "Abandoned", completedAtEpochMillis = null))
|
||||
clearActiveRun(run.id)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun resetProgress() {
|
||||
clearAttempts()
|
||||
clearMastery()
|
||||
clearRuns()
|
||||
clearPersonalBests()
|
||||
clearStreak()
|
||||
clearStamps()
|
||||
clearActiveRun()
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ProgressReadDao {
|
||||
@Query("SELECT * FROM active_run WHERE singletonId = 1")
|
||||
suspend fun activeRun(): ActiveRunSnapshotEntity?
|
||||
|
||||
@Query("SELECT * FROM mastery ORDER BY countryId, skill")
|
||||
fun mastery(): Flow<List<MasteryEntity>>
|
||||
|
||||
@Query("SELECT * FROM mastery ORDER BY countryId, skill")
|
||||
suspend fun masteryNow(): List<MasteryEntity>
|
||||
|
||||
@Query("SELECT * FROM mastery WHERE countryId = :countryId AND skill = :skill")
|
||||
suspend fun mastery(countryId: String, skill: String): MasteryEntity?
|
||||
|
||||
@Query("SELECT * FROM runs WHERE status = 'Completed' ORDER BY completedAtEpochMillis DESC")
|
||||
fun completedRuns(): Flow<List<RunEntity>>
|
||||
|
||||
@Query("SELECT * FROM runs WHERE status = 'Completed' ORDER BY completedAtEpochMillis DESC")
|
||||
suspend fun completedRunsNow(): List<RunEntity>
|
||||
|
||||
@Query("SELECT * FROM attempts ORDER BY answeredAtEpochMillis DESC")
|
||||
fun attempts(): Flow<List<AttemptEntity>>
|
||||
|
||||
@Query("SELECT * FROM attempts ORDER BY answeredAtEpochMillis DESC")
|
||||
suspend fun attemptsNow(): List<AttemptEntity>
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE runId = :runId ORDER BY answeredAtEpochMillis")
|
||||
suspend fun attemptsForRun(runId: String): List<AttemptEntity>
|
||||
|
||||
@Query("SELECT * FROM personal_bests")
|
||||
fun personalBests(): Flow<List<PersonalBestEntity>>
|
||||
|
||||
@Query("SELECT * FROM personal_bests")
|
||||
suspend fun personalBestsNow(): List<PersonalBestEntity>
|
||||
|
||||
@Query("SELECT * FROM streak WHERE singletonId = 1")
|
||||
fun streak(): Flow<StreakEntity?>
|
||||
|
||||
@Query("SELECT * FROM streak WHERE singletonId = 1")
|
||||
suspend fun streakNow(): StreakEntity?
|
||||
|
||||
@Query("SELECT * FROM stamps ORDER BY earnedAtEpochMillis")
|
||||
fun stamps(): Flow<List<StampEntity>>
|
||||
|
||||
@Query("SELECT * FROM stamps ORDER BY earnedAtEpochMillis")
|
||||
suspend fun stampsNow(): List<StampEntity>
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
AttemptEntity::class,
|
||||
MasteryEntity::class,
|
||||
RunEntity::class,
|
||||
PersonalBestEntity::class,
|
||||
StreakEntity::class,
|
||||
StampEntity::class,
|
||||
ActiveRunSnapshotEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ConstructedBy(BertsAtlasDatabaseConstructor::class)
|
||||
abstract class BertsAtlasDatabase : RoomDatabase() {
|
||||
abstract fun runWriter(): RunWriteDao
|
||||
abstract fun progressReader(): ProgressReadDao
|
||||
}
|
||||
|
||||
class RoomProgressRepository(
|
||||
database: BertsAtlasDatabase,
|
||||
private val json: Json = Json,
|
||||
) : ProgressReader, ProgressWriter, ActiveRunRepository {
|
||||
private val writer = database.runWriter()
|
||||
private val reader = database.progressReader()
|
||||
|
||||
override suspend fun mastery(): List<Mastery> = reader.masteryNow().mapNotNull { it.toDomain(json) }
|
||||
|
||||
override fun observeProgress(): Flow<ProgressSummary> = combine(
|
||||
reader.attempts(),
|
||||
reader.mastery(),
|
||||
reader.streak(),
|
||||
reader.stamps(),
|
||||
) { attempts, mastery, streak, stamps ->
|
||||
ProgressSummary(
|
||||
totalAnswered = attempts.size,
|
||||
totalCorrect = attempts.count { it.correct },
|
||||
mastery = mastery.mapNotNull { it.toDomain(json) },
|
||||
streak = streak?.toDomain() ?: Streak(),
|
||||
stamps = stamps.mapNotNull(StampEntity::toDomain),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun recordAcceptedAnswer(
|
||||
attempt: Attempt,
|
||||
updatedMastery: List<Mastery>,
|
||||
activeRunSnapshot: ActiveRunSnapshot,
|
||||
) {
|
||||
require(attempt.countryIds.isNotEmpty())
|
||||
writer.recordAcceptedAnswer(
|
||||
attempt = attempt.toEntity(json),
|
||||
updatedMastery = updatedMastery
|
||||
.filter { it.countryId in attempt.countryIds && it.skill == attempt.skill }
|
||||
.map { it.toEntity(json) },
|
||||
snapshot = activeRunSnapshot.toEntity(json),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun recordCompletedRun(
|
||||
result: RunResult,
|
||||
streak: Streak,
|
||||
newlyEarnedStamps: List<PassportStamp>,
|
||||
) {
|
||||
writer.completeRun(result.toEntity(json), streak.toEntity(), newlyEarnedStamps.map(PassportStamp::toEntity))
|
||||
}
|
||||
|
||||
override suspend fun recordAbandonedRun(runId: String) = writer.abandonRun(runId)
|
||||
|
||||
override suspend fun resetProgress() = writer.resetProgress()
|
||||
|
||||
override suspend fun loadActiveRun(): ActiveRunSnapshot? {
|
||||
val entity = reader.activeRun() ?: return null
|
||||
return try {
|
||||
json.decodeFromString<ActiveRunSnapshot>(entity.snapshotJson).takeIf {
|
||||
it.schemaVersion == entity.schemaVersion &&
|
||||
it.catalogVersion == entity.catalogVersion &&
|
||||
it.state.runId == entity.runId
|
||||
} ?: error("Active-run snapshot metadata does not match its payload")
|
||||
} catch (_: Exception) {
|
||||
writer.clearActiveRun(entity.runId)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveActiveRun(snapshot: ActiveRunSnapshot) = writer.saveActiveRun(snapshot.toEntity(json))
|
||||
|
||||
override suspend fun clearActiveRun(runId: String) = writer.clearActiveRun(runId)
|
||||
}
|
||||
|
||||
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||
expect object BertsAtlasDatabaseConstructor : RoomDatabaseConstructor<BertsAtlasDatabase> {
|
||||
override fun initialize(): BertsAtlasDatabase
|
||||
}
|
||||
|
||||
fun RoomDatabase.Builder<BertsAtlasDatabase>.buildBertsAtlasDatabase(): BertsAtlasDatabase =
|
||||
addMigrations(Migration1To2)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.Default)
|
||||
.build()
|
||||
|
||||
private object Migration1To2 : Migration(1, 2) {
|
||||
override fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS `attempts_new` (`id` TEXT NOT NULL, `runId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `countryIdsJson` TEXT NOT NULL, `skill` TEXT NOT NULL, `correct` INTEGER NOT NULL, `hinted` INTEGER NOT NULL, `overtime` INTEGER NOT NULL, `submittedAnswer` TEXT NOT NULL, `correctAnswer` TEXT NOT NULL, `responseMillis` INTEGER NOT NULL, `pointsAwarded` INTEGER NOT NULL, `answeredAtEpochMillis` INTEGER NOT NULL, `localEpochDay` INTEGER NOT NULL, `hintType` TEXT, `hintPointsDeducted` INTEGER NOT NULL, `questionWindowMillis` INTEGER NOT NULL, `questionRemainingMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))""",
|
||||
)
|
||||
connection.execSQL(
|
||||
"""INSERT INTO `attempts_new` (`id`, `runId`, `questionId`, `countryIdsJson`, `skill`, `correct`, `hinted`, `overtime`, `submittedAnswer`, `correctAnswer`, `responseMillis`, `pointsAwarded`, `answeredAtEpochMillis`, `localEpochDay`, `hintType`, `hintPointsDeducted`, `questionWindowMillis`, `questionRemainingMillis`) SELECT `id`, `runId`, `questionId`, `countryIdsJson`, `skill`, `correct`, `hinted`, `overtime`, `submittedAnswer`, `correctAnswer`, `responseMillis`, `pointsAwarded`, `answeredAtEpochMillis`, `localEpochDay`, NULL, 0, 0, 0 FROM `attempts`""",
|
||||
)
|
||||
connection.execSQL("DROP TABLE `attempts`")
|
||||
connection.execSQL("ALTER TABLE `attempts_new` RENAME TO `attempts`")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Attempt.toEntity(json: Json) = AttemptEntity(
|
||||
id = id,
|
||||
runId = runId,
|
||||
questionId = questionId,
|
||||
countryIdsJson = json.encodeToString(SetSerializer(String.serializer()), countryIds.toSet()),
|
||||
skill = skill.name,
|
||||
correct = correct,
|
||||
hinted = hinted,
|
||||
overtime = overtime,
|
||||
submittedAnswer = submittedAnswer,
|
||||
correctAnswer = correctAnswer,
|
||||
responseMillis = responseMillis,
|
||||
pointsAwarded = pointsAwarded,
|
||||
answeredAtEpochMillis = answeredAtEpochMillis,
|
||||
localEpochDay = localEpochDay,
|
||||
hintType = hintType?.name,
|
||||
hintPointsDeducted = hintPointsDeducted,
|
||||
questionWindowMillis = questionWindowMillis,
|
||||
questionRemainingMillis = questionRemainingMillis,
|
||||
)
|
||||
|
||||
private fun Mastery.toEntity(json: Json) = MasteryEntity(
|
||||
countryId = countryId,
|
||||
skill = skill.name,
|
||||
level = level.name,
|
||||
attempts = attempts,
|
||||
correctAnswers = correctAnswers,
|
||||
unhintedCorrectAnswers = unhintedCorrectAnswers,
|
||||
unhintedCorrectRunIdsJson = json.encodeToString(SetSerializer(String.serializer()), unhintedCorrectRunIds),
|
||||
unhintedCorrectEpochDaysJson = json.encodeToString(SetSerializer(Int.serializer()), unhintedCorrectEpochDays),
|
||||
recentOutcomesJson = json.encodeToString(ListSerializer(MasteryOutcome.serializer()), recentOutcomes),
|
||||
nextReviewEpochDay = nextReviewEpochDay,
|
||||
)
|
||||
|
||||
private fun MasteryEntity.toDomain(json: Json): Mastery? {
|
||||
val parsedSkill = Skill.entries.firstOrNull { it.name == skill } ?: return null
|
||||
val parsedLevel = MasteryLevel.entries.firstOrNull { it.name == level } ?: return null
|
||||
return runCatching {
|
||||
Mastery(
|
||||
countryId = countryId,
|
||||
skill = parsedSkill,
|
||||
level = parsedLevel,
|
||||
attempts = attempts,
|
||||
correctAnswers = correctAnswers,
|
||||
unhintedCorrectAnswers = unhintedCorrectAnswers,
|
||||
unhintedCorrectRunIds = json.decodeFromString(SetSerializer(String.serializer()), unhintedCorrectRunIdsJson),
|
||||
unhintedCorrectEpochDays = json.decodeFromString(SetSerializer(Int.serializer()), unhintedCorrectEpochDaysJson),
|
||||
recentOutcomes = json.decodeFromString(ListSerializer(MasteryOutcome.serializer()), recentOutcomesJson),
|
||||
nextReviewEpochDay = nextReviewEpochDay,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun ActiveRunSnapshot.toEntity(json: Json) = ActiveRunSnapshotEntity(
|
||||
runId = state.runId,
|
||||
schemaVersion = schemaVersion,
|
||||
catalogVersion = catalogVersion,
|
||||
snapshotJson = json.encodeToString(this),
|
||||
)
|
||||
|
||||
private fun RunResult.toEntity(json: Json) = RunEntity(
|
||||
id = runId,
|
||||
duration = configuration.duration.name,
|
||||
topic = configuration.topic.name,
|
||||
status = "Completed",
|
||||
score = score,
|
||||
correctAnswers = correctAnswers,
|
||||
answeredQuestions = answeredQuestions,
|
||||
averageResponseMillis = averageResponseMillis,
|
||||
bestCorrectStreak = bestCorrectStreak,
|
||||
improvedMasteryItems = improvedMasteryItems,
|
||||
revisitCountryIdsJson = json.encodeToString(ListSerializer(String.serializer()), revisitCountryIds),
|
||||
completedAtEpochMillis = completedAtEpochMillis,
|
||||
)
|
||||
|
||||
private fun Streak.toEntity() = StreakEntity(
|
||||
currentDays = currentDays,
|
||||
bestDays = bestDays,
|
||||
lastCompletedEpochDay = lastCompletedEpochDay,
|
||||
)
|
||||
|
||||
private fun StreakEntity.toDomain() = Streak(
|
||||
currentDays = currentDays,
|
||||
bestDays = bestDays,
|
||||
lastCompletedEpochDay = lastCompletedEpochDay,
|
||||
)
|
||||
|
||||
private fun PassportStamp.toEntity() = StampEntity(kind.name, earnedAtEpochMillis)
|
||||
|
||||
private fun StampEntity.toDomain(): PassportStamp? =
|
||||
PassportStampKind.entries.firstOrNull { it.name == id }?.let { PassportStamp(it, earnedAtEpochMillis) }
|
||||
|
||||
private fun String.decodeStringSet(): Set<String> = runCatching {
|
||||
Json.decodeFromString(SetSerializer(String.serializer()), this)
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
private fun Set<String>.encodeStringSet(): String =
|
||||
Json.encodeToString(SetSerializer(String.serializer()), this)
|
||||
|
||||
private fun String.decodeIntSet(): Set<Int> = runCatching {
|
||||
Json.decodeFromString(SetSerializer(Int.serializer()), this)
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
private fun Set<Int>.encodeIntSet(): String =
|
||||
Json.encodeToString(SetSerializer(Int.serializer()), this)
|
||||
|
||||
private fun String.decodeMastery(): Mastery? = runCatching {
|
||||
Json.decodeFromString<Mastery>(this)
|
||||
}.getOrNull()
|
||||
|
||||
private fun String.decodeActiveRunId(): String = runCatching {
|
||||
Json.decodeFromString<ActiveRunSnapshot>(this).state.runId
|
||||
}.getOrDefault("")
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.bertsatlas.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import app.bertsatlas.domain.LaunchPreferences
|
||||
import app.bertsatlas.domain.PlayerSettings
|
||||
import app.bertsatlas.domain.PlayerSettingsRepository
|
||||
import app.bertsatlas.domain.QuizConfigurationPreferences
|
||||
import app.bertsatlas.domain.model.QuizConfiguration
|
||||
import app.bertsatlas.domain.model.QuizDuration
|
||||
import app.bertsatlas.domain.model.QuizTopic
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
data class SavedPreferences(
|
||||
val hasPreviouslyLaunched: Boolean = false,
|
||||
val duration: String = "FiveMinutes",
|
||||
val topic: String = "MostlyFlags",
|
||||
val soundEnabled: Boolean = true,
|
||||
val hapticsEnabled: Boolean = true,
|
||||
val motionMode: String = "System",
|
||||
val autoAdvanceEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
class PreferencesStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : LaunchPreferences, QuizConfigurationPreferences, PlayerSettingsRepository {
|
||||
val values: Flow<SavedPreferences> = dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is CancellationException) throw exception
|
||||
emit(emptyPreferences())
|
||||
}
|
||||
.map { preferences ->
|
||||
SavedPreferences(
|
||||
hasPreviouslyLaunched = preferences[Keys.previouslyLaunched] ?: false,
|
||||
duration = preferences[Keys.duration] ?: "FiveMinutes",
|
||||
topic = preferences[Keys.topic] ?: "MostlyFlags",
|
||||
soundEnabled = preferences[Keys.soundEnabled] ?: true,
|
||||
hapticsEnabled = preferences[Keys.hapticsEnabled] ?: true,
|
||||
motionMode = preferences[Keys.motionMode] ?: "System",
|
||||
autoAdvanceEnabled = preferences[Keys.autoAdvanceEnabled] ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun hasLaunchedBefore(): Boolean = values.first().hasPreviouslyLaunched
|
||||
|
||||
override suspend fun markLaunched() {
|
||||
dataStore.edit { it[Keys.previouslyLaunched] = true }
|
||||
}
|
||||
|
||||
override suspend fun loadConfiguration(): QuizConfiguration {
|
||||
val preferences = values.first()
|
||||
return QuizConfiguration(
|
||||
duration = QuizDuration.entries.firstOrNull { it.name == preferences.duration } ?: QuizDuration.FiveMinutes,
|
||||
topic = QuizTopic.entries.firstOrNull { it.name == preferences.topic } ?: QuizTopic.MostlyFlags,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun saveConfiguration(duration: QuizDuration, topic: QuizTopic) {
|
||||
saveConfiguration(duration.name, topic.name)
|
||||
}
|
||||
|
||||
suspend fun saveConfiguration(duration: String, topic: String) {
|
||||
require(QuizDuration.entries.any { it.name == duration }) { "Unknown quiz duration: $duration" }
|
||||
require(QuizTopic.entries.any { it.name == topic }) { "Unknown quiz topic: $topic" }
|
||||
dataStore.edit {
|
||||
it[Keys.duration] = duration
|
||||
it[Keys.topic] = topic
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSoundEnabled(enabled: Boolean) {
|
||||
dataStore.edit { it[Keys.soundEnabled] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setHapticsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { it[Keys.hapticsEnabled] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setMotionMode(mode: String) {
|
||||
require(mode == "System" || mode == "Reduced") { "Unknown motion mode: $mode" }
|
||||
dataStore.edit { it[Keys.motionMode] = mode }
|
||||
}
|
||||
|
||||
suspend fun setAutoAdvanceEnabled(enabled: Boolean) {
|
||||
dataStore.edit { it[Keys.autoAdvanceEnabled] = enabled }
|
||||
}
|
||||
|
||||
override fun observeSettings(): Flow<PlayerSettings> = values.map { preferences ->
|
||||
PlayerSettings(
|
||||
soundEnabled = preferences.soundEnabled,
|
||||
hapticsEnabled = preferences.hapticsEnabled,
|
||||
motionEnabled = preferences.motionMode != "Reduced",
|
||||
automaticAdvanceEnabled = preferences.autoAdvanceEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun saveSettings(settings: PlayerSettings) {
|
||||
dataStore.edit {
|
||||
it[Keys.soundEnabled] = settings.soundEnabled
|
||||
it[Keys.hapticsEnabled] = settings.hapticsEnabled
|
||||
it[Keys.motionMode] = if (settings.motionEnabled) "System" else "Reduced"
|
||||
it[Keys.autoAdvanceEnabled] = settings.automaticAdvanceEnabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetFirstLaunch() {
|
||||
dataStore.edit { it.remove(Keys.previouslyLaunched) }
|
||||
}
|
||||
|
||||
suspend fun resetConfiguration() {
|
||||
dataStore.edit {
|
||||
it.remove(Keys.duration)
|
||||
it.remove(Keys.topic)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetSettings() {
|
||||
dataStore.edit {
|
||||
it.remove(Keys.soundEnabled)
|
||||
it.remove(Keys.hapticsEnabled)
|
||||
it.remove(Keys.motionMode)
|
||||
it.remove(Keys.autoAdvanceEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetFirstLaunchAndConfiguration() {
|
||||
dataStore.edit {
|
||||
it.remove(Keys.previouslyLaunched)
|
||||
it.remove(Keys.duration)
|
||||
it.remove(Keys.topic)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetAll() {
|
||||
dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
private object Keys {
|
||||
val previouslyLaunched = booleanPreferencesKey("previously_launched")
|
||||
val duration = stringPreferencesKey("quiz_duration")
|
||||
val topic = stringPreferencesKey("quiz_topic")
|
||||
val soundEnabled = booleanPreferencesKey("sound_enabled")
|
||||
val hapticsEnabled = booleanPreferencesKey("haptics_enabled")
|
||||
val motionMode = stringPreferencesKey("motion_mode")
|
||||
val autoAdvanceEnabled = booleanPreferencesKey("auto_advance_enabled")
|
||||
}
|
||||
}
|
||||
|
||||
fun createPreferencesStore(filePath: String): PreferencesStore {
|
||||
val dataStore = PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { filePath.toPath() },
|
||||
)
|
||||
return PreferencesStore(dataStore)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.min
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ResourceItem
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import berts_atlas.composeapp.generated.resources.Res
|
||||
import io.github.alexzhirkevich.compottie.Compottie
|
||||
import io.github.alexzhirkevich.compottie.LottieCompositionSpec
|
||||
import io.github.alexzhirkevich.compottie.animateLottieCompositionAsState
|
||||
import io.github.alexzhirkevich.compottie.rememberLottieComposition
|
||||
import io.github.alexzhirkevich.compottie.rememberLottiePainter
|
||||
|
||||
private val ScreenShape = RoundedCornerShape(24.dp)
|
||||
private val ControlShape = RoundedCornerShape(16.dp)
|
||||
|
||||
@Composable
|
||||
fun AtlasScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollable: Boolean = true,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(AtlasTheme.colors.parchment),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
val contentModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
.padding(contentPadding)
|
||||
.let { if (scrollable) it.verticalScroll(rememberScrollState()) else it }
|
||||
Box(modifier = contentModifier, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = AtlasTheme.typography.body,
|
||||
color: Color = AtlasTheme.colors.plum,
|
||||
textAlign: TextAlign? = null,
|
||||
) {
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
style = if (textAlign == null) style.copy(color = color) else style.copy(color = color, textAlign = textAlign),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasHeading(text: String, modifier: Modifier = Modifier, centered: Boolean = false) {
|
||||
AtlasText(
|
||||
text = text,
|
||||
modifier = modifier.semantics { heading() },
|
||||
style = AtlasTheme.typography.title,
|
||||
textAlign = if (centered) TextAlign.Center else null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: AtlasButtonStyle = AtlasButtonStyle.Primary,
|
||||
enabled: Boolean = true,
|
||||
contentDescription: String = text,
|
||||
) {
|
||||
val colors = AtlasTheme.colors
|
||||
val background = when (style) {
|
||||
AtlasButtonStyle.Primary -> colors.atlasBlue
|
||||
AtlasButtonStyle.Secondary -> colors.paper
|
||||
AtlasButtonStyle.Quiet -> Color.Transparent
|
||||
AtlasButtonStyle.Destructive -> colors.error
|
||||
}
|
||||
val foreground = when (style) {
|
||||
AtlasButtonStyle.Primary, AtlasButtonStyle.Destructive -> colors.white
|
||||
AtlasButtonStyle.Secondary, AtlasButtonStyle.Quiet -> colors.plum
|
||||
}
|
||||
val border = if (style == AtlasButtonStyle.Secondary) Modifier.border(2.dp, colors.atlasBlue, ControlShape) else Modifier
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = 52.dp)
|
||||
.then(border)
|
||||
.background(if (enabled) background else colors.outline, ControlShape)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
this.contentDescription = contentDescription
|
||||
if (!enabled) disabled()
|
||||
}
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 22.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(text, style = AtlasTheme.typography.label, color = foreground, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasCard(
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(AtlasTheme.colors.paper, ScreenShape)
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .45f), ScreenShape)
|
||||
.then(if (contentDescription == null) Modifier else Modifier.semantics(mergeDescendants = true) { this.contentDescription = contentDescription })
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
content = { content() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasChoice(
|
||||
choice: ChoiceUi,
|
||||
onClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = AtlasTheme.colors
|
||||
val background = when (choice.state) {
|
||||
AnswerState.Available -> colors.paper
|
||||
AnswerState.Selected -> colors.mustard.copy(alpha = .28f)
|
||||
AnswerState.Correct -> colors.success.copy(alpha = .16f)
|
||||
AnswerState.Incorrect -> colors.error.copy(alpha = .14f)
|
||||
AnswerState.Eliminated -> colors.outline.copy(alpha = .12f)
|
||||
}
|
||||
val outline = when (choice.state) {
|
||||
AnswerState.Available -> colors.outline
|
||||
AnswerState.Selected -> colors.mustard
|
||||
AnswerState.Correct -> colors.success
|
||||
AnswerState.Incorrect -> colors.error
|
||||
AnswerState.Eliminated -> colors.outline.copy(alpha = .5f)
|
||||
}
|
||||
val stateText = when (choice.state) {
|
||||
AnswerState.Available -> "Available"
|
||||
AnswerState.Selected -> "Selected"
|
||||
AnswerState.Correct -> "Correct answer"
|
||||
AnswerState.Incorrect -> "Incorrect answer"
|
||||
AnswerState.Eliminated -> "Eliminated"
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.background(background, ControlShape)
|
||||
.border(2.dp, outline, ControlShape)
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Button
|
||||
contentDescription = choice.label
|
||||
stateDescription = stateText
|
||||
if (!choice.enabled) disabled()
|
||||
}
|
||||
.clickable(enabled = choice.enabled) { onClick(choice.id) }
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
choice.flag?.let {
|
||||
FlagImage(
|
||||
flag = it,
|
||||
modifier = Modifier.width(82.dp),
|
||||
accessible = false,
|
||||
showFallbackLabel = false,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (choice.flag == null) AtlasText(choice.label, style = AtlasTheme.typography.label)
|
||||
if (choice.flag == null) {
|
||||
choice.supportingText?.let { AtlasText(it, style = AtlasTheme.typography.detail, color = colors.mutedPlum) }
|
||||
}
|
||||
if (choice.state == AnswerState.Correct) AtlasText("✓ Correct", style = AtlasTheme.typography.detail, color = colors.success)
|
||||
if (choice.state == AnswerState.Incorrect) AtlasText("× Your answer", style = AtlasTheme.typography.detail, color = colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalResourceApi::class)
|
||||
@Composable
|
||||
fun FlagImage(
|
||||
flag: FlagUi,
|
||||
modifier: Modifier = Modifier,
|
||||
accessible: Boolean = true,
|
||||
showFallbackLabel: Boolean = true,
|
||||
) {
|
||||
val alpha2 = flag.alpha2.lowercase()
|
||||
val validCode = alpha2.length == 2 && alpha2.all { it in 'a'..'z' }
|
||||
val resource = remember(alpha2) {
|
||||
DrawableResource(
|
||||
id = "flag:$alpha2",
|
||||
items = setOf(
|
||||
ResourceItem(
|
||||
qualifiers = setOf(),
|
||||
path = "composeResources/berts_atlas.composeapp.generated.resources/files/flag-renders/$alpha2.png",
|
||||
offset = -1,
|
||||
size = -1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val imageModifier = modifier
|
||||
.aspectRatio(3f / 2f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(AtlasTheme.colors.paper, RoundedCornerShape(8.dp))
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .55f), RoundedCornerShape(8.dp))
|
||||
.then(if (accessible) Modifier.semantics { contentDescription = "Flag of ${flag.countryName}" } else Modifier)
|
||||
if (validCode) {
|
||||
Image(
|
||||
painter = painterResource(resource),
|
||||
contentDescription = null,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
FlagFallback(flag.countryName, imageModifier, showFallbackLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlagFallback(countryName: String, modifier: Modifier, showLabel: Boolean) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
AtlasText("⚑", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.atlasBlue)
|
||||
if (showLabel) {
|
||||
AtlasText(
|
||||
countryName,
|
||||
Modifier.align(Alignment.BottomCenter).padding(3.dp),
|
||||
style = AtlasTheme.typography.detail,
|
||||
color = AtlasTheme.colors.mutedPlum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasToggle(setting: SettingToggleUi, onToggle: (String, Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 64.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Switch
|
||||
stateDescription = if (setting.checked) "On" else "Off"
|
||||
if (!setting.enabled) disabled()
|
||||
}
|
||||
.clickable(enabled = setting.enabled) { onToggle(setting.id, !setting.checked) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
AtlasText(setting.title, style = AtlasTheme.typography.label)
|
||||
AtlasText(setting.description, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
Box(
|
||||
Modifier.size(width = 54.dp, height = 32.dp)
|
||||
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.outline, CircleShape)
|
||||
.padding(4.dp),
|
||||
contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
) {
|
||||
Box(Modifier.size(24.dp).background(AtlasTheme.colors.white, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasProgress(label: String, progress: Float, detail: String, modifier: Modifier = Modifier) {
|
||||
val value = progress.coerceIn(0f, 1f)
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
AtlasText(label, style = AtlasTheme.typography.label)
|
||||
AtlasText(detail, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(12.dp).background(AtlasTheme.colors.outline.copy(alpha = .24f), CircleShape)
|
||||
.semantics { progressBarRangeInfo = androidx.compose.ui.semantics.ProgressBarRangeInfo(value, 0f..1f) },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(value).height(12.dp).background(AtlasTheme.colors.atlasBlue, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BertIllustration(
|
||||
pose: BertPose,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String = "Bert, the curious atlas brain",
|
||||
reducedMotion: Boolean = AtlasTheme.reducedMotion,
|
||||
) {
|
||||
if (reducedMotion) {
|
||||
StaticBertIllustration(pose, modifier, contentDescription)
|
||||
return
|
||||
}
|
||||
val compositionResult = rememberLottieComposition(pose) {
|
||||
LottieCompositionSpec.JsonString(
|
||||
Res.readBytes("files/${pose.animationFileName}").decodeToString(),
|
||||
)
|
||||
}
|
||||
val composition = compositionResult.value
|
||||
if (composition == null || compositionResult.isFailure) {
|
||||
StaticBertIllustration(pose, modifier, contentDescription)
|
||||
return
|
||||
}
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = Compottie.IterateForever,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier.size(180.dp).semantics { this.contentDescription = contentDescription },
|
||||
) {
|
||||
StaticBertIllustration(pose, Modifier.matchParentSize(), contentDescription = null)
|
||||
Image(
|
||||
painter = rememberLottiePainter(composition = composition, progress = { progress }),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val BertPose.animationFileName: String
|
||||
get() = when (this) {
|
||||
BertPose.Idle -> "bert_idle.json"
|
||||
BertPose.Correct -> "bert_correct.json"
|
||||
BertPose.Incorrect -> "bert_incorrect.json"
|
||||
BertPose.Overtime -> "bert_overtime.json"
|
||||
BertPose.Hint -> "bert_hint.json"
|
||||
BertPose.Fact -> "bert_fact.json"
|
||||
BertPose.Results -> "bert_results.json"
|
||||
BertPose.Streak -> "bert_streak.json"
|
||||
BertPose.Stamp -> "bert_stamp.json"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaticBertIllustration(
|
||||
pose: BertPose,
|
||||
modifier: Modifier,
|
||||
contentDescription: String?,
|
||||
) {
|
||||
val colors = AtlasTheme.colors
|
||||
val accessibleModifier = if (contentDescription == null) Modifier else Modifier.semantics { this.contentDescription = contentDescription }
|
||||
Canvas(modifier = modifier.size(180.dp).then(accessibleModifier)) {
|
||||
val scale = min(size.width, size.height) / 180f
|
||||
fun p(x: Float, y: Float) = Offset(x * scale, y * scale)
|
||||
val brain = Path().apply {
|
||||
moveTo(38f * scale, 96f * scale)
|
||||
cubicTo(18f * scale, 85f * scale, 25f * scale, 55f * scale, 47f * scale, 52f * scale)
|
||||
cubicTo(48f * scale, 27f * scale, 80f * scale, 20f * scale, 94f * scale, 38f * scale)
|
||||
cubicTo(113f * scale, 23f * scale, 143f * scale, 38f * scale, 139f * scale, 63f * scale)
|
||||
cubicTo(159f * scale, 74f * scale, 151f * scale, 105f * scale, 133f * scale, 110f * scale)
|
||||
cubicTo(127f * scale, 139f * scale, 91f * scale, 146f * scale, 74f * scale, 128f * scale)
|
||||
cubicTo(53f * scale, 139f * scale, 29f * scale, 119f * scale, 38f * scale, 96f * scale)
|
||||
close()
|
||||
}
|
||||
drawPath(brain, colors.coral)
|
||||
drawPath(brain, colors.plum, style = Stroke(width = 4f * scale))
|
||||
listOf(Rect(p(43f, 67f), Size(43f * scale, 34f * scale)), Rect(p(94f, 67f), Size(43f * scale, 34f * scale))).forEach {
|
||||
drawRoundRect(
|
||||
color = colors.paper,
|
||||
topLeft = it.topLeft,
|
||||
size = it.size,
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(10f * scale),
|
||||
)
|
||||
drawRoundRect(
|
||||
color = colors.plum,
|
||||
topLeft = it.topLeft,
|
||||
size = it.size,
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(10f * scale),
|
||||
style = Stroke(5f * scale),
|
||||
)
|
||||
}
|
||||
drawLine(colors.plum, p(86f, 82f), p(94f, 82f), 5f * scale, StrokeCap.Round)
|
||||
drawCircle(colors.plum, 4f * scale, p(65f, 84f))
|
||||
drawCircle(colors.plum, 4f * scale, p(115f, 84f))
|
||||
val mouthY = if (pose == BertPose.Incorrect) 121f else 113f
|
||||
val mouth = Path().apply {
|
||||
moveTo(72f * scale, mouthY * scale)
|
||||
quadraticTo(90f * scale, (if (pose == BertPose.Incorrect) 106f else 130f) * scale, 109f * scale, mouthY * scale)
|
||||
}
|
||||
drawPath(mouth, colors.plum, style = Stroke(4f * scale, cap = StrokeCap.Round))
|
||||
val accent = when (pose) {
|
||||
BertPose.Correct, BertPose.Streak, BertPose.Stamp -> colors.success
|
||||
BertPose.Incorrect -> colors.error
|
||||
BertPose.Overtime, BertPose.Hint -> colors.mustard
|
||||
BertPose.Fact, BertPose.Results, BertPose.Idle -> colors.atlasBlue
|
||||
}
|
||||
drawCircle(accent, 10f * scale, p(145f, 39f))
|
||||
drawLine(accent, p(145f, 17f), p(145f, 7f), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(161f, 25f), p(169f, 18f), 4f * scale, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenTopBar(title: String, onBack: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (onBack != null) AtlasButton("Back", onBack, style = AtlasButtonStyle.Quiet)
|
||||
AtlasText(title, Modifier.weight(1f).semantics { heading() }, style = AtlasTheme.typography.heading)
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatRow(stats: List<StatUi>, modifier: Modifier = Modifier) {
|
||||
BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (maxWidth < 440.dp && stats.size > 2) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
stats.forEach { stat -> StatCell(stat, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
stats.forEach { stat -> StatCell(stat, Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatCell(stat: StatUi, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.background(AtlasTheme.colors.paper, ControlShape).padding(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AtlasText(stat.value, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center)
|
||||
AtlasText(stat.label, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionGap() = Spacer(Modifier.height(18.dp))
|
||||
@@ -0,0 +1,101 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import berts_atlas.composeapp.generated.resources.Res
|
||||
import berts_atlas.composeapp.generated.resources.atkinson_hyperlegible_next_bold
|
||||
import berts_atlas.composeapp.generated.resources.atkinson_hyperlegible_next_regular
|
||||
import berts_atlas.composeapp.generated.resources.atkinson_hyperlegible_next_semibold
|
||||
import org.jetbrains.compose.resources.Font
|
||||
|
||||
@Immutable
|
||||
data class AtlasColors(
|
||||
val parchment: Color,
|
||||
val paper: Color,
|
||||
val plum: Color,
|
||||
val mutedPlum: Color,
|
||||
val atlasBlue: Color,
|
||||
val atlasBluePressed: Color,
|
||||
val coral: Color,
|
||||
val mustard: Color,
|
||||
val success: Color,
|
||||
val error: Color,
|
||||
val outline: Color,
|
||||
val white: Color,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class AtlasTypography(
|
||||
val display: TextStyle,
|
||||
val title: TextStyle,
|
||||
val heading: TextStyle,
|
||||
val body: TextStyle,
|
||||
val label: TextStyle,
|
||||
val detail: TextStyle,
|
||||
val timer: TextStyle,
|
||||
)
|
||||
|
||||
private val LightAtlasColors = AtlasColors(
|
||||
parchment = Color(0xFFF7F0DF),
|
||||
paper = Color(0xFFFFFBF2),
|
||||
plum = Color(0xFF3E2447),
|
||||
mutedPlum = Color(0xFF69536F),
|
||||
atlasBlue = Color(0xFF17647E),
|
||||
atlasBluePressed = Color(0xFF0E4B60),
|
||||
coral = Color(0xFFE96E5B),
|
||||
mustard = Color(0xFFE3AD35),
|
||||
success = Color(0xFF147568),
|
||||
error = Color(0xFF9C3E32),
|
||||
outline = Color(0xFF8A748E),
|
||||
white = Color(0xFFFFFFFF),
|
||||
)
|
||||
|
||||
private fun atlasTypography(fontFamily: FontFamily) = AtlasTypography(
|
||||
display = TextStyle(fontFamily = fontFamily, fontSize = 38.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold),
|
||||
title = TextStyle(fontFamily = fontFamily, fontSize = 30.sp, lineHeight = 37.sp, fontWeight = FontWeight.Bold),
|
||||
heading = TextStyle(fontFamily = fontFamily, fontSize = 22.sp, lineHeight = 29.sp, fontWeight = FontWeight.Bold),
|
||||
body = TextStyle(fontFamily = fontFamily, fontSize = 18.sp, lineHeight = 26.sp),
|
||||
label = TextStyle(fontFamily = fontFamily, fontSize = 17.sp, lineHeight = 23.sp, fontWeight = FontWeight.SemiBold),
|
||||
detail = TextStyle(fontFamily = fontFamily, fontSize = 15.sp, lineHeight = 21.sp),
|
||||
timer = TextStyle(fontFamily = fontFamily, fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
private val LocalAtlasColors = staticCompositionLocalOf { LightAtlasColors }
|
||||
private val LocalAtlasTypography = staticCompositionLocalOf { atlasTypography(FontFamily.Default) }
|
||||
private val LocalReducedMotion = staticCompositionLocalOf { false }
|
||||
|
||||
object AtlasTheme {
|
||||
val colors: AtlasColors
|
||||
@Composable get() = LocalAtlasColors.current
|
||||
|
||||
val typography: AtlasTypography
|
||||
@Composable get() = LocalAtlasTypography.current
|
||||
|
||||
val reducedMotion: Boolean
|
||||
@Composable get() = LocalReducedMotion.current
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BertsAtlasTheme(
|
||||
reducedMotion: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val fontFamily = FontFamily(
|
||||
Font(Res.font.atkinson_hyperlegible_next_regular, FontWeight.Normal),
|
||||
Font(Res.font.atkinson_hyperlegible_next_semibold, FontWeight.SemiBold),
|
||||
Font(Res.font.atkinson_hyperlegible_next_bold, FontWeight.Bold),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalAtlasColors provides LightAtlasColors,
|
||||
LocalAtlasTypography provides atlasTypography(fontFamily),
|
||||
LocalReducedMotion provides reducedMotion,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
isFirstLaunch: Boolean,
|
||||
streakDays: Int = 0,
|
||||
progressSummary: String = "",
|
||||
onStart: () -> Unit,
|
||||
onOptions: () -> Unit = {},
|
||||
onProgress: () -> Unit = {},
|
||||
onSettings: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
BertIllustration(BertPose.Idle, contentDescription = "Bert smiles behind oversized glasses")
|
||||
AtlasText(
|
||||
"Bert's Atlas",
|
||||
Modifier.semantics { heading() },
|
||||
AtlasTheme.typography.display,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AtlasText("Getting nerdier every day.", color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AtlasButton("Start", onStart, Modifier.fillMaxWidth().widthIn(max = 420.dp))
|
||||
if (!isFirstLaunch) {
|
||||
AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary)
|
||||
AtlasCard(Modifier.widthIn(max = 420.dp), "Current streak: $streakDays days. $progressSummary") {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
AtlasText("$streakDays", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
AtlasText(if (streakDays == 1) "day curious" else "days curious", style = AtlasTheme.typography.label)
|
||||
AtlasText(progressSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
AtlasButton("Progress", onProgress, style = AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
AtlasButton("Settings", onSettings, style = AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionsScreen(
|
||||
step: Int,
|
||||
choices: List<ChoiceUi>,
|
||||
onChoose: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isTimeStep = step <= 1
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ScreenTopBar(if (isTimeStep) "Options · 1 of 2" else "Options · 2 of 2", onBack)
|
||||
SectionGap()
|
||||
BertIllustration(if (isTimeStep) BertPose.Idle else BertPose.Fact, Modifier.align(Alignment.CenterHorizontally))
|
||||
AtlasHeading(
|
||||
if (isTimeStep) "How much time have you got?" else "What are we nerding out about?",
|
||||
Modifier.fillMaxWidth(),
|
||||
centered = true,
|
||||
)
|
||||
AtlasText(
|
||||
if (isTimeStep) "The clock only runs while you're answering." else "Difficulty adapts quietly as you play.",
|
||||
Modifier.fillMaxWidth(),
|
||||
color = AtlasTheme.colors.mutedPlum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
choices.forEach { AtlasChoice(it, onChoose) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BootstrapScreen(message: String = "Opening the atlas…", modifier: Modifier = Modifier) {
|
||||
AtlasScreen(modifier, scrollable = false) {
|
||||
Column(Modifier.fillMaxWidth().align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BertIllustration(BertPose.Idle)
|
||||
AtlasText(message, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecoveryScreen(
|
||||
questionSummary: String,
|
||||
score: Int,
|
||||
timeRemaining: String,
|
||||
onResume: () -> Unit,
|
||||
onAbandon: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Hint, contentDescription = "Bert holds your place in the atlas")
|
||||
AtlasHeading("Your place is still marked", Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText("Pick up on the exact question where you left off.", textAlign = TextAlign.Center)
|
||||
AtlasCard(contentDescription = "Saved run. Score $score. Time remaining $timeRemaining. $questionSummary") {
|
||||
StatRow(listOf(StatUi("Score", score.toString()), StatUi("Time left", timeRemaining)))
|
||||
AtlasText(questionSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
AtlasButton("Resume run", onResume, Modifier.fillMaxWidth())
|
||||
AtlasButton("Leave this run", onAbandon, Modifier.fillMaxWidth(), AtlasButtonStyle.Quiet)
|
||||
AtlasText("Answered questions still count toward what Bert revisits next.", style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun QuizScreen(
|
||||
prompt: String,
|
||||
imageDescription: String?,
|
||||
score: Int,
|
||||
sessionTime: String,
|
||||
questionTime: String,
|
||||
timerExpired: Boolean,
|
||||
choices: List<ChoiceUi> = emptyList(),
|
||||
letterSlots: List<LetterSlotUi> = emptyList(),
|
||||
letterTiles: List<LetterTileUi> = emptyList(),
|
||||
hintAvailable: Boolean,
|
||||
unlimited: Boolean,
|
||||
canSubmit: Boolean = false,
|
||||
onChoice: (String) -> Unit = {},
|
||||
onLetter: (String) -> Unit = {},
|
||||
onRemoveLetter: (String) -> Unit = {},
|
||||
onClearLetters: () -> Unit = {},
|
||||
onSubmit: () -> Unit = {},
|
||||
onHint: () -> Unit,
|
||||
onFinish: () -> Unit = {},
|
||||
onLeave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
promptFlag: FlagUi? = null,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth < 480.dp) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Leave", onLeave, style = AtlasButtonStyle.Quiet)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric(if (unlimited) "Elapsed" else "Run", sessionTime, Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Leave", onLeave, style = AtlasButtonStyle.Quiet)
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric(if (unlimited) "Elapsed" else "Run", sessionTime, Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (timerExpired) {
|
||||
AtlasText("Overtime · you can still answer for half points", color = AtlasTheme.colors.error, style = AtlasTheme.typography.label)
|
||||
}
|
||||
if (promptFlag != null || imageDescription != null) {
|
||||
AtlasCard(contentDescription = imageDescription ?: "Flag of ${promptFlag?.countryName}") {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 116.dp), contentAlignment = Alignment.Center) {
|
||||
if (promptFlag != null) {
|
||||
FlagImage(promptFlag, Modifier.fillMaxWidth().heightIn(min = 116.dp))
|
||||
} else {
|
||||
AtlasText("⚑", style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
|
||||
}
|
||||
}
|
||||
imageDescription?.let {
|
||||
AtlasText(it, Modifier.fillMaxWidth(), style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
AtlasHeading(prompt)
|
||||
if (choices.isNotEmpty()) ResponsiveChoices(choices, onChoice)
|
||||
if (letterSlots.isNotEmpty() || letterTiles.isNotEmpty()) {
|
||||
LetterBank(letterSlots, letterTiles, onLetter, onRemoveLetter)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Clear", onClearLetters, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = letterSlots.any { it.character != null && !it.fixed })
|
||||
AtlasButton("Submit", onSubmit, Modifier.weight(1f), enabled = canSubmit)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Hint", onHint, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = hintAvailable)
|
||||
if (unlimited) AtlasButton("Finish", onFinish, Modifier.weight(1f), AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuizMetric(label: String, value: String, modifier: Modifier, warning: Boolean = false) {
|
||||
Column(modifier.semantics(mergeDescendants = true) { contentDescription = "$label: $value" }, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
AtlasText(value, style = AtlasTheme.typography.timer, color = if (warning) AtlasTheme.colors.error else AtlasTheme.colors.plum, textAlign = TextAlign.Center)
|
||||
AtlasText(label, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResponsiveChoices(choices: List<ChoiceUi>, onChoice: (String) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth >= 560.dp) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
choices.chunked(2).forEach { rowChoices ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
rowChoices.forEach { AtlasChoice(it, onChoice, Modifier.weight(1f)) }
|
||||
if (rowChoices.size == 1) Box(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { choices.forEach { AtlasChoice(it, onChoice) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LetterBank(
|
||||
slots: List<LetterSlotUi>,
|
||||
tiles: List<LetterTileUi>,
|
||||
onLetter: (String) -> Unit,
|
||||
onRemoveLetter: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
WrappingCells(slots.size) { index ->
|
||||
val slot = slots[index]
|
||||
val label = slot.character ?: "blank"
|
||||
Box(
|
||||
Modifier.size(52.dp).background(AtlasTheme.colors.paper, RoundedCornerShape(12.dp))
|
||||
.border(2.dp, AtlasTheme.colors.atlasBlue, RoundedCornerShape(12.dp))
|
||||
.semantics { contentDescription = "Answer position ${slot.position + 1}: $label" }
|
||||
.clickable(enabled = slot.tileId != null && !slot.fixed) { slot.tileId?.let(onRemoveLetter) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AtlasText(slot.character ?: "_", style = AtlasTheme.typography.heading) }
|
||||
}
|
||||
WrappingCells(tiles.size) { index ->
|
||||
val tile = tiles[index]
|
||||
val available = tile.enabled && tile.placedSlot == null
|
||||
Box(
|
||||
Modifier.size(52.dp).background(if (available) AtlasTheme.colors.mustard.copy(alpha = .3f) else AtlasTheme.colors.outline.copy(alpha = .12f), RoundedCornerShape(12.dp))
|
||||
.border(1.dp, AtlasTheme.colors.outline, RoundedCornerShape(12.dp))
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
contentDescription = "Letter ${tile.letter}"
|
||||
stateDescription = if (available) "Available" else "Placed"
|
||||
}
|
||||
.clickable(enabled = available) { onLetter(tile.id) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AtlasText(tile.letter, style = AtlasTheme.typography.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WrappingCells(count: Int, content: @Composable (Int) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val columns = (maxWidth / 60.dp).toInt().coerceAtLeast(1)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(0 until count).chunked(columns).forEach { indices ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (index in indices) content(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HintChooserScreen(
|
||||
score: Int,
|
||||
hints: List<ChoiceUi>,
|
||||
onChooseHint: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ScreenTopBar("Choose a hint", onCancel)
|
||||
BertIllustration(BertPose.Hint)
|
||||
AtlasHeading("A little nudge?", Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText("Every hint costs up to 25 points. Your score can never drop below zero.", textAlign = TextAlign.Center)
|
||||
AtlasCard(contentDescription = "Current score $score. Score after hint ${maxOf(0, score - 25)}") {
|
||||
StatRow(listOf(StatUi("Now", score.toString()), StatUi("After hint", maxOf(0, score - 25).toString())))
|
||||
}
|
||||
hints.forEach { AtlasChoice(it, onChooseHint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedbackScreen(
|
||||
correct: Boolean,
|
||||
overtime: Boolean,
|
||||
heading: String,
|
||||
selectedAnswer: String?,
|
||||
correctAnswer: String,
|
||||
pointsAwarded: Int,
|
||||
explanation: String?,
|
||||
autoAdvanceMessage: String?,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pose = when { overtime && correct -> BertPose.Overtime; correct -> BertPose.Correct; else -> BertPose.Incorrect }
|
||||
val status = when { overtime && correct -> "Overtime correct"; correct -> "Correct"; else -> "Incorrect" }
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BertIllustration(pose, contentDescription = "Bert reacts: $status")
|
||||
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText(
|
||||
if (correct) "+$pointsAwarded points" else "No points this time",
|
||||
style = AtlasTheme.typography.heading,
|
||||
color = if (correct) AtlasTheme.colors.success else AtlasTheme.colors.error,
|
||||
)
|
||||
AtlasCard(contentDescription = "$status. Correct answer: $correctAnswer") {
|
||||
if (!correct && selectedAnswer != null) AtlasText("Your answer · $selectedAnswer", color = AtlasTheme.colors.error, style = AtlasTheme.typography.label)
|
||||
AtlasText("Correct answer · $correctAnswer", color = AtlasTheme.colors.success, style = AtlasTheme.typography.label)
|
||||
explanation?.let { AtlasText(it) }
|
||||
}
|
||||
if (overtime && correct) AtlasText("The clock gave up. You didn't. Half points.", textAlign = TextAlign.Center)
|
||||
AtlasButton("Continue", onContinue, Modifier.fillMaxWidth())
|
||||
autoAdvanceMessage?.let { AtlasText(it, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FunFactScreen(
|
||||
heading: String,
|
||||
fact: String,
|
||||
relatedTo: String,
|
||||
artDescription: String? = null,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Fact, contentDescription = "Bert has found an oddly specific fact")
|
||||
AtlasText("FIELD NOTE · $relatedTo", style = AtlasTheme.typography.label, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasCard(contentDescription = artDescription) { AtlasText(fact, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center) }
|
||||
AtlasButton("Back to the atlas", onContinue, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ResultsScreen(
|
||||
score: Int,
|
||||
correctAnswers: Int,
|
||||
answeredQuestions: Int,
|
||||
accuracy: String,
|
||||
averageResponseTime: String,
|
||||
bestCorrectStreak: Int,
|
||||
masteryImproved: Int,
|
||||
revisitCountries: List<String>,
|
||||
newPersonalBest: Boolean,
|
||||
onPlayAgain: () -> Unit,
|
||||
onHome: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Results, contentDescription = "Bert celebrates a completed atlas run")
|
||||
AtlasHeading(if (newPersonalBest) "New personal best" else "Run complete", Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText(score.toString(), style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasText("points", color = AtlasTheme.colors.mutedPlum)
|
||||
StatRow(
|
||||
listOf(
|
||||
StatUi("Correct", "$correctAnswers/$answeredQuestions"),
|
||||
StatUi("Accuracy", accuracy),
|
||||
StatUi("Best streak", bestCorrectStreak.toString()),
|
||||
),
|
||||
)
|
||||
AtlasCard {
|
||||
AtlasText("Run notes", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Average response · $averageResponseTime")
|
||||
AtlasText("New or improved knowledge · $masteryImproved")
|
||||
}
|
||||
if (revisitCountries.isNotEmpty()) {
|
||||
AtlasCard(contentDescription = "Worth another look: ${revisitCountries.joinToString()}") {
|
||||
AtlasText("Worth another look", style = AtlasTheme.typography.heading)
|
||||
AtlasText(revisitCountries.joinToString(" · "), color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
AtlasButton("Play again", onPlayAgain, Modifier.fillMaxWidth())
|
||||
AtlasButton("Home", onHome, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressScreen(
|
||||
currentStreak: Int,
|
||||
bestStreak: Int,
|
||||
overallAccuracy: String,
|
||||
totalAnswered: Int,
|
||||
masteryGroups: List<ProgressGroupUi>,
|
||||
strongCountries: List<String>,
|
||||
revisitCountries: List<String>,
|
||||
stamps: List<StampUi>,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ScreenTopBar("Your atlas", onBack)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
BertIllustration(BertPose.Streak, Modifier.weight(.8f), "Bert celebrates your curiosity streak")
|
||||
Column(Modifier.weight(1.2f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasText("$currentStreak day streak", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.coral)
|
||||
AtlasText("Best · $bestStreak days", color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
StatRow(listOf(StatUi("Accuracy", overallAccuracy), StatUi("Questions", totalAnswered.toString())))
|
||||
AtlasCard {
|
||||
AtlasText("Things you're getting to know", style = AtlasTheme.typography.heading)
|
||||
masteryGroups.forEach { AtlasProgress(it.label, it.progress, it.detail) }
|
||||
}
|
||||
CountryNotes("Strong lately", strongCountries, AtlasTheme.colors.success)
|
||||
CountryNotes("Worth revisiting", revisitCountries, AtlasTheme.colors.mustard)
|
||||
AtlasText("Passport stamps", style = AtlasTheme.typography.heading)
|
||||
stamps.forEach { StampCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountryNotes(title: String, countries: List<String>, accent: androidx.compose.ui.graphics.Color) {
|
||||
if (countries.isEmpty()) return
|
||||
AtlasCard(contentDescription = "$title: ${countries.joinToString()}") {
|
||||
AtlasText(title, style = AtlasTheme.typography.label, color = accent)
|
||||
AtlasText(countries.joinToString(" · "))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StampCard(stamp: StampUi) {
|
||||
val accent = if (stamp.earned) AtlasTheme.colors.atlasBlue else AtlasTheme.colors.outline
|
||||
Row(
|
||||
Modifier.fillMaxWidth().heightIn(min = 76.dp)
|
||||
.background(AtlasTheme.colors.paper, RoundedCornerShape(16.dp))
|
||||
.border(2.dp, accent, RoundedCornerShape(16.dp))
|
||||
.semantics(mergeDescendants = true) { contentDescription = "${if (stamp.earned) "Earned" else "Not yet earned"} stamp: ${stamp.title}. ${stamp.description}" }
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
AtlasText(if (stamp.earned) "★" else "◇", style = AtlasTheme.typography.title, color = accent, textAlign = TextAlign.Center)
|
||||
Column(Modifier.weight(1f)) {
|
||||
AtlasText(stamp.title, style = AtlasTheme.typography.label)
|
||||
AtlasText(stamp.description, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: List<SettingToggleUi>,
|
||||
version: String,
|
||||
debugEntryEnabled: Boolean,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onPrivacy: () -> Unit,
|
||||
onCredits: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onOpenDebug: () -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ScreenTopBar("Settings", onBack)
|
||||
AtlasCard {
|
||||
settings.forEach { AtlasToggle(it, onToggle) }
|
||||
}
|
||||
AtlasButton("Privacy", onPrivacy, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
AtlasButton("Credits and licenses", onCredits, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
AtlasButton("Reset local data", onReset, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
|
||||
AtlasText(
|
||||
"Bert's Atlas $version",
|
||||
Modifier.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Bert's Atlas version $version"
|
||||
if (debugEntryEnabled) role = Role.Button
|
||||
}
|
||||
.combinedClickable(
|
||||
enabled = debugEntryEnabled,
|
||||
onClick = {},
|
||||
onLongClick = onOpenDebug,
|
||||
)
|
||||
.padding(16.dp),
|
||||
style = AtlasTheme.typography.detail,
|
||||
color = AtlasTheme.colors.mutedPlum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetConfirmationScreen(
|
||||
onReset: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ScreenTopBar("Reset local data", onCancel)
|
||||
BertIllustration(BertPose.Incorrect, Modifier.fillMaxWidth())
|
||||
AtlasHeading("Clear this atlas?")
|
||||
AtlasText("This removes your progress, streaks, stamps, settings, and any saved run from this device. It cannot be undone.")
|
||||
AtlasButton("Reset everything", onReset, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
|
||||
AtlasButton("Keep my atlas", onCancel, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
InformationScreen(
|
||||
title = "Privacy",
|
||||
paragraphs = listOf(
|
||||
"Bert's Atlas works entirely offline. It does not ask for an account or personal information.",
|
||||
"Your quiz history, progress, streaks, stamps, and preferences stay on this device. The app has no advertising, analytics, remote configuration, or product telemetry.",
|
||||
"Clearing app data or deleting the app may permanently remove your progress. Operating-system and store diagnostics are controlled by your platform settings.",
|
||||
),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreditsScreen(
|
||||
catalogVersion: String,
|
||||
credits: List<String>,
|
||||
licenses: List<String>,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ScreenTopBar("Credits and licenses", onBack)
|
||||
AtlasText("Bert, the writing, and the interface artwork are original to Bert's Atlas.")
|
||||
AtlasCard {
|
||||
AtlasText("Content", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Catalog version $catalogVersion")
|
||||
credits.forEach { AtlasText(it, style = AtlasTheme.typography.detail) }
|
||||
}
|
||||
AtlasCard {
|
||||
AtlasText("Open-source licenses", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Atkinson Hyperlegible Next · SIL Open Font License 1.1", style = AtlasTheme.typography.detail)
|
||||
licenses.forEach { AtlasText(it, style = AtlasTheme.typography.detail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InformationScreen(title: String, paragraphs: List<String>, onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ScreenTopBar(title, onBack)
|
||||
paragraphs.forEach { AtlasText(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DebugPanelScreen(
|
||||
buildVersion: String,
|
||||
catalogVersion: String,
|
||||
activeSnapshot: String,
|
||||
actions: List<DebugActionUi>,
|
||||
onAction: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ScreenTopBar("Debug QA", onClose)
|
||||
AtlasCard {
|
||||
AtlasText("Build $buildVersion", style = AtlasTheme.typography.label)
|
||||
AtlasText("Catalog $catalogVersion")
|
||||
AtlasText(activeSnapshot, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
actions.forEach { action ->
|
||||
AtlasButton(
|
||||
text = action.title + (action.detail?.let { " · $it" } ?: ""),
|
||||
onClick = { onAction(action.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = if (action.destructive) AtlasButtonStyle.Destructive else AtlasButtonStyle.Secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
enum class BertPose { Idle, Correct, Incorrect, Overtime, Hint, Fact, Results, Streak, Stamp }
|
||||
|
||||
enum class AtlasButtonStyle { Primary, Secondary, Quiet, Destructive }
|
||||
|
||||
enum class AnswerState { Available, Selected, Correct, Incorrect, Eliminated }
|
||||
|
||||
@Immutable
|
||||
data class FlagUi(
|
||||
val alpha2: String,
|
||||
val countryName: String,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ChoiceUi(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val supportingText: String? = null,
|
||||
val state: AnswerState = AnswerState.Available,
|
||||
val enabled: Boolean = true,
|
||||
val flag: FlagUi? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class LetterTileUi(
|
||||
val id: String,
|
||||
val letter: String,
|
||||
val placedSlot: Int? = null,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class LetterSlotUi(
|
||||
val position: Int,
|
||||
val character: String? = null,
|
||||
val tileId: String? = null,
|
||||
val fixed: Boolean = false,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class StatUi(val label: String, val value: String)
|
||||
|
||||
@Immutable
|
||||
data class ProgressGroupUi(
|
||||
val label: String,
|
||||
val progress: Float,
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class StampUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val earned: Boolean,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SettingToggleUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val checked: Boolean,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class DebugActionUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
val destructive: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package app.bertsatlas
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.AudioToolbox.AudioServicesPlaySystemSound
|
||||
import platform.AudioToolbox.kSystemSoundID_Vibrate
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
class IosDeviceFeedback : DeviceFeedback {
|
||||
override fun play(correct: Boolean, soundEnabled: Boolean, hapticsEnabled: Boolean) {
|
||||
if (soundEnabled) AudioServicesPlaySystemSound(if (correct) 1104u else 1053u)
|
||||
if (hapticsEnabled) AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,35 @@
|
||||
package app.bertsatlas
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
import app.bertsatlas.data.buildBertsAtlasDatabase
|
||||
import app.bertsatlas.data.createPreferencesStore
|
||||
import app.bertsatlas.data.getDatabaseBuilder
|
||||
import kotlin.experimental.ExperimentalNativeApi
|
||||
import kotlin.native.Platform
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
fun MainViewController() = ComposeUIViewController { App() }
|
||||
private var activeController: AppController? = null
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class)
|
||||
fun MainViewController(): platform.UIKit.UIViewController {
|
||||
val directory = NSFileManager.defaultManager.URLForDirectory(
|
||||
directory = NSDocumentDirectory,
|
||||
inDomain = NSUserDomainMask,
|
||||
appropriateForURL = null,
|
||||
create = true,
|
||||
error = null,
|
||||
)
|
||||
val preferencesPath = requireNotNull(directory?.path) + "/berts-atlas.preferences_pb"
|
||||
val database = getDatabaseBuilder().buildBertsAtlasDatabase()
|
||||
val preferences = createPreferencesStore(preferencesPath)
|
||||
return ComposeUIViewController {
|
||||
App(database, preferences, debugBuild = Platform.isDebugBinary, deviceFeedback = IosDeviceFeedback()) { activeController = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseApp() = activeController?.pauseForInterruption()
|
||||
|
||||
fun resumeApp() = activeController?.resumeAfterInterruption()
|
||||
@@ -0,0 +1,21 @@
|
||||
package app.bertsatlas.data
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun getDatabaseBuilder(): RoomDatabase.Builder<BertsAtlasDatabase> {
|
||||
val directory = NSFileManager.defaultManager.URLForDirectory(
|
||||
directory = NSDocumentDirectory,
|
||||
inDomain = NSUserDomainMask,
|
||||
appropriateForURL = null,
|
||||
create = true,
|
||||
error = null,
|
||||
)
|
||||
val databasePath = requireNotNull(directory?.path) + "/berts-atlas.db"
|
||||
return Room.databaseBuilder(name = databasePath)
|
||||
}
|
||||
Reference in new issue
Block a user