refine home options and trivia pacing

This commit is contained in:
pj committed 2026-09-10 03:44:59 +05:30
1 parent c6b2eb3cce
commit 598c5f9ef3
30 files changed
+469 -163

No files matched your search

@@ -1,17 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="512" android:viewportHeight="512"> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="512" android:viewportHeight="512">
<group android:scaleX="0.66" android:scaleY="0.66" android:translateX="55" android:translateY="85"> <group android:scaleX="0.78" android:scaleY="0.78" android:translateX="107" android:translateY="128">
<path android:fillColor="#F7BFBE" android:strokeColor="#27635B" android:strokeWidth="17" android:pathData="M105,270C47,237 68,151 135,146C138,74 231,54 272,107C327,62 414,105 402,177C465,207 444,297 387,312C369,397 264,415 214,363C152,395 79,335 105,270Z"/> <path android:fillColor="#F7BFBE" android:strokeColor="#27635B" android:strokeWidth="12" android:pathData="M39.8382,198.948C-18.1618,165.948 2.83815,79.9482 69.8382,74.9482C72.8382,2.9482 165.838,-17.0518 206.838,35.9482C261.838,-9.05179 348.838,33.9482 336.838,105.948C399.838,135.948 378.838,225.948 321.838,240.948C303.838,325.948 198.838,343.948 148.838,291.948C86.8382,323.948 13.8382,263.948 39.8382,198.948Z"/>
<path android:fillColor="#F6E9E1" android:strokeColor="#27635B" android:strokeWidth="19" android:pathData="M154,205H217C232,205 245,217 245,233V269C245,285 232,297 217,297H154C138,297 126,285 126,269V233C126,217 138,205 154,205Z"/> <path android:strokeColor="#E1814E" android:strokeWidth="8" android:strokeLineCap="round" android:pathData="M87.8381,78.9482C68.8381,100.948 67.8381,127.948 80.8381,147.948M138.838,28.9482C123.838,59.9482 128.838,82.9482 145.838,100.948M212.838,34.9482C233.838,56.9482 233.838,83.9482 220.838,107.948M284.838,80.9482C267.838,101.948 266.838,123.948 277.838,144.948M57.8381,188.948C83.8381,196.948 96.8381,216.948 93.8381,242.948M287.838,168.948C260.838,181.948 251.838,203.948 258.838,228.948"/>
<path android:fillColor="#F6E9E1" android:strokeColor="#27635B" android:strokeWidth="19" android:pathData="M296,205H359C375,205 387,217 387,233V269C387,285 375,297 359,297H296C280,297 268,285 268,269V233C268,217 280,205 296,205Z"/> <path android:fillColor="#F6E9E1" android:strokeColor="#27635B" android:strokeWidth="15" android:pathData="M151.838,133.948H88.8381C73.3742,133.948 60.8381,146.484 60.8381,161.948V197.948C60.8381,213.412 73.3742,225.948 88.8381,225.948H151.838C167.302,225.948 179.838,213.412 179.838,197.948V161.948C179.838,146.484 167.302,133.948 151.838,133.948Z"/>
<path android:strokeColor="#27635B" android:strokeWidth="19" android:pathData="M245,245L268,245"/> <path android:fillColor="#F6E9E1" android:strokeColor="#27635B" android:strokeWidth="15" android:pathData="M293.838,133.948H230.838C215.374,133.948 202.838,146.484 202.838,161.948V197.948C202.838,213.412 215.374,225.948 230.838,225.948H293.838C309.302,225.948 321.838,213.412 321.838,197.948V161.948C321.838,146.484 309.302,133.948 293.838,133.948Z"/>
<path android:fillColor="#27635B" android:pathData="M185,250m-14,0a14,14 0,1 0,28 0a14,14 0,1 0,-28 0M328,250m-14,0a14,14 0,1 0,28 0a14,14 0,1 0,-28 0"/> <path android:strokeColor="#27635B" android:strokeWidth="15" android:strokeLineCap="round" android:pathData="M179.838,173.948H202.838"/>
<path android:strokeColor="#27635B" android:strokeWidth="15" android:strokeLineCap="round" android:pathData="M204,325Q256,372 309,325"/> <path android:fillColor="#27635B" android:pathData="M119.838,190.948C126.466,190.948 131.838,185.576 131.838,178.948C131.838,172.321 126.466,166.948 119.838,166.948C113.211,166.948 107.838,172.321 107.838,178.948C107.838,185.576 113.211,190.948 119.838,190.948Z"/>
</group> <path android:fillColor="#27635B" android:pathData="M262.838,190.948C269.466,190.948 274.838,185.576 274.838,178.948C274.838,172.321 269.466,166.948 262.838,166.948C256.211,166.948 250.838,172.321 250.838,178.948C250.838,185.576 256.211,190.948 262.838,190.948Z"/>
<group android:scaleX="0.82" android:scaleY="0.82" android:translateX="64" android:translateY="41"> <path android:strokeColor="#27635B" android:strokeWidth="12" android:strokeLineCap="round" android:pathData="M138.838,253.948C173.505,285.282 208.505,285.282 243.838,253.948"/>
<path android:fillColor="#F1DCC7" android:strokeColor="#27635B" android:strokeWidth="10" android:pathData="M300,105L332,91L360,104L390,91L418,105L418,196L390,182L360,195L332,182L300,196Z"/>
<path android:fillColor="#B7CFC8" android:strokeColor="#27635B" android:strokeWidth="10" android:pathData="M332,91L360,104L360,195L332,182Z"/>
<path android:fillColor="#E9AF8B" android:strokeColor="#27635B" android:strokeWidth="10" android:pathData="M390,91L418,105L418,196L390,182Z"/>
<path android:strokeColor="#E1814E" android:strokeWidth="7" android:strokeLineCap="round" android:pathData="M308,157C328,139 336,164 354,147C370,133 375,118 392,135C402,147 406,153 414,150"/>
</group> </group>
</vector> </vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -1,17 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg width="382" height="328" viewBox="0 0 382 328" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="112" fill="#F6E9E1"/> <path d="M39.8382 198.948C-18.1618 165.948 2.83815 79.9482 69.8382 74.9482C72.8382 2.9482 165.838 -17.0518 206.838 35.9482C261.838 -9.05179 348.838 33.9482 336.838 105.948C399.838 135.948 378.838 225.948 321.838 240.948C303.838 325.948 198.838 343.948 148.838 291.948C86.8382 323.948 13.8382 263.948 39.8382 198.948Z" fill="#F7BFBE" stroke="#27635B" stroke-width="12"/>
<g transform="translate(55 85) scale(.66)"> <path d="M87.8381 78.9482C68.8381 100.948 67.8381 127.948 80.8381 147.948M138.838 28.9482C123.838 59.9482 128.838 82.9482 145.838 100.948M212.838 34.9482C233.838 56.9482 233.838 83.9482 220.838 107.948M284.838 80.9482C267.838 101.948 266.838 123.948 277.838 144.948M57.8381 188.948C83.8381 196.948 96.8381 216.948 93.8381 242.948M287.838 168.948C260.838 181.948 251.838 203.948 258.838 228.948" stroke="#E1814E" stroke-width="8" stroke-linecap="round"/>
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/> <path d="M151.838 133.948H88.8381C73.3742 133.948 60.8381 146.484 60.8381 161.948V197.948C60.8381 213.412 73.3742 225.948 88.8381 225.948H151.838C167.302 225.948 179.838 213.412 179.838 197.948V161.948C179.838 146.484 167.302 133.948 151.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/> <path d="M293.838 133.948H230.838C215.374 133.948 202.838 146.484 202.838 161.948V197.948C202.838 213.412 215.374 225.948 230.838 225.948H293.838C309.302 225.948 321.838 213.412 321.838 197.948V161.948C321.838 146.484 309.302 133.948 293.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/> <path d="M179.838 173.948H202.838Z" fill="black"/>
<path stroke="#27635B" stroke-width="19" d="M245 245h23"/> <path d="M179.838 173.948H202.838" stroke="#27635B" stroke-width="15" stroke-linecap="round"/>
<circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/> <path d="M119.838 190.948C126.466 190.948 131.838 185.576 131.838 178.948C131.838 172.321 126.466 166.948 119.838 166.948C113.211 166.948 107.838 172.321 107.838 178.948C107.838 185.576 113.211 190.948 119.838 190.948Z" fill="#27635B"/>
<path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/> <path d="M262.838 190.948C269.466 190.948 274.838 185.576 274.838 178.948C274.838 172.321 269.466 166.948 262.838 166.948C256.211 166.948 250.838 172.321 250.838 178.948C250.838 185.576 256.211 190.948 262.838 190.948Z" fill="#27635B"/>
</g> <path d="M138.838 253.948C173.505 285.282 208.505 285.282 243.838 253.948" stroke="#27635B" stroke-width="12" stroke-linecap="round"/>
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round">
<path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/>
<path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/>
<path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/>
<path fill="none" d="M332 91v91m28-78v91m30-104v91"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,11 @@
<svg width="382" height="328" viewBox="0 0 382 328" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.8382 198.948C-18.1618 165.948 2.83815 79.9482 69.8382 74.9482C72.8382 2.9482 165.838 -17.0518 206.838 35.9482C261.838 -9.05179 348.838 33.9482 336.838 105.948C399.838 135.948 378.838 225.948 321.838 240.948C303.838 325.948 198.838 343.948 148.838 291.948C86.8382 323.948 13.8382 263.948 39.8382 198.948Z" fill="#F7BFBE" stroke="#27635B" stroke-width="12"/>
<path d="M87.8381 78.9482C68.8381 100.948 67.8381 127.948 80.8381 147.948M138.838 28.9482C123.838 59.9482 128.838 82.9482 145.838 100.948M212.838 34.9482C233.838 56.9482 233.838 83.9482 220.838 107.948M284.838 80.9482C267.838 101.948 266.838 123.948 277.838 144.948M57.8381 188.948C83.8381 196.948 96.8381 216.948 93.8381 242.948M287.838 168.948C260.838 181.948 251.838 203.948 258.838 228.948" stroke="#E1814E" stroke-width="8" stroke-linecap="round"/>
<path d="M151.838 133.948H88.8381C73.3742 133.948 60.8381 146.484 60.8381 161.948V197.948C60.8381 213.412 73.3742 225.948 88.8381 225.948H151.838C167.302 225.948 179.838 213.412 179.838 197.948V161.948C179.838 146.484 167.302 133.948 151.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<path d="M293.838 133.948H230.838C215.374 133.948 202.838 146.484 202.838 161.948V197.948C202.838 213.412 215.374 225.948 230.838 225.948H293.838C309.302 225.948 321.838 213.412 321.838 197.948V161.948C321.838 146.484 309.302 133.948 293.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<path d="M179.838 173.948H202.838Z" fill="black"/>
<path d="M179.838 173.948H202.838" stroke="#27635B" stroke-width="15" stroke-linecap="round"/>
<path d="M119.838 190.948C126.466 190.948 131.838 185.576 131.838 178.948C131.838 172.321 126.466 166.948 119.838 166.948C113.211 166.948 107.838 172.321 107.838 178.948C107.838 185.576 113.211 190.948 119.838 190.948Z" fill="#27635B"/>
<path d="M262.838 190.948C269.466 190.948 274.838 185.576 274.838 178.948C274.838 172.321 269.466 166.948 262.838 166.948C256.211 166.948 250.838 172.321 250.838 178.948C250.838 185.576 256.211 190.948 262.838 190.948Z" fill="#27635B"/>
<path d="M138.838 253.948C173.505 285.282 208.505 285.282 243.838 253.948" stroke="#27635B" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -80,7 +80,7 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
Destination.Home -> HomeScreen( Destination.Home -> HomeScreen(
isFirstLaunch = state.firstLaunch, isFirstLaunch = state.firstLaunch,
streakDays = state.streak.currentDays, streakDays = state.streak.currentDays,
progressSummary = "${state.attempts.size} questions explored", progressSummary = if (state.attempts.size == 1) "1 question explored" else "${state.attempts.size} questions explored",
totalPoints = state.attempts.sumOf { it.pointsAwarded } - state.attempts.sumOf { it.hintPointsDeducted }, totalPoints = state.attempts.sumOf { it.pointsAwarded } - state.attempts.sumOf { it.hintPointsDeducted },
hasStartedTrivia = !state.firstLaunch, hasStartedTrivia = !state.firstLaunch,
onStart = controller::start, onStart = controller::start,
@@ -90,8 +90,15 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
) )
is Destination.Options -> OptionsScreen( is Destination.Options -> OptionsScreen(
step = destination.step, step = destination.step,
choices = if (destination.step == 1) durationChoices(state.selectedDuration) else topicChoices(), choices = when (destination.step) {
1 -> durationChoices(state.selectedDuration)
2 -> topicChoices(state.selectedTopic)
else -> emptyList()
},
durationSummary = state.selectedDuration.displayName(),
topicSummary = state.selectedTopic.displayName(),
onChoose = controller::chooseOption, onChoose = controller::chooseOption,
onStart = controller::startSavedOptions,
onBack = controller::back, onBack = controller::back,
) )
Destination.Recovery -> if (run == null) BootstrapScreen() else RecoveryScreen( Destination.Recovery -> if (run == null) BootstrapScreen() else RecoveryScreen(
@@ -262,15 +269,28 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
} }
private fun durationChoices(selected: QuizDuration) = QuizDuration.entries.map { duration -> 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) ChoiceUi(duration.name, duration.displayName(), state = if (duration == selected) AnswerState.Selected else AnswerState.Available)
} }
private fun topicChoices() = listOf( private fun topicChoices(selected: app.bertsatlas.domain.model.QuizTopic) = listOf(
ChoiceUi("MostlyFlags", "Mostly flags", "Recognize flags and spell country names"), ChoiceUi("MostlyFlags", "Mostly flags", "Recognize flags and spell country names", state = if (selected == app.bertsatlas.domain.model.QuizTopic.MostlyFlags) AnswerState.Selected else AnswerState.Available),
ChoiceUi("Capitals", "Capitals", "Match countries and capitals"), ChoiceUi("Capitals", "Capitals", "Match countries and capitals", state = if (selected == app.bertsatlas.domain.model.QuizTopic.Capitals) AnswerState.Selected else AnswerState.Available),
ChoiceUi("MixedGeography", "Mixed geography", "A broader grab bag"), ChoiceUi("MixedGeography", "Mixed geography", "A broader grab bag", state = if (selected == app.bertsatlas.domain.model.QuizTopic.MixedGeography) AnswerState.Selected else AnswerState.Available),
) )
private fun QuizDuration.displayName() = when (this) {
QuizDuration.FiveMinutes -> "5 minutes"
QuizDuration.TenMinutes -> "10 minutes"
QuizDuration.TwentyMinutes -> "20 minutes"
QuizDuration.Unlimited -> "Unlimited"
}
private fun app.bertsatlas.domain.model.QuizTopic.displayName() = when (this) {
app.bertsatlas.domain.model.QuizTopic.MostlyFlags -> "Mostly flags"
app.bertsatlas.domain.model.QuizTopic.Capitals -> "Capitals"
app.bertsatlas.domain.model.QuizTopic.MixedGeography -> "Mixed geography"
}
private fun letterSlots( private fun letterSlots(
question: Question, question: Question,
placements: List<app.bertsatlas.domain.model.LetterPlacement>, placements: List<app.bertsatlas.domain.model.LetterPlacement>,
@@ -78,6 +78,7 @@ data class AtlasAppState(
val firstLaunch: Boolean = true, val firstLaunch: Boolean = true,
val preferences: SavedPreferences = SavedPreferences(), val preferences: SavedPreferences = SavedPreferences(),
val selectedDuration: QuizDuration = QuizDuration.FiveMinutes, val selectedDuration: QuizDuration = QuizDuration.FiveMinutes,
val selectedTopic: QuizTopic = QuizTopic.MostlyFlags,
val run: QuizRunState? = null, val run: QuizRunState? = null,
val result: RunResult? = null, val result: RunResult? = null,
val newPersonalBest: Boolean = false, val newPersonalBest: Boolean = false,
@@ -131,7 +132,16 @@ class AppController(
val mastery = readMastery() val mastery = readMastery()
val attempts = database.progressReader().attemptsNow() val attempts = database.progressReader().attemptsNow()
val runs = database.progressReader().completedRunsNow() val runs = database.progressReader().completedRunsNow()
val streak = database.progressReader().streakNow().toDomain() val storedStreak = database.progressReader().streakNow().toDomain()
val streak = if (attempts.isNotEmpty() && storedStreak.currentDays == 0) {
attempts.map { it.localEpochDay }
.distinct()
.sorted()
.fold(Streak(), StreakRules::recordActivity)
.also { database.runWriter().saveStreak(it.toEntity()) }
} else {
storedStreak
}
val stamps = database.progressReader().stampsNow().mapNotNull { it.toDomain() } val stamps = database.progressReader().stampsNow().mapNotNull { it.toDomain() }
val snapshot = database.progressReader().activeRun() val snapshot = database.progressReader().activeRun()
val restored = snapshot?.let { val restored = snapshot?.let {
@@ -145,6 +155,7 @@ class AppController(
firstLaunch = !hasStartedTrivia, firstLaunch = !hasStartedTrivia,
preferences = preferences, preferences = preferences,
selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes), selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes),
selectedTopic = enumValueOrDefault(preferences.topic, QuizTopic.MostlyFlags),
run = restored, run = restored,
mastery = mastery, mastery = mastery,
attempts = attempts, attempts = attempts,
@@ -168,7 +179,14 @@ class AppController(
} }
} }
fun openOptions() { _state.value = _state.value.copy(destination = Destination.Options(1)) } fun openOptions() {
val current = _state.value
_state.value = current.copy(
destination = Destination.Options(1),
selectedDuration = enumValueOrDefault(current.preferences.duration, QuizDuration.FiveMinutes),
selectedTopic = enumValueOrDefault(current.preferences.topic, QuizTopic.MostlyFlags),
)
}
fun chooseOption(id: String) { fun chooseOption(id: String) {
when (val destination = _state.value.destination) { when (val destination = _state.value.destination) {
@@ -180,17 +198,31 @@ class AppController(
} else { } else {
val topic = enumValueOrDefault(id, QuizTopic.MostlyFlags) val topic = enumValueOrDefault(id, QuizTopic.MostlyFlags)
val duration = _state.value.selectedDuration val duration = _state.value.selectedDuration
_state.value = _state.value.copy(selectedTopic = topic)
scope.launch { scope.launch {
preferencesStore.saveConfiguration(duration.name, topic.name) preferencesStore.saveConfiguration(duration.name, topic.name)
val saved = preferencesStore.values.first() val saved = preferencesStore.values.first()
_state.value = _state.value.copy(preferences = saved) val current = _state.value
beginRun(QuizConfiguration(duration, topic)) val showSavedStep = current.destination == Destination.Options(2) &&
current.selectedDuration == duration &&
current.selectedTopic == topic
_state.value = if (showSavedStep) {
current.copy(preferences = saved, destination = Destination.Options(3))
} else {
current.copy(preferences = saved)
}
} }
} }
else -> Unit else -> Unit
} }
} }
fun startSavedOptions() {
val current = _state.value
if (current.destination != Destination.Options(3)) return
beginRun(QuizConfiguration(current.selectedDuration, current.selectedTopic))
}
fun back() { handleBack() } fun back() { handleBack() }
fun handleBack(): Boolean { fun handleBack(): Boolean {
@@ -203,7 +235,11 @@ class AppController(
Destination.Bootstrap, Destination.Home -> false Destination.Bootstrap, Destination.Home -> false
is Destination.Options -> { is Destination.Options -> {
_state.value = current.copy( _state.value = current.copy(
destination = if (destination.step == 2) Destination.Options(1) else Destination.Home, destination = when (destination.step) {
3 -> Destination.Options(2)
2 -> Destination.Options(1)
else -> Destination.Home
},
) )
true true
} }
@@ -333,16 +369,22 @@ class AppController(
.distinct() .distinct()
.size, .size,
) )
val activityDays = abandoned.attempts.map { it.localEpochDay }.distinct().sorted()
val activityStreak = activityDays.fold(_state.value.streak, StreakRules::recordActivity)
_state.value = _state.value.copy( _state.value = _state.value.copy(
destination = Destination.Results, destination = Destination.Results,
run = abandoned, run = abandoned,
result = summary, result = summary,
newPersonalBest = false, newPersonalBest = false,
streak = activityStreak,
leaveConfirmationRequested = false, leaveConfirmationRequested = false,
resumeRunAfterLeaveCancellation = false, resumeRunAfterLeaveCancellation = false,
) )
enqueueRunPersistence { enqueueRunPersistence {
database.runWriter().abandonRun(abandoned.toRunEntity(summary)) database.runWriter().abandonRun(
abandoned.toRunEntity(summary),
activityStreak.takeIf { activityDays.isNotEmpty() }?.toEntity(),
)
refreshProgress() refreshProgress()
} }
} }
@@ -385,13 +427,15 @@ class AppController(
enqueueRunPersistence { enqueueRunPersistence {
val before = _state.value.mastery val before = _state.value.mastery
val after = MasteryRules.applyAttemptToCountries(before, attempt) val after = MasteryRules.applyAttemptToCountries(before, attempt)
val activityStreak = StreakRules.recordActivity(_state.value.streak, attempt.localEpochDay)
val changed = after.filter { updated -> before.none { it.countryId == updated.countryId && it.skill == updated.skill && it == updated } } val changed = after.filter { updated -> before.none { it.countryId == updated.countryId && it.skill == updated.skill && it == updated } }
database.runWriter().recordAcceptedAnswer( database.runWriter().recordAcceptedAnswer(
attempt.toEntity(json), attempt.toEntity(json),
changed.map { it.toEntity() }, changed.map { it.toEntity() },
run.toSnapshotEntity(engine, json), run.toSnapshotEntity(engine, json),
activityStreak.toEntity(),
) )
_state.value = _state.value.copy(mastery = after) _state.value = _state.value.copy(mastery = after, streak = activityStreak)
} }
} }
@@ -306,7 +306,7 @@ abstract class RunWriteDao {
protected abstract suspend fun upsertPersonalBest(personalBest: PersonalBestEntity) protected abstract suspend fun upsertPersonalBest(personalBest: PersonalBestEntity)
@Upsert @Upsert
protected abstract suspend fun upsertStreak(streak: StreakEntity) abstract suspend fun saveStreak(streak: StreakEntity)
@Upsert @Upsert
protected abstract suspend fun upsertStamps(stamps: List<StampEntity>) protected abstract suspend fun upsertStamps(stamps: List<StampEntity>)
@@ -319,6 +319,7 @@ abstract class RunWriteDao {
attempt: AttemptEntity, attempt: AttemptEntity,
updatedMastery: List<MasteryEntity>, updatedMastery: List<MasteryEntity>,
snapshot: ActiveRunSnapshotEntity, snapshot: ActiveRunSnapshotEntity,
streak: StreakEntity,
) { ) {
require(updatedMastery.isNotEmpty()) { "An accepted answer must update at least one mastery item" } require(updatedMastery.isNotEmpty()) { "An accepted answer must update at least one mastery item" }
val countryIds = updatedMastery.mapTo(linkedSetOf()) { it.countryId } val countryIds = updatedMastery.mapTo(linkedSetOf()) { it.countryId }
@@ -329,6 +330,7 @@ abstract class RunWriteDao {
require(snapshot.runId == attempt.runId) { "Attempt and snapshot must belong to the same run" } require(snapshot.runId == attempt.runId) { "Attempt and snapshot must belong to the same run" }
insertAttempt(attempt.copy(countryIdsJson = countryIds.encodeStringSet())) insertAttempt(attempt.copy(countryIdsJson = countryIds.encodeStringSet()))
upsertMastery(updatedMastery) upsertMastery(updatedMastery)
saveStreak(streak)
saveActiveRun(snapshot) saveActiveRun(snapshot)
} }
@@ -346,7 +348,7 @@ abstract class RunWriteDao {
if (existingBest == null || run.score > existingBest.score) { if (existingBest == null || run.score > existingBest.score) {
upsertPersonalBest(PersonalBestEntity(duration, topic, run.score, run.id)) upsertPersonalBest(PersonalBestEntity(duration, topic, run.score, run.id))
} }
upsertStreak(streak) saveStreak(streak)
if (stamps.isNotEmpty()) upsertStamps(stamps) if (stamps.isNotEmpty()) upsertStamps(stamps)
clearActiveRun(run.id) clearActiveRun(run.id)
} }
@@ -364,7 +366,7 @@ abstract class RunWriteDao {
val existingBest = personalBest(it.duration, it.topic) val existingBest = personalBest(it.duration, it.topic)
if (existingBest == null || it.score > existingBest.score) upsertPersonalBest(it) if (existingBest == null || it.score > existingBest.score) upsertPersonalBest(it)
} }
upsertStreak(streak) saveStreak(streak)
if (stamps.isNotEmpty()) upsertStamps(stamps) if (stamps.isNotEmpty()) upsertStamps(stamps)
clearActiveRun(run.id) clearActiveRun(run.id)
} }
@@ -387,7 +389,7 @@ abstract class RunWriteDao {
} }
@Transaction @Transaction
open suspend fun abandonRun(runId: String) { open suspend fun abandonRun(runId: String, streak: StreakEntity? = null) {
upsertRun( upsertRun(
RunEntity( RunEntity(
id = runId, id = runId,
@@ -404,12 +406,14 @@ abstract class RunWriteDao {
completedAtEpochMillis = null, completedAtEpochMillis = null,
), ),
) )
if (streak != null) saveStreak(streak)
clearActiveRun(runId) clearActiveRun(runId)
} }
@Transaction @Transaction
open suspend fun abandonRun(run: RunEntity) { open suspend fun abandonRun(run: RunEntity, streak: StreakEntity? = null) {
upsertRun(run.copy(status = "Abandoned", completedAtEpochMillis = null)) upsertRun(run.copy(status = "Abandoned", completedAtEpochMillis = null))
if (streak != null) saveStreak(streak)
clearActiveRun(run.id) clearActiveRun(run.id)
} }
@@ -520,6 +524,7 @@ class RoomProgressRepository(
attempt: Attempt, attempt: Attempt,
updatedMastery: List<Mastery>, updatedMastery: List<Mastery>,
activeRunSnapshot: ActiveRunSnapshot, activeRunSnapshot: ActiveRunSnapshot,
streak: Streak,
) { ) {
require(attempt.countryIds.isNotEmpty()) require(attempt.countryIds.isNotEmpty())
writer.recordAcceptedAnswer( writer.recordAcceptedAnswer(
@@ -528,6 +533,7 @@ class RoomProgressRepository(
.filter { it.countryId in attempt.countryIds && it.skill == attempt.skill } .filter { it.countryId in attempt.countryIds && it.skill == attempt.skill }
.map { it.toEntity(json) }, .map { it.toEntity(json) },
snapshot = activeRunSnapshot.toEntity(json), snapshot = activeRunSnapshot.toEntity(json),
streak = streak.toEntity(),
) )
} }
@@ -539,7 +545,8 @@ class RoomProgressRepository(
writer.completeRun(result.toEntity(json), streak.toEntity(), newlyEarnedStamps.map(PassportStamp::toEntity)) writer.completeRun(result.toEntity(json), streak.toEntity(), newlyEarnedStamps.map(PassportStamp::toEntity))
} }
override suspend fun recordAbandonedRun(runId: String) = writer.abandonRun(runId) override suspend fun recordAbandonedRun(runId: String, streak: Streak?) =
writer.abandonRun(runId, streak?.toEntity())
override suspend fun resetProgress() = writer.resetProgress() override suspend fun resetProgress() = writer.resetProgress()
@@ -48,6 +48,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -63,6 +64,7 @@ import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.min import kotlin.math.min
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.InternalResourceApi import org.jetbrains.compose.resources.InternalResourceApi
@@ -77,6 +79,8 @@ fun AtlasScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scrollable: Boolean = true, scrollable: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 24.dp), contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
headerPadding: PaddingValues = PaddingValues(0.dp),
header: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
Box( Box(
@@ -86,14 +90,39 @@ fun AtlasScreen(
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
Column(Modifier.fillMaxSize().widthIn(max = 720.dp)) {
header?.let {
Box(Modifier.fillMaxWidth().padding(headerPadding), contentAlignment = Alignment.TopCenter) {
it()
}
}
val contentModifier = Modifier val contentModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight() .weight(1f)
.widthIn(max = 720.dp) .let { base -> if (scrollable) base.verticalScroll(rememberScrollState()) else base }
.padding(contentPadding) .padding(contentPadding)
.let { if (scrollable) it.verticalScroll(rememberScrollState()) else it }
Box(modifier = contentModifier, content = content) Box(modifier = contentModifier, content = content)
} }
}
}
@Composable
fun AtlasScreenScaffold(
title: String,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
scrollable: Boolean = true,
trailing: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
AtlasScreen(
modifier = modifier,
scrollable = scrollable,
contentPadding = PaddingValues(start = 20.dp, top = 12.dp, end = 20.dp, bottom = 24.dp),
headerPadding = PaddingValues(start = 20.dp, top = 12.dp, end = 20.dp),
header = { ScreenTopBar(title, onBack, trailing) },
content = content,
)
} }
@Composable @Composable
@@ -204,7 +233,7 @@ fun AtlasChoice(
} }
val outline = when (choice.state) { val outline = when (choice.state) {
AnswerState.Available -> colors.outline AnswerState.Available -> colors.outline
AnswerState.Selected -> colors.mustard AnswerState.Selected -> colors.success
AnswerState.Correct -> colors.success AnswerState.Correct -> colors.success
AnswerState.Incorrect -> colors.orangeAccent AnswerState.Incorrect -> colors.orangeAccent
AnswerState.Eliminated -> colors.outline.copy(alpha = .5f) AnswerState.Eliminated -> colors.outline.copy(alpha = .5f)
@@ -334,6 +363,11 @@ fun AtlasToggle(setting: SettingToggleUi, onToggle: (String, Boolean) -> Unit) {
Box( Box(
Modifier.size(width = 54.dp, height = 32.dp) Modifier.size(width = 54.dp, height = 32.dp)
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.seaGlass, CircleShape) .background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.seaGlass, CircleShape)
.border(
2.dp,
if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.outline,
CircleShape,
)
.padding(4.dp), .padding(4.dp),
contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart, contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart,
) { ) {
@@ -366,18 +400,121 @@ fun BertIllustration(
contentDescription: String = "Bert, the curious atlas brain", contentDescription: String = "Bert, the curious atlas brain",
reducedMotion: Boolean = AtlasTheme.reducedMotion, reducedMotion: Boolean = AtlasTheme.reducedMotion,
) { ) {
if (reducedMotion) {
StaticBertIllustration(pose, modifier, contentDescription)
return
}
val transition = rememberInfiniteTransition(label = "Bert bulb") val transition = rememberInfiniteTransition(label = "Bert bulb")
val rayLength by transition.animateFloat( val animatedProgress by transition.animateFloat(
initialValue = .82f, initialValue = 0f,
targetValue = 1.08f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), animationSpec = infiniteRepeatable(
label = "Bulb ray length", animation = tween(
when (pose) {
BertPose.Correct, BertPose.Overtime -> 520
BertPose.Incorrect -> 260
BertPose.Hint -> 1_100
BertPose.Fact -> 800
BertPose.Results, BertPose.Stamp -> 900
BertPose.Streak -> 700
BertPose.Idle -> 1_400
},
),
repeatMode = RepeatMode.Reverse,
),
label = "Bert ${pose.name.lowercase()} motion",
) )
StaticBertIllustration(pose, modifier, contentDescription, rayLength) val progress = if (reducedMotion) .5f else animatedProgress
val wobble = with(LocalDensity.current) { ((progress - .5f) * 6f).dp.toPx() }
Box(
modifier.size(180.dp).semantics { this.contentDescription = contentDescription },
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.matchParentSize()) { drawBertDecoration(pose, progress) }
StaticBertIllustration(
pose = pose,
modifier = Modifier.matchParentSize().graphicsLayer {
when (pose) {
BertPose.Incorrect -> translationX = wobble
BertPose.Idle -> {
scaleX = .99f + progress * .01f
scaleY = .99f + progress * .01f
}
else -> Unit
}
},
contentDescription = null,
)
}
}
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawBertDecoration(pose: BertPose, progress: Float) {
val green = Color(0xFF27635B)
val blue = Color(0xFF778BA5)
val seaGlass = Color(0xFFB7CFC8)
val coral = Color(0xFFF7BFBE)
val peach = Color(0xFFE9AF8B)
val orange = Color(0xFFE1814E)
val unit = size.minDimension / 180f
fun p(x: Float, y: Float) = Offset(x * unit, y * unit)
fun star(center: Offset, radius: Float, color: Color) {
val path = Path()
repeat(10) { index ->
val angle = -kotlin.math.PI.toFloat() / 2f + index * kotlin.math.PI.toFloat() / 5f
val length = if (index % 2 == 0) radius else radius * .42f
val point = Offset(center.x + kotlin.math.cos(angle) * length, center.y + kotlin.math.sin(angle) * length)
if (index == 0) path.moveTo(point.x, point.y) else path.lineTo(point.x, point.y)
}
path.close()
drawPath(path, color)
}
fun successBurst(includeClock: Boolean) {
val alpha = .35f + progress * .65f
val radius = (5f + progress * 4f) * unit
star(p(22f, 38f), radius, seaGlass.copy(alpha = alpha))
star(p(158f, 42f), radius * .82f, coral.copy(alpha = alpha))
star(p(24f, 142f), radius * .72f, peach.copy(alpha = alpha))
star(p(155f, 136f), radius, seaGlass.copy(alpha = alpha))
if (includeClock) {
val center = p(155f, 151f)
drawCircle(Color(0xFFF6E9E1), 15f * unit, center)
drawCircle(green, 15f * unit, center, style = Stroke(3f * unit))
drawLine(green, center, p(155f, 140f), 2.5f * unit, StrokeCap.Round)
drawLine(green, center, p(163f, 155f), 2.5f * unit, StrokeCap.Round)
}
}
when (pose) {
BertPose.Idle, BertPose.Incorrect -> Unit
BertPose.Correct -> successBurst(includeClock = false)
BertPose.Overtime -> successBurst(includeClock = true)
BertPose.Hint -> {
val lift = progress * 3f
drawCircle(seaGlass.copy(alpha = .7f), 4f * unit, p(139f, 38f + lift))
drawCircle(seaGlass.copy(alpha = .8f), 7f * unit, p(151f, 27f + lift))
drawCircle(seaGlass, 10f * unit, p(165f, 17f + lift))
}
BertPose.Fact -> {
val center = p(151f, 32f)
drawCircle(peach, 10f * unit, center)
val rayLength = 7f + 4f * progress
drawLine(orange, p(151f, 14f), p(151f, 14f - rayLength), 3.5f * unit, StrokeCap.Round)
drawLine(orange, p(165f, 19f), p(165f + rayLength * .7f, 19f - rayLength * .7f), 3.5f * unit, StrokeCap.Round)
}
BertPose.Results -> {
drawCircle(blue.copy(alpha = .18f + progress * .12f), (72f + progress * 5f) * unit, p(90f, 88f), style = Stroke(4f * unit))
}
BertPose.Streak -> {
val flame = Path().apply {
moveTo(158f * unit, 161f * unit)
cubicTo(139f * unit, 151f * unit, 151f * unit, (127f - progress * 5f) * unit, 159f * unit, 121f * unit)
cubicTo(177f * unit, 141f * unit, 174f * unit, 157f * unit, 158f * unit, 161f * unit)
close()
}
drawPath(flame, orange.copy(alpha = .8f + progress * .2f))
}
BertPose.Stamp -> {
val center = p(155f, 150f)
drawCircle(blue.copy(alpha = .7f), (13f + progress * 2f) * unit, center, style = Stroke(3f * unit))
drawLine(green, p(148f, 150f), p(153f, 155f), 3f * unit, StrokeCap.Round)
drawLine(green, p(153f, 155f), p(163f, 143f), 3f * unit, StrokeCap.Round)
}
}
} }
@Composable @Composable
@@ -385,7 +522,6 @@ private fun StaticBertIllustration(
pose: BertPose, pose: BertPose,
modifier: Modifier, modifier: Modifier,
contentDescription: String?, contentDescription: String?,
rayLength: Float = 1f,
) { ) {
val colors = AtlasTheme.colors val colors = AtlasTheme.colors
val accessibleModifier = if (contentDescription == null) Modifier else Modifier.semantics { this.contentDescription = contentDescription } val accessibleModifier = if (contentDescription == null) Modifier else Modifier.semantics { this.contentDescription = contentDescription }
@@ -428,10 +564,6 @@ private fun StaticBertIllustration(
quadraticTo(90f * scale, (if (pose == BertPose.Incorrect) 106f else 130f) * scale, 109f * 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)) drawPath(mouth, colors.plum, style = Stroke(4f * scale, cap = StrokeCap.Round))
val accent = colors.orangeAccent
drawCircle(accent, 10f * scale, p(145f, 39f))
drawLine(accent, p(145f, 17f), p(145f, 17f - 10f * rayLength), 4f * scale, StrokeCap.Round)
drawLine(accent, p(161f, 25f), p(161f + 8f * rayLength, 25f - 7f * rayLength), 4f * scale, StrokeCap.Round)
} }
} }
@@ -4,10 +4,12 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -39,18 +41,23 @@ fun HomeScreen(
onSettings: () -> Unit = {}, onSettings: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AtlasScreen(modifier) { AtlasScreen(
modifier = modifier,
headerPadding = PaddingValues(start = 20.dp, top = 12.dp, end = 20.dp),
header = if (hasStartedTrivia) {
{
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
PointsBadge(totalPoints, onProgress, Modifier.weight(1f))
SettingsIconButton(onSettings)
}
}
} else null,
) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (hasStartedTrivia) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
PointsBadge(totalPoints, Modifier.weight(1f))
SettingsIconButton(onSettings)
}
}
Spacer(Modifier.height(if (hasStartedTrivia) 8.dp else 24.dp)) Spacer(Modifier.height(if (hasStartedTrivia) 8.dp else 24.dp))
HomeBertIllustration(large = !hasStartedTrivia) HomeBertIllustration(large = !hasStartedTrivia)
AtlasText( AtlasText(
@@ -65,11 +72,16 @@ fun HomeScreen(
if (hasStartedTrivia) { if (hasStartedTrivia) {
AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary) AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary)
SectionGap() SectionGap()
AtlasCard(Modifier.widthIn(max = 420.dp), "Current streak: $streakDays days. $progressSummary") { val streakSummary = when (streakDays) {
0 -> "Your curiosity streak starts with your first answer"
1 -> "1 curious day"
else -> "$streakDays curious days"
}
AtlasCard(Modifier.widthIn(max = 420.dp), "$streakSummary. $progressSummary") {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
AtlasText("✦", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral) AtlasText("✦", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
AtlasText(if (streakDays == 1) "1 curious day" else "$streakDays curious days", style = AtlasTheme.typography.label) AtlasText(streakSummary, style = AtlasTheme.typography.label)
AtlasText(progressSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum) AtlasText(progressSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
} }
} }
@@ -85,35 +97,45 @@ private fun HomeBertIllustration(large: Boolean) {
val size = if (large) 270.dp else 200.dp val size = if (large) 270.dp else 200.dp
val colors = AtlasTheme.colors val colors = AtlasTheme.colors
androidx.compose.foundation.layout.Box(Modifier.size(size), contentAlignment = Alignment.Center) { androidx.compose.foundation.layout.Box(Modifier.size(size), contentAlignment = Alignment.Center) {
BertIllustration(BertPose.Idle, Modifier.fillMaxWidth(), contentDescription = "Bert smiles with a bright idea and a folded map") BertIllustration(BertPose.Idle, Modifier.size(size), contentDescription = "Bert smiles with a bright idea and a folded map")
Canvas(Modifier.fillMaxWidth().size(size)) { Canvas(Modifier.fillMaxWidth().size(size)) {
val w = this.size.width val w = this.size.width
val h = this.size.height val h = this.size.height
val outline = 4.dp.toPx() val outline = 4.dp.toPx()
val map = Path().apply { val map = Path().apply {
moveTo(w * .04f, h * .68f) moveTo(w * .01f, h * .68f)
lineTo(w * .14f, h * .63f) lineTo(w * .11f, h * .63f)
lineTo(w * .25f, h * .68f) lineTo(w * .22f, h * .68f)
lineTo(w * .36f, h * .63f) lineTo(w * .33f, h * .63f)
lineTo(w * .36f, h * .88f) lineTo(w * .33f, h * .88f)
lineTo(w * .25f, h * .93f) lineTo(w * .22f, h * .93f)
lineTo(w * .14f, h * .88f) lineTo(w * .11f, h * .88f)
lineTo(w * .04f, h * .93f) lineTo(w * .01f, h * .93f)
close() close()
} }
drawPath(map, colors.paper) drawPath(map, colors.paper)
drawPath(map, colors.plum, style = Stroke(outline, join = androidx.compose.ui.graphics.StrokeJoin.Round)) drawPath(map, colors.plum, style = Stroke(outline, join = androidx.compose.ui.graphics.StrokeJoin.Round))
drawLine(colors.plum, Offset(w * .14f, h * .63f), Offset(w * .14f, h * .88f), outline) drawLine(colors.plum, Offset(w * .11f, h * .63f), Offset(w * .11f, h * .88f), outline)
drawLine(colors.plum, Offset(w * .25f, h * .68f), Offset(w * .25f, h * .93f), outline) drawLine(colors.plum, Offset(w * .22f, h * .68f), Offset(w * .22f, h * .93f), outline)
drawLine(colors.orangeAccent, Offset(w * .06f, h * .83f), Offset(w * .34f, h * .76f), outline * .7f, StrokeCap.Round) drawLine(colors.orangeAccent, Offset(w * .03f, h * .83f), Offset(w * .31f, h * .76f), outline * .7f, StrokeCap.Round)
} }
} }
} }
@Composable @Composable
private fun PointsBadge(points: Int, modifier: Modifier = Modifier) { private fun PointsBadge(points: Int, onClick: () -> Unit, modifier: Modifier = Modifier) {
val colors = AtlasTheme.colors val colors = AtlasTheme.colors
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
modifier
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) {
role = Role.Button
contentDescription = "View progress, $points total points"
}
.clickable(role = Role.Button, onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Canvas(Modifier.size(24.dp)) { Canvas(Modifier.size(24.dp)) {
drawCircle(colors.mustard, size.minDimension * .40f) drawCircle(colors.mustard, size.minDimension * .40f)
drawCircle(colors.plum, size.minDimension * .40f, style = Stroke(2.4.dp.toPx())) drawCircle(colors.plum, size.minDimension * .40f, style = Stroke(2.4.dp.toPx()))
@@ -148,15 +170,22 @@ private fun SettingsIconButton(onClick: () -> Unit) {
fun OptionsScreen( fun OptionsScreen(
step: Int, step: Int,
choices: List<ChoiceUi>, choices: List<ChoiceUi>,
durationSummary: String,
topicSummary: String,
onChoose: (String) -> Unit, onChoose: (String) -> Unit,
onStart: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isTimeStep = step <= 1 val isTimeStep = step <= 1
AtlasScreen(modifier) { val title = when (step) {
1 -> "Options · 1 of 2"
2 -> "Options · 2 of 2"
else -> "Options saved"
}
AtlasScreenScaffold(title, onBack, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar(if (isTimeStep) "Options · 1 of 2" else "Options · 2 of 2", onBack) if (step <= 2) {
SectionGap()
BertIllustration(if (isTimeStep) BertPose.Idle else BertPose.Fact, Modifier.align(Alignment.CenterHorizontally)) BertIllustration(if (isTimeStep) BertPose.Idle else BertPose.Fact, Modifier.align(Alignment.CenterHorizontally))
AtlasHeading( AtlasHeading(
if (isTimeStep) "How much time have you got?" else "What are we nerding out about?", if (isTimeStep) "How much time have you got?" else "What are we nerding out about?",
@@ -170,6 +199,21 @@ fun OptionsScreen(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
choices.forEach { AtlasChoice(it, onChoose) } choices.forEach { AtlasChoice(it, onChoose) }
} else {
SectionGap()
BertIllustration(BertPose.Correct, Modifier.align(Alignment.CenterHorizontally))
AtlasHeading("Saved and ready", Modifier.fillMaxWidth(), centered = true)
AtlasText(
"Your next trivia run is set up.",
Modifier.fillMaxWidth(),
color = AtlasTheme.colors.mutedPlum,
textAlign = TextAlign.Center,
)
AtlasCard(contentDescription = "Saved options. Duration: $durationSummary. Topic: $topicSummary") {
StatRow(listOf(StatUi("Duration", durationSummary), StatUi("Topic", topicSummary)))
}
AtlasButton("Start", onStart, Modifier.fillMaxWidth())
}
} }
} }
} }
@@ -56,21 +56,33 @@ fun QuizScreen(
runProgressDescription: String = "Trivia progress", runProgressDescription: String = "Trivia progress",
overtimeMessage: String = "Time's up. Keep going for half points", overtimeMessage: String = "Time's up. Keep going for half points",
) { ) {
AtlasScreen(modifier, contentPadding = PaddingValues(0.dp)) { AtlasScreen(
Column(Modifier.fillMaxWidth()) { modifier = modifier,
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
header = {
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
runProgress?.let { SubtleRunProgress(it, runProgressDescription) } runProgress?.let { SubtleRunProgress(it, runProgressDescription) }
Column( Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 24.dp), Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 20.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(Modifier.fillMaxWidth().heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) {
QuizMetric("Score", score.toString(), Modifier.weight(1f)) QuizMetric("Score", score.toString(), Modifier.weight(1f))
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired) QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
AtlasExitButton(onLeave) AtlasExitButton(onLeave)
} }
if (timerExpired) { if (timerExpired) {
AtlasText(overtimeMessage, Modifier.fillMaxWidth(), color = AtlasTheme.colors.error, style = AtlasTheme.typography.label, textAlign = TextAlign.Center) AtlasText(
overtimeMessage,
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp),
color = AtlasTheme.colors.error,
style = AtlasTheme.typography.label,
textAlign = TextAlign.Center,
)
} }
}
},
) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (promptFlag != null || imageDescription != null) { if (promptFlag != null || imageDescription != null) {
Box( Box(
Modifier.fillMaxWidth().heightIn(min = 148.dp) Modifier.fillMaxWidth().heightIn(min = 148.dp)
@@ -106,7 +118,6 @@ fun QuizScreen(
} }
} }
} }
}
} }
@Composable @Composable
@@ -240,9 +251,8 @@ fun HintChooserScreen(
introText: String = "Choose the clue that sounds most useful.", introText: String = "Choose the clue that sounds most useful.",
chargedHintLabel: String = "−25 points", chargedHintLabel: String = "−25 points",
) { ) {
AtlasScreen(modifier) { AtlasScreenScaffold("Choose a hint", onCancel, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
ScreenTopBar("Choose a hint", onCancel)
BertIllustration(BertPose.Hint, Modifier.size(150.dp)) BertIllustration(BertPose.Hint, Modifier.size(150.dp))
AtlasHeading("A little nudge?", Modifier.fillMaxWidth(), centered = true) AtlasHeading("A little nudge?", Modifier.fillMaxWidth(), centered = true)
AtlasText(introText, textAlign = TextAlign.Center, color = AtlasTheme.colors.mutedPlum) AtlasText(introText, textAlign = TextAlign.Center, color = AtlasTheme.colors.mutedPlum)
@@ -47,7 +47,7 @@ fun ResultsScreen(
) )
if (abandoned) { if (abandoned) {
AtlasText( AtlasText(
"Your answers still shaped what you'll practise next. This trivia run didn't count toward your personal best or daily streak.", "Your answers still shaped what you'll practise next and counted toward today's activity. This trivia run didn't count toward your personal best.",
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
color = AtlasTheme.colors.mutedPlum, color = AtlasTheme.colors.mutedPlum,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -92,14 +92,28 @@ fun ProgressScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AtlasScreen(modifier) { AtlasScreenScaffold("Your atlas", onBack, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar("Your atlas", onBack)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
BertIllustration(BertPose.Streak, Modifier.weight(.8f), "Bert celebrates your curiosity streak") BertIllustration(BertPose.Streak, Modifier.weight(.8f), "Bert celebrates your curiosity streak")
Column(Modifier.weight(1.2f), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.weight(1.2f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
AtlasText("$currentStreak day streak", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.coral) AtlasText(
AtlasText("Best · $bestStreak days", color = AtlasTheme.colors.mutedPlum) when (currentStreak) {
0 -> "Start your streak"
1 -> "1 day streak"
else -> "$currentStreak day streak"
},
style = AtlasTheme.typography.heading,
color = AtlasTheme.colors.coral,
)
AtlasText(
when (bestStreak) {
0 -> "Answer a question to begin"
1 -> "Best · 1 day"
else -> "Best · $bestStreak days"
},
color = AtlasTheme.colors.mutedPlum,
)
} }
} }
StatRow(listOf(StatUi("Accuracy", overallAccuracy), StatUi("Questions", totalAnswered.toString()))) StatRow(listOf(StatUi("Accuracy", overallAccuracy), StatUi("Questions", totalAnswered.toString())))
@@ -30,10 +30,8 @@ fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AtlasScreen(modifier) { AtlasScreenScaffold("Settings", onBack, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
ScreenTopBar("Settings", onBack)
SectionGap()
AtlasCard { AtlasCard {
settings.forEach { AtlasToggle(it, onToggle) } settings.forEach { AtlasToggle(it, onToggle) }
} }
@@ -69,9 +67,8 @@ fun ResetConfirmationScreen(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AtlasScreen(modifier) { AtlasScreenScaffold("Reset local data", onCancel, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar("Reset local data", onCancel)
BertIllustration(BertPose.Incorrect, Modifier.fillMaxWidth()) BertIllustration(BertPose.Incorrect, Modifier.fillMaxWidth())
AtlasHeading("Clear this atlas?") AtlasHeading("Clear this atlas?")
AtlasText("This removes your progress, streaks, stamps, settings, and any saved run from this device. It cannot be undone.") AtlasText("This removes your progress, streaks, stamps, settings, and any saved run from this device. It cannot be undone.")
@@ -103,9 +100,8 @@ fun CreditsScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AtlasScreen(modifier) { AtlasScreenScaffold("Credits and licenses", onBack, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { 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.") AtlasText("Bert, the writing, and the interface artwork are original to Bert's Atlas.")
AtlasCard { AtlasCard {
AtlasText("Content", style = AtlasTheme.typography.heading) AtlasText("Content", style = AtlasTheme.typography.heading)
@@ -123,9 +119,8 @@ fun CreditsScreen(
@Composable @Composable
private fun InformationScreen(title: String, paragraphs: List<String>, onBack: () -> Unit, modifier: Modifier = Modifier) { private fun InformationScreen(title: String, paragraphs: List<String>, onBack: () -> Unit, modifier: Modifier = Modifier) {
AtlasScreen(modifier) { AtlasScreenScaffold(title, onBack, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTopBar(title, onBack)
paragraphs.forEach { AtlasText(it) } paragraphs.forEach { AtlasText(it) }
} }
} }
@@ -141,9 +136,8 @@ fun DebugPanelScreen(
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AtlasScreen(modifier) { AtlasScreenScaffold("Debug QA", onClose, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
ScreenTopBar("Debug QA", onClose)
AtlasCard { AtlasCard {
AtlasText("Build $buildVersion", style = AtlasTheme.typography.label) AtlasText("Build $buildVersion", style = AtlasTheme.typography.label)
AtlasText("Catalog $catalogVersion") AtlasText("Catalog $catalogVersion")
+10 -11
View File
@@ -1,14 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<title id="title">Bert's Atlas mark</title>
<desc id="description">Smiling Bert in green glasses with a small folded paper map at his top right.</desc>
<rect width="512" height="512" rx="112" fill="#F6E9E1"/> <rect width="512" height="512" rx="112" fill="#F6E9E1"/>
<g transform="translate(55 85) scale(.66)"> <g transform="translate(107 128) scale(.78)">
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" stroke-linejoin="round" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/> <path d="M39.8382 198.948C-18.1618 165.948 2.83815 79.9482 69.8382 74.9482C72.8382 2.9482 165.838 -17.0518 206.838 35.9482C261.838 -9.05179 348.838 33.9482 336.838 105.948C399.838 135.948 378.838 225.948 321.838 240.948C303.838 325.948 198.838 343.948 148.838 291.948C86.8382 323.948 13.8382 263.948 39.8382 198.948Z" fill="#F7BFBE" stroke="#27635B" stroke-width="12"/>
<path fill="none" stroke="#E1814E" stroke-width="10" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/> <path d="M87.8381 78.9482C68.8381 100.948 67.8381 127.948 80.8381 147.948M138.838 28.9482C123.838 59.9482 128.838 82.9482 145.838 100.948M212.838 34.9482C233.838 56.9482 233.838 83.9482 220.838 107.948M284.838 80.9482C267.838 101.948 266.838 123.948 277.838 144.948M57.8381 188.948C83.8381 196.948 96.8381 216.948 93.8381 242.948M287.838 168.948C260.838 181.948 251.838 203.948 258.838 228.948" stroke="#E1814E" stroke-width="8" stroke-linecap="round"/>
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><path stroke="#27635B" stroke-width="19" stroke-linecap="round" d="M245 245h23"/><circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/><path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/> <path d="M151.838 133.948H88.8381C73.3742 133.948 60.8381 146.484 60.8381 161.948V197.948C60.8381 213.412 73.3742 225.948 88.8381 225.948H151.838C167.302 225.948 179.838 213.412 179.838 197.948V161.948C179.838 146.484 167.302 133.948 151.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
</g> <path d="M293.838 133.948H230.838C215.374 133.948 202.838 146.484 202.838 161.948V197.948C202.838 213.412 215.374 225.948 230.838 225.948H293.838C309.302 225.948 321.838 213.412 321.838 197.948V161.948C321.838 146.484 309.302 133.948 293.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round"> <path d="M179.838 173.948H202.838" stroke="#27635B" stroke-width="15" stroke-linecap="round"/>
<path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/> <path d="M119.838 190.948C126.466 190.948 131.838 185.576 131.838 178.948C131.838 172.321 126.466 166.948 119.838 166.948C113.211 166.948 107.838 172.321 107.838 178.948C107.838 185.576 113.211 190.948 119.838 190.948Z" fill="#27635B"/>
<path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/><path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/><path fill="none" d="M332 91v91m28-78v91m30-104v91"/><path fill="none" stroke="#E1814E" stroke-width="7" stroke-linecap="round" d="M308 157c20-18 28 7 46-10 16-14 21-29 38-12 10 12 14 18 22 15"/> <path d="M262.838 190.948C269.466 190.948 274.838 185.576 274.838 178.948C274.838 172.321 269.466 166.948 262.838 166.948C256.211 166.948 250.838 172.321 250.838 178.948C250.838 185.576 256.211 190.948 262.838 190.948Z" fill="#27635B"/>
<path d="M138.838 253.948C173.505 285.282 208.505 285.282 243.838 253.948" stroke="#27635B" stroke-width="12" stroke-linecap="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+10 -4
View File
@@ -1,7 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bert's Atlas splash mark"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" fill="#F6E9E1"/> <rect width="512" height="512" fill="#F6E9E1"/>
<g transform="translate(55 85) scale(.66)"> <g transform="translate(72 105) scale(.96)">
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" stroke-linejoin="round" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/><rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/><path stroke="#27635B" stroke-width="19" d="M245 245h23"/><circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/><path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/> <path d="M39.8382 198.948C-18.1618 165.948 2.83815 79.9482 69.8382 74.9482C72.8382 2.9482 165.838 -17.0518 206.838 35.9482C261.838 -9.05179 348.838 33.9482 336.838 105.948C399.838 135.948 378.838 225.948 321.838 240.948C303.838 325.948 198.838 343.948 148.838 291.948C86.8382 323.948 13.8382 263.948 39.8382 198.948Z" fill="#F7BFBE" stroke="#27635B" stroke-width="12"/>
<path d="M87.8381 78.9482C68.8381 100.948 67.8381 127.948 80.8381 147.948M138.838 28.9482C123.838 59.9482 128.838 82.9482 145.838 100.948M212.838 34.9482C233.838 56.9482 233.838 83.9482 220.838 107.948M284.838 80.9482C267.838 101.948 266.838 123.948 277.838 144.948M57.8381 188.948C83.8381 196.948 96.8381 216.948 93.8381 242.948M287.838 168.948C260.838 181.948 251.838 203.948 258.838 228.948" stroke="#E1814E" stroke-width="8" stroke-linecap="round"/>
<path d="M151.838 133.948H88.8381C73.3742 133.948 60.8381 146.484 60.8381 161.948V197.948C60.8381 213.412 73.3742 225.948 88.8381 225.948H151.838C167.302 225.948 179.838 213.412 179.838 197.948V161.948C179.838 146.484 167.302 133.948 151.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<path d="M293.838 133.948H230.838C215.374 133.948 202.838 146.484 202.838 161.948V197.948C202.838 213.412 215.374 225.948 230.838 225.948H293.838C309.302 225.948 321.838 213.412 321.838 197.948V161.948C321.838 146.484 309.302 133.948 293.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<path d="M179.838 173.948H202.838" stroke="#27635B" stroke-width="15" stroke-linecap="round"/>
<path d="M119.838 190.948C126.466 190.948 131.838 185.576 131.838 178.948C131.838 172.321 126.466 166.948 119.838 166.948C113.211 166.948 107.838 172.321 107.838 178.948C107.838 185.576 113.211 190.948 119.838 190.948Z" fill="#27635B"/>
<path d="M262.838 190.948C269.466 190.948 274.838 185.576 274.838 178.948C274.838 172.321 269.466 166.948 262.838 166.948C256.211 166.948 250.838 172.321 250.838 178.948C250.838 185.576 256.211 190.948 262.838 190.948Z" fill="#27635B"/>
<path d="M138.838 253.948C173.505 285.282 208.505 285.282 243.838 253.948" stroke="#27635B" stroke-width="12" stroke-linecap="round"/>
</g> </g>
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round"><path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/><path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/><path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/><path fill="none" d="M332 91v91m28-78v91m30-104v91"/><path fill="none" stroke="#E1814E" stroke-width="7" stroke-linecap="round" d="M308 157c20-18 28 7 46-10 16-14 21-29 38-12 10 12 14 18 22 15"/></g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="382" height="328" viewBox="0 0 382 328" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.8382 198.948C-18.1618 165.948 2.83815 79.9482 69.8382 74.9482C72.8382 2.9482 165.838 -17.0518 206.838 35.9482C261.838 -9.05179 348.838 33.9482 336.838 105.948C399.838 135.948 378.838 225.948 321.838 240.948C303.838 325.948 198.838 343.948 148.838 291.948C86.8382 323.948 13.8382 263.948 39.8382 198.948Z" fill="#F7BFBE" stroke="#27635B" stroke-width="12"/>
<path d="M87.8381 78.9482C68.8381 100.948 67.8381 127.948 80.8381 147.948M138.838 28.9482C123.838 59.9482 128.838 82.9482 145.838 100.948M212.838 34.9482C233.838 56.9482 233.838 83.9482 220.838 107.948M284.838 80.9482C267.838 101.948 266.838 123.948 277.838 144.948M57.8381 188.948C83.8381 196.948 96.8381 216.948 93.8381 242.948M287.838 168.948C260.838 181.948 251.838 203.948 258.838 228.948" stroke="#E1814E" stroke-width="8" stroke-linecap="round"/>
<path d="M151.838 133.948H88.8381C73.3742 133.948 60.8381 146.484 60.8381 161.948V197.948C60.8381 213.412 73.3742 225.948 88.8381 225.948H151.838C167.302 225.948 179.838 213.412 179.838 197.948V161.948C179.838 146.484 167.302 133.948 151.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<path d="M293.838 133.948H230.838C215.374 133.948 202.838 146.484 202.838 161.948V197.948C202.838 213.412 215.374 225.948 230.838 225.948H293.838C309.302 225.948 321.838 213.412 321.838 197.948V161.948C321.838 146.484 309.302 133.948 293.838 133.948Z" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
<path d="M179.838 173.948H202.838Z" fill="black"/>
<path d="M179.838 173.948H202.838" stroke="#27635B" stroke-width="15" stroke-linecap="round"/>
<path d="M119.838 190.948C126.466 190.948 131.838 185.576 131.838 178.948C131.838 172.321 126.466 166.948 119.838 166.948C113.211 166.948 107.838 172.321 107.838 178.948C107.838 185.576 113.211 190.948 119.838 190.948Z" fill="#27635B"/>
<path d="M262.838 190.948C269.466 190.948 274.838 185.576 274.838 178.948C274.838 172.321 269.466 166.948 262.838 166.948C256.211 166.948 250.838 172.321 250.838 178.948C250.838 185.576 256.211 190.948 262.838 190.948Z" fill="#27635B"/>
<path d="M138.838 253.948C173.505 285.282 208.505 285.282 243.838 253.948" stroke="#27635B" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -28,6 +28,7 @@ interface ProgressWriter {
attempt: Attempt, attempt: Attempt,
updatedMastery: List<Mastery>, updatedMastery: List<Mastery>,
activeRunSnapshot: ActiveRunSnapshot, activeRunSnapshot: ActiveRunSnapshot,
streak: Streak,
) )
suspend fun recordCompletedRun( suspend fun recordCompletedRun(
@@ -36,7 +37,7 @@ interface ProgressWriter {
newlyEarnedStamps: List<PassportStamp>, newlyEarnedStamps: List<PassportStamp>,
) )
suspend fun recordAbandonedRun(runId: String) suspend fun recordAbandonedRun(runId: String, streak: Streak? = null)
suspend fun resetProgress() suspend fun resetProgress()
} }
@@ -76,11 +76,11 @@ object MasteryRules {
} }
object StreakRules { object StreakRules {
fun recordCompletedRun(current: Streak, completedEpochDay: Int): Streak { fun recordActivity(current: Streak, activityEpochDay: Int): Streak {
val lastDay = current.lastCompletedEpochDay val lastDay = current.lastActivityEpochDay
if (lastDay != null && completedEpochDay <= lastDay) return current if (lastDay != null && activityEpochDay <= lastDay) return current
val currentDays = if (lastDay != null && completedEpochDay == lastDay + 1) { val currentDays = if (lastDay != null && activityEpochDay == lastDay + 1) {
current.currentDays + 1 current.currentDays + 1
} else { } else {
1 1
@@ -88,9 +88,12 @@ object StreakRules {
return Streak( return Streak(
currentDays = currentDays, currentDays = currentDays,
bestDays = maxOf(current.bestDays, currentDays), bestDays = maxOf(current.bestDays, currentDays),
lastCompletedEpochDay = completedEpochDay, lastCompletedEpochDay = activityEpochDay,
) )
} }
fun recordCompletedRun(current: Streak, completedEpochDay: Int): Streak =
recordActivity(current, completedEpochDay)
} }
data class PassportEvidence( data class PassportEvidence(
@@ -109,7 +109,8 @@ class QuizEngine(
} }
val completionPending = state.runCompletionPending || val completionPending = state.runCompletionPending ||
state.sessionExpired || state.sessionExpired ||
(state.configuration.duration != QuizDuration.Unlimited && state.sessionRemainingMillis == 0L) (state.configuration.duration != QuizDuration.Unlimited && state.sessionRemainingMillis == 0L) ||
state.hasReachedAnsweredQuestionLimit()
if (state.phase == RunPhase.Paused) { if (state.phase == RunPhase.Paused) {
require(state.pausedFromPhase in PAUSABLE_PHASES) { "A paused snapshot has no resumable phase" } require(state.pausedFromPhase in PAUSABLE_PHASES) { "A paused snapshot has no resumable phase" }
return state.copy( return state.copy(
@@ -161,13 +162,17 @@ class QuizEngine(
hintTypes = timed.activeQuestion.usedHintTypes, hintTypes = timed.activeQuestion.usedHintTypes,
) )
val correctStreak = if (evaluation.correct) timed.currentCorrectStreak + 1 else 0 val correctStreak = if (evaluation.correct) timed.currentCorrectStreak + 1 else 0
val attempts = timed.attempts + attempt
val answeredQuestionLimitReached = timed.configuration.duration.answeredQuestionLimit
?.let { attempts.size >= it }
?: false
return timed.copy( return timed.copy(
phase = RunPhase.Feedback, phase = RunPhase.Feedback,
score = timed.score + points, score = timed.score + points,
answeredQuestionIds = timed.answeredQuestionIds + timed.activeQuestion.question.id, answeredQuestionIds = timed.answeredQuestionIds + timed.activeQuestion.question.id,
recentQuestionIds = (timed.recentQuestionIds + timed.activeQuestion.question.id).takeLast(10), recentQuestionIds = (timed.recentQuestionIds + timed.activeQuestion.question.id).takeLast(10),
answeredCountryIds = (timed.answeredCountryIds + timed.activeQuestion.question.countryIds).takeLast(10), answeredCountryIds = (timed.answeredCountryIds + timed.activeQuestion.question.countryIds).takeLast(10),
attempts = timed.attempts + attempt, attempts = attempts,
feedback = AnswerFeedback( feedback = AnswerFeedback(
kind = when { kind = when {
!evaluation.correct -> FeedbackKind.Incorrect !evaluation.correct -> FeedbackKind.Incorrect
@@ -182,6 +187,7 @@ class QuizEngine(
adaptive = AdaptiveDifficulty.afterAnswer(timed.adaptive, evaluation.correct, hinted), adaptive = AdaptiveDifficulty.afterAnswer(timed.adaptive, evaluation.correct, hinted),
currentCorrectStreak = correctStreak, currentCorrectStreak = correctStreak,
bestCorrectStreak = maxOf(timed.bestCorrectStreak, correctStreak), bestCorrectStreak = maxOf(timed.bestCorrectStreak, correctStreak),
runCompletionPending = timed.runCompletionPending || answeredQuestionLimitReached,
tickingSinceMillis = null, tickingSinceMillis = null,
) )
} }
@@ -592,6 +598,9 @@ class QuizEngine(
private fun ActiveQuestion.revealedTileIds(): Set<String> = private fun ActiveQuestion.revealedTileIds(): Set<String> =
appliedHints.mapNotNullTo(linkedSetOf()) { it.revealedTileId } appliedHints.mapNotNullTo(linkedSetOf()) { it.revealedTileId }
private fun QuizRunState.hasReachedAnsweredQuestionLimit(): Boolean =
configuration.duration.answeredQuestionLimit?.let { attempts.size >= it } == true
private fun correctAnswer(question: Question): String = when (question) { private fun correctAnswer(question: Question): String = when (question) {
is Question.MultipleChoice -> question.choices.first { it.id == question.correctChoiceId }.label is Question.MultipleChoice -> question.choices.first { it.id == question.correctChoiceId }.label
is Question.LetterBank -> question.displayAnswer is Question.LetterBank -> question.displayAnswer
@@ -667,8 +676,8 @@ class QuizEngine(
private data class AnswerEvaluation(val correct: Boolean, val submittedText: String) private data class AnswerEvaluation(val correct: Boolean, val submittedText: String)
companion object { companion object {
const val MULTIPLE_CHOICE_WINDOW_MILLIS = 10_000L const val MULTIPLE_CHOICE_WINDOW_MILLIS = 30_000L
const val LETTER_BANK_WINDOW_MILLIS = 20_000L const val LETTER_BANK_WINDOW_MILLIS = 30_000L
const val LETTER_BANK_DECOY_COUNT = 3 const val LETTER_BANK_DECOY_COUNT = 3
const val MINIMUM_UNLIMITED_ANSWERS = 5 const val MINIMUM_UNLIMITED_ANSWERS = 5
const val ACTIVE_RUN_SNAPSHOT_SCHEMA_VERSION = 1 const val ACTIVE_RUN_SNAPSHOT_SCHEMA_VERSION = 1
@@ -9,11 +9,14 @@ data class QuizConfiguration(
) )
@Serializable @Serializable
enum class QuizDuration(val activeDurationMillis: Long?) { enum class QuizDuration(
FiveMinutes(5 * 60 * 1_000L), val activeDurationMillis: Long?,
TenMinutes(10 * 60 * 1_000L), val answeredQuestionLimit: Int?,
TwentyMinutes(20 * 60 * 1_000L), ) {
Unlimited(null), FiveMinutes(5 * 60 * 1_000L, 10),
TenMinutes(10 * 60 * 1_000L, 20),
TwentyMinutes(20 * 60 * 1_000L, 40),
Unlimited(null, null),
} }
@Serializable @Serializable
@@ -60,7 +60,10 @@ data class Streak(
val currentDays: Int = 0, val currentDays: Int = 0,
val bestDays: Int = 0, val bestDays: Int = 0,
val lastCompletedEpochDay: Int? = null, val lastCompletedEpochDay: Int? = null,
) ) {
val lastActivityEpochDay: Int?
get() = lastCompletedEpochDay
}
@Serializable @Serializable
enum class PassportStampKind { enum class PassportStampKind {
+17 -12
View File
@@ -111,14 +111,15 @@ Tapping `Start` immediately begins a trivia run with these defaults:
No configuration questionnaire or modal tutorial appears before the first trivia run. The interface teaches mechanics contextually without delaying the first question. No configuration questionnaire or modal tutorial appears before the first trivia run. The interface teaches mechanics contextually without delaying the first question.
### 6.2 Returning launch ### 6.2 Returning home
From the second app launch onward, the home screen contains: After the player starts their first trivia run, the home screen contains:
- `Start`, which immediately uses the most recently saved trivia configuration - `Start`, which immediately uses the most recently saved trivia configuration
- `Options`, a secondary action - `Options`, a secondary action
- A tappable lifetime-points summary that opens Progress
- Current streak and a compact recent-progress summary - Current streak and a compact recent-progress summary
- Settings - A Settings icon
The minimal first-use Home remains until the player starts their first trivia run. Merely opening and closing the app does not reveal returning-player controls. Completing the run is not required. The minimal first-use Home remains until the player starts their first trivia run. Merely opening and closing the app does not reveal returning-player controls. Completing the run is not required.
@@ -138,7 +139,7 @@ The initial V1 sequence is:
- Capitals - Capitals
- Mixed geography - Mixed geography
The final answer is saved and immediately starts the trivia. All answers are saved as one configuration. Abandoning the flow leaves the previously saved configuration unchanged. The final answer is saved atomically and opens a full-screen success step that summarizes the chosen duration and topic. The player starts the trivia from that screen. Abandoning the flow before saving leaves the previously saved configuration unchanged.
Difficulty is always adaptive in V1 and has no player-facing configuration. Difficulty is always adaptive in V1 and has no player-facing configuration.
@@ -156,11 +157,11 @@ A run repeatedly moves through:
6. Occasionally present a full-screen fun fact. 6. Occasionally present a full-screen fun fact.
7. End with a result screen when the selected duration expires. 7. End with a result screen when the selected duration expires.
Timed runs finish the question currently on screen when the session clock expires. They do not begin another question. Unlimited runs continue until the player chooses to finish; elapsed time and score remain visible. Timed runs also have fixed question caps: 10 questions in 5 minutes, 20 in 10 minutes, and 40 in 20 minutes. Reaching the cap completes the run after feedback. The session clock remains a fallback and moves directly to Results when it expires. Unlimited runs continue until the player chooses to finish; elapsed time and score remain visible.
The selected duration measures active answering time. The session and question clocks run only while an unanswered question is interactive. Both clocks pause during answer feedback, the hint chooser, fun-fact cards, lifecycle interruptions, and backgrounding. This gives the player the full chosen duration for play rather than charging them for reading. The selected duration measures active answering time. The session and question clocks run only while an unanswered question is interactive. Both clocks pause during answer feedback, the hint chooser, fun-fact cards, lifecycle interruptions, and backgrounding. This gives the player the full chosen duration for play rather than charging them for reading.
An interrupted active run is saved locally and can be resumed after process death or relaunch. A `Finish` action is always available in unlimited mode. Finishing after at least five answered questions produces results and maintains the daily streak. Leaving any mode early, or finishing unlimited mode before five answers, is an abandoned run: answered questions still update mastery, but it produces no result, personal best, or daily-streak credit. An interrupted active run is saved locally and can be resumed after process death or relaunch. A `Finish` action is always available in unlimited mode. Finishing after at least five answered questions produces results. Leaving any mode early, or finishing unlimited mode before five answers, is an abandoned run: answered questions still update mastery and daily activity, but it produces no personal best or completed-run credit.
## 7. Question system ## 7. Question system
@@ -239,8 +240,8 @@ Time pressure is central to scoring, but never prevents an answer.
### 8.1 Question timer ### 8.1 Question timer
- Multiple choice starts with a 10-second response window. - Multiple choice starts with a 30-second response window.
- Letter-bank questions start with a 20-second response window. - Letter-bank questions start with a 30-second response window.
- The interface warns visually and haptically near expiration without relying on color alone. - The interface warns visually and haptically near expiration without relying on color alone.
- When the timer reaches zero, the question stays active and the player may still answer. - When the timer reaches zero, the question stays active and the player may still answer.
@@ -316,7 +317,7 @@ Personal bests are recorded separately for each duration and topic configuration
### 11.1 Gentle daily streak ### 11.1 Gentle daily streak
- Finishing any quiz run counts toward the current local calendar day. - Answering at least one question counts toward the current local calendar day, including in a run the player later leaves.
- Multiple runs in one day do not inflate the streak. - Multiple runs in one day do not inflate the streak.
- Missing a day resets the number without guilt-oriented language or loss animation. - Missing a day resets the number without guilt-oriented language or loss animation.
- There are no streak freezes, repairs, purchases, or notifications in V1. - There are no streak freezes, repairs, purchases, or notifications in V1.
@@ -359,7 +360,7 @@ Core traits:
- Supportive after mistakes without excessive praise - Supportive after mistakes without excessive praise
- Never smug about a player's lack of knowledge - Never smug about a player's lack of knowledge
The provided brain-with-glasses reference establishes only the broad character archetype. Final artwork must use an original silhouette, facial design, proportions, clothing/accessories, poses, and rendering style. Its licensing status is unknown and it must not be shipped or traced. The user-supplied `logo-bert.svg` is the canonical launcher and splash artwork. In-app poses preserve Bert's recognizable brain, glasses, and palette while adding state-specific supporting motion that never covers the face.
### 12.2 Art direction ### 12.2 Art direction
@@ -371,9 +372,13 @@ The provided brain-with-glasses reference establishes only the broad character a
### 12.3 Motion ### 12.3 Motion
Compottie animations support state and personality: Animations support state and personality:
- Bert reacts to correct, incorrect, overtime, hint, streak, and milestone events. - Idle uses restrained breathing motion.
- Correct answers use stars behind Bert; overtime adds a clock at bottom right.
- Incorrect answers use a restrained wobble.
- Hints use a thought cloud, while fun facts animate only the bulb rays.
- Results, streaks, and stamps use distinct non-overlapping motifs.
- Answer cards respond immediately to selection. - Answer cards respond immediately to selection.
- Passport stamps land with a short satisfying animation. - Passport stamps land with a short satisfying animation.
- Fun facts can use a restrained entrance and supporting loop. - Fun facts can use a restrained entrance and supporting loop.