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
+493 -187

No files matched your search

@@ -1,17 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#F6E9E1"/>
<g transform="translate(55 85) scale(.66)">
<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"/>
<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"/>
</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"/>
</g>
<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>

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(
isFirstLaunch = state.firstLaunch,
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 },
hasStartedTrivia = !state.firstLaunch,
onStart = controller::start,
@@ -90,8 +90,15 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
)
is Destination.Options -> OptionsScreen(
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,
onStart = controller::startSavedOptions,
onBack = controller::back,
)
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 ->
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(
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 topicChoices(selected: app.bertsatlas.domain.model.QuizTopic) = listOf(
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", state = if (selected == app.bertsatlas.domain.model.QuizTopic.Capitals) AnswerState.Selected else AnswerState.Available),
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(
question: Question,
placements: List<app.bertsatlas.domain.model.LetterPlacement>,
@@ -78,6 +78,7 @@ data class AtlasAppState(
val firstLaunch: Boolean = true,
val preferences: SavedPreferences = SavedPreferences(),
val selectedDuration: QuizDuration = QuizDuration.FiveMinutes,
val selectedTopic: QuizTopic = QuizTopic.MostlyFlags,
val run: QuizRunState? = null,
val result: RunResult? = null,
val newPersonalBest: Boolean = false,
@@ -131,7 +132,16 @@ class AppController(
val mastery = readMastery()
val attempts = database.progressReader().attemptsNow()
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 snapshot = database.progressReader().activeRun()
val restored = snapshot?.let {
@@ -145,6 +155,7 @@ class AppController(
firstLaunch = !hasStartedTrivia,
preferences = preferences,
selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes),
selectedTopic = enumValueOrDefault(preferences.topic, QuizTopic.MostlyFlags),
run = restored,
mastery = mastery,
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) {
when (val destination = _state.value.destination) {
@@ -180,17 +198,31 @@ class AppController(
} else {
val topic = enumValueOrDefault(id, QuizTopic.MostlyFlags)
val duration = _state.value.selectedDuration
_state.value = _state.value.copy(selectedTopic = topic)
scope.launch {
preferencesStore.saveConfiguration(duration.name, topic.name)
val saved = preferencesStore.values.first()
_state.value = _state.value.copy(preferences = saved)
beginRun(QuizConfiguration(duration, topic))
val current = _state.value
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
}
}
fun startSavedOptions() {
val current = _state.value
if (current.destination != Destination.Options(3)) return
beginRun(QuizConfiguration(current.selectedDuration, current.selectedTopic))
}
fun back() { handleBack() }
fun handleBack(): Boolean {
@@ -203,7 +235,11 @@ class AppController(
Destination.Bootstrap, Destination.Home -> false
is Destination.Options -> {
_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
}
@@ -333,16 +369,22 @@ class AppController(
.distinct()
.size,
)
val activityDays = abandoned.attempts.map { it.localEpochDay }.distinct().sorted()
val activityStreak = activityDays.fold(_state.value.streak, StreakRules::recordActivity)
_state.value = _state.value.copy(
destination = Destination.Results,
run = abandoned,
result = summary,
newPersonalBest = false,
streak = activityStreak,
leaveConfirmationRequested = false,
resumeRunAfterLeaveCancellation = false,
)
enqueueRunPersistence {
database.runWriter().abandonRun(abandoned.toRunEntity(summary))
database.runWriter().abandonRun(
abandoned.toRunEntity(summary),
activityStreak.takeIf { activityDays.isNotEmpty() }?.toEntity(),
)
refreshProgress()
}
}
@@ -385,13 +427,15 @@ class AppController(
enqueueRunPersistence {
val before = _state.value.mastery
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 } }
database.runWriter().recordAcceptedAnswer(
attempt.toEntity(json),
changed.map { it.toEntity() },
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)
@Upsert
protected abstract suspend fun upsertStreak(streak: StreakEntity)
abstract suspend fun saveStreak(streak: StreakEntity)
@Upsert
protected abstract suspend fun upsertStamps(stamps: List<StampEntity>)
@@ -319,6 +319,7 @@ abstract class RunWriteDao {
attempt: AttemptEntity,
updatedMastery: List<MasteryEntity>,
snapshot: ActiveRunSnapshotEntity,
streak: StreakEntity,
) {
require(updatedMastery.isNotEmpty()) { "An accepted answer must update at least one mastery item" }
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" }
insertAttempt(attempt.copy(countryIdsJson = countryIds.encodeStringSet()))
upsertMastery(updatedMastery)
saveStreak(streak)
saveActiveRun(snapshot)
}
@@ -346,7 +348,7 @@ abstract class RunWriteDao {
if (existingBest == null || run.score > existingBest.score) {
upsertPersonalBest(PersonalBestEntity(duration, topic, run.score, run.id))
}
upsertStreak(streak)
saveStreak(streak)
if (stamps.isNotEmpty()) upsertStamps(stamps)
clearActiveRun(run.id)
}
@@ -364,7 +366,7 @@ abstract class RunWriteDao {
val existingBest = personalBest(it.duration, it.topic)
if (existingBest == null || it.score > existingBest.score) upsertPersonalBest(it)
}
upsertStreak(streak)
saveStreak(streak)
if (stamps.isNotEmpty()) upsertStamps(stamps)
clearActiveRun(run.id)
}
@@ -387,7 +389,7 @@ abstract class RunWriteDao {
}
@Transaction
open suspend fun abandonRun(runId: String) {
open suspend fun abandonRun(runId: String, streak: StreakEntity? = null) {
upsertRun(
RunEntity(
id = runId,
@@ -404,12 +406,14 @@ abstract class RunWriteDao {
completedAtEpochMillis = null,
),
)
if (streak != null) saveStreak(streak)
clearActiveRun(runId)
}
@Transaction
open suspend fun abandonRun(run: RunEntity) {
open suspend fun abandonRun(run: RunEntity, streak: StreakEntity? = null) {
upsertRun(run.copy(status = "Abandoned", completedAtEpochMillis = null))
if (streak != null) saveStreak(streak)
clearActiveRun(run.id)
}
@@ -520,6 +524,7 @@ class RoomProgressRepository(
attempt: Attempt,
updatedMastery: List<Mastery>,
activeRunSnapshot: ActiveRunSnapshot,
streak: Streak,
) {
require(attempt.countryIds.isNotEmpty())
writer.recordAcceptedAnswer(
@@ -528,6 +533,7 @@ class RoomProgressRepository(
.filter { it.countryId in attempt.countryIds && it.skill == attempt.skill }
.map { it.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))
}
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()
@@ -48,6 +48,7 @@ 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.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.min
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.InternalResourceApi
@@ -77,6 +79,8 @@ fun AtlasScreen(
modifier: Modifier = Modifier,
scrollable: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
headerPadding: PaddingValues = PaddingValues(0.dp),
header: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
Box(
@@ -86,16 +90,41 @@ fun AtlasScreen(
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
val contentModifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.widthIn(max = 720.dp)
.padding(contentPadding)
.let { if (scrollable) it.verticalScroll(rememberScrollState()) else it }
Box(modifier = contentModifier, content = content)
Column(Modifier.fillMaxSize().widthIn(max = 720.dp)) {
header?.let {
Box(Modifier.fillMaxWidth().padding(headerPadding), contentAlignment = Alignment.TopCenter) {
it()
}
}
val contentModifier = Modifier
.fillMaxWidth()
.weight(1f)
.let { base -> if (scrollable) base.verticalScroll(rememberScrollState()) else base }
.padding(contentPadding)
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
fun AtlasText(
text: String,
@@ -204,7 +233,7 @@ fun AtlasChoice(
}
val outline = when (choice.state) {
AnswerState.Available -> colors.outline
AnswerState.Selected -> colors.mustard
AnswerState.Selected -> colors.success
AnswerState.Correct -> colors.success
AnswerState.Incorrect -> colors.orangeAccent
AnswerState.Eliminated -> colors.outline.copy(alpha = .5f)
@@ -334,6 +363,11 @@ fun AtlasToggle(setting: SettingToggleUi, onToggle: (String, Boolean) -> Unit) {
Box(
Modifier.size(width = 54.dp, height = 32.dp)
.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),
contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart,
) {
@@ -366,18 +400,121 @@ fun BertIllustration(
contentDescription: String = "Bert, the curious atlas brain",
reducedMotion: Boolean = AtlasTheme.reducedMotion,
) {
if (reducedMotion) {
StaticBertIllustration(pose, modifier, contentDescription)
return
}
val transition = rememberInfiniteTransition(label = "Bert bulb")
val rayLength by transition.animateFloat(
initialValue = .82f,
targetValue = 1.08f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "Bulb ray length",
val animatedProgress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
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
@@ -385,7 +522,6 @@ private fun StaticBertIllustration(
pose: BertPose,
modifier: Modifier,
contentDescription: String?,
rayLength: Float = 1f,
) {
val colors = AtlasTheme.colors
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)
}
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.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.size
@@ -39,18 +41,23 @@ fun HomeScreen(
onSettings: () -> Unit = {},
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(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
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))
HomeBertIllustration(large = !hasStartedTrivia)
AtlasText(
@@ -65,11 +72,16 @@ fun HomeScreen(
if (hasStartedTrivia) {
AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary)
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) {
AtlasText("✦", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
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)
}
}
@@ -85,35 +97,45 @@ private fun HomeBertIllustration(large: Boolean) {
val size = if (large) 270.dp else 200.dp
val colors = AtlasTheme.colors
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)) {
val w = this.size.width
val h = this.size.height
val outline = 4.dp.toPx()
val map = Path().apply {
moveTo(w * .04f, h * .68f)
lineTo(w * .14f, h * .63f)
lineTo(w * .25f, h * .68f)
lineTo(w * .36f, h * .63f)
lineTo(w * .36f, h * .88f)
lineTo(w * .25f, h * .93f)
lineTo(w * .14f, h * .88f)
lineTo(w * .04f, h * .93f)
moveTo(w * .01f, h * .68f)
lineTo(w * .11f, h * .63f)
lineTo(w * .22f, h * .68f)
lineTo(w * .33f, h * .63f)
lineTo(w * .33f, h * .88f)
lineTo(w * .22f, h * .93f)
lineTo(w * .11f, h * .88f)
lineTo(w * .01f, h * .93f)
close()
}
drawPath(map, colors.paper)
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 * .25f, h * .68f), Offset(w * .25f, h * .93f), outline)
drawLine(colors.orangeAccent, Offset(w * .06f, h * .83f), Offset(w * .34f, h * .76f), outline * .7f, StrokeCap.Round)
drawLine(colors.plum, Offset(w * .11f, h * .63f), Offset(w * .11f, h * .88f), outline)
drawLine(colors.plum, Offset(w * .22f, h * .68f), Offset(w * .22f, h * .93f), outline)
drawLine(colors.orangeAccent, Offset(w * .03f, h * .83f), Offset(w * .31f, h * .76f), outline * .7f, StrokeCap.Round)
}
}
}
@Composable
private fun PointsBadge(points: Int, modifier: Modifier = Modifier) {
private fun PointsBadge(points: Int, onClick: () -> Unit, modifier: Modifier = Modifier) {
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)) {
drawCircle(colors.mustard, size.minDimension * .40f)
drawCircle(colors.plum, size.minDimension * .40f, style = Stroke(2.4.dp.toPx()))
@@ -148,28 +170,50 @@ private fun SettingsIconButton(onClick: () -> Unit) {
fun OptionsScreen(
step: Int,
choices: List<ChoiceUi>,
durationSummary: String,
topicSummary: String,
onChoose: (String) -> Unit,
onStart: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
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)) {
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) }
if (step <= 2) {
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) }
} 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",
overtimeMessage: String = "Time's up. Keep going for half points",
) {
AtlasScreen(modifier, contentPadding = PaddingValues(0.dp)) {
Column(Modifier.fillMaxWidth()) {
runProgress?.let { SubtleRunProgress(it, runProgressDescription) }
Column(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(Modifier.fillMaxWidth().heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) {
QuizMetric("Score", score.toString(), Modifier.weight(1f))
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
AtlasExitButton(onLeave)
}
if (timerExpired) {
AtlasText(overtimeMessage, Modifier.fillMaxWidth(), color = AtlasTheme.colors.error, style = AtlasTheme.typography.label, textAlign = TextAlign.Center)
AtlasScreen(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
header = {
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
runProgress?.let { SubtleRunProgress(it, runProgressDescription) }
Row(
Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 20.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
QuizMetric("Score", score.toString(), Modifier.weight(1f))
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
AtlasExitButton(onLeave)
}
if (timerExpired) {
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) {
Box(
Modifier.fillMaxWidth().heightIn(min = 148.dp)
@@ -104,7 +116,6 @@ fun QuizScreen(
AtlasButton("Get a hint", onHint, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = hintAvailable)
if (unlimited) AtlasButton("Finish", onFinish, Modifier.weight(1f), AtlasButtonStyle.Quiet)
}
}
}
}
}
@@ -240,9 +251,8 @@ fun HintChooserScreen(
introText: String = "Choose the clue that sounds most useful.",
chargedHintLabel: String = "−25 points",
) {
AtlasScreen(modifier) {
AtlasScreenScaffold("Choose a hint", onCancel, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
ScreenTopBar("Choose a hint", onCancel)
BertIllustration(BertPose.Hint, Modifier.size(150.dp))
AtlasHeading("A little nudge?", Modifier.fillMaxWidth(), centered = true)
AtlasText(introText, textAlign = TextAlign.Center, color = AtlasTheme.colors.mutedPlum)
@@ -47,7 +47,7 @@ fun ResultsScreen(
)
if (abandoned) {
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(),
color = AtlasTheme.colors.mutedPlum,
textAlign = TextAlign.Center,
@@ -92,14 +92,28 @@ fun ProgressScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
AtlasScreenScaffold("Your atlas", onBack, 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)
AtlasText(
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())))
@@ -30,10 +30,8 @@ fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
AtlasScreenScaffold("Settings", onBack, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
ScreenTopBar("Settings", onBack)
SectionGap()
AtlasCard {
settings.forEach { AtlasToggle(it, onToggle) }
}
@@ -69,9 +67,8 @@ fun ResetConfirmationScreen(
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
AtlasScreenScaffold("Reset local data", onCancel, 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.")
@@ -103,9 +100,8 @@ fun CreditsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
AtlasScreenScaffold("Credits and licenses", onBack, 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)
@@ -123,9 +119,8 @@ fun CreditsScreen(
@Composable
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)) {
ScreenTopBar(title, onBack)
paragraphs.forEach { AtlasText(it) }
}
}
@@ -141,9 +136,8 @@ fun DebugPanelScreen(
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
AtlasScreen(modifier) {
AtlasScreenScaffold("Debug QA", onClose, modifier) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
ScreenTopBar("Debug QA", onClose)
AtlasCard {
AtlasText("Build $buildVersion", style = AtlasTheme.typography.label)
AtlasText("Catalog $catalogVersion")