From 26f1effb4ff0b8d8c07b0a33092fc3659e6225e1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 13:05:18 +0200 Subject: [PATCH] feat(worldclock): the tab, the picker, and the face that answers at a glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hero face for home over a list of cities, each carrying its time, its offset and how many days apart it is from you. The dial morphs between a circle and a sun with the hour *there*: the one showpiece M0 reserved, spent on information rather than decoration — a glance says "it is the middle of the night for them" with no sentence needed. The picker searches the device's own zones by word prefix. The list reorders by drag **and** by move-up/move-down actions, because a drag-only reorder is unreachable by a screen reader; each row reads as one sentence rather than three texts in child order. Home is set by hand or left following the device. At the twenty-fourth city the refusal is written on the screen, not only in the content description. --- .../clockula/ui/WorldClockInstrumentedTest.kt | 141 ++++++ .../clockula/ui/common/AlarmTimeFormatter.kt | 19 +- .../ui/worldclock/WorldClockDefaults.kt | 15 + .../clockula/ui/worldclock/WorldClockFace.kt | 153 +++++++ .../clockula/ui/worldclock/WorldClockRow.kt | 180 ++++++++ .../ui/worldclock/WorldClockRowState.kt | 115 +++++ .../ui/worldclock/WorldClockScreen.kt | 257 ++++++++++- .../ui/worldclock/WorldClockSource.kt | 133 ++++++ .../ui/worldclock/WorldClockViewModel.kt | 141 ++++++ .../ui/worldclock/ZonePickerScreen.kt | 204 +++++++++ .../clockula/ui/worldclock/ZonePickerState.kt | 70 +++ app/src/main/res/values/strings.xml | 40 ++ .../clockula/testing/WorldClockHarness.kt | 87 ++++ .../ui/worldclock/WorldClockRowsTest.kt | 246 +++++++++++ .../ui/worldclock/WorldClockViewModelTest.kt | 418 ++++++++++++++++++ .../ui/worldclock/ZonePickerRowsTest.kt | 184 ++++++++ 16 files changed, 2394 insertions(+), 9 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/WorldClockInstrumentedTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerState.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerRowsTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/WorldClockInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/WorldClockInstrumentedTest.kt new file mode 100644 index 0000000..ee70c42 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/WorldClockInstrumentedTest.kt @@ -0,0 +1,141 @@ +package de.jeanlucmakiola.clockula.ui + +import android.content.Context +import android.content.Intent +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.domain.WorldClock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * §5.11 — 4 cases. What only a device can answer about the World clock tab: + * that the `Canvas` face really composes and its semantics really reach + * TalkBack, that the picker's real `Dialog` and real IME really add a city, and + * that the two accessibility actions D13 exists for really reorder and remove a + * row without a drag. + * + * Compiled in the gate; **not run here** — no device is attached (§5.11). + */ +@RunWith(AndroidJUnit4::class) +class WorldClockInstrumentedTest { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Seams { + fun worldClocks(): WorldClockRepository + } + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + private val seams: Seams = + EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java) + + private var scenario: ActivityScenario? = null + + @After + fun tearDown() { + scenario?.close() + runBlocking { stored().forEach { seams.worldClocks().remove(it.id) } } + } + + private fun string(id: Int): String = context.getString(id) + + private fun stored(): List = runBlocking { seams.worldClocks().worldClocks().first() } + + private fun given(vararg zoneIds: String) { + runBlocking { zoneIds.forEach { seams.worldClocks().add(it) } } + } + + private fun openWorldClockTab() { + ActivityScenario.launch( + Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ).also { scenario = it } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(string(R.string.tab_world_clock)).performClick() + composeTestRule.waitForIdle() + } + + /** TalkBack's own route into a row: the custom action, by its label. */ + private fun SemanticsNodeInteraction.performAccessibilityAction(label: String) { + val action = fetchSemanticsNode().config[SemanticsActions.CustomActions] + .first { it.label == label } + composeTestRule.runOnUiThread { action.action() } + composeTestRule.waitForIdle() + } + + /** §5.11 #1 */ + @Test + fun theHeroFaceComposesAndNamesTheHomeCityToTalkBack() { + openWorldClockTab() + + val home = java.time.ZoneId.systemDefault().id.substringAfterLast('/') + + composeTestRule.onNodeWithContentDescription(home, substring = true).assertExists() + } + + /** §5.11 #2 */ + @Test + fun addingACityThroughThePickerPutsItOnTheTab() { + openWorldClockTab() + + composeTestRule.onNodeWithText(string(R.string.world_clock_add)).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(string(R.string.zone_picker_search)).performTextInput("Tokyo") + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Tokyo").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Tokyo").assertExists() + assertThat(stored().map { it.zoneId }).contains("Asia/Tokyo") + } + + /** §5.11 #3 */ + @Test + fun theRemoveAccessibilityActionTakesTheCityOffTheTab() { + given("Asia/Tokyo") + openWorldClockTab() + + composeTestRule.onNodeWithText("Tokyo") + .performAccessibilityAction(string(R.string.world_clock_row_remove)) + composeTestRule.onNodeWithText(string(R.string.action_remove)).performClick() + composeTestRule.waitForIdle() + + assertThat(stored()).isEmpty() + } + + /** §5.11 #4 */ + @Test + fun theMoveUpAccessibilityActionReordersWithoutADrag() { + given("Europe/Berlin", "Asia/Tokyo") + openWorldClockTab() + + composeTestRule.onNodeWithText("Tokyo") + .performAccessibilityAction(string(R.string.world_clock_row_move_up)) + + assertThat(stored().map { it.zoneId }).containsExactly("Asia/Tokyo", "Europe/Berlin").inOrder() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index 263056f..bf77123 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -12,11 +12,13 @@ import java.time.format.FormatStyle /** * Honours the user's 12/24-hour setting. Deliberately not unit-tested: it is - * one platform call. Shared by the ring screen, the alarm list and the editor, - * so all three read a time the same way (M5). + * one platform call. The app's **one** time formatter — the ring screen, the + * alarm list, the editor and the world clock all read a time through it, so a + * second `DateFormat.is24HourFormat` call site would be the start of two + * answers (M5, M8 D17). */ @Composable -internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String { +internal fun rememberLocalTimeFormatter(): (LocalTime) -> String { val context = LocalContext.current // The locale can change under a running activity, and the 12/24-hour // choice rides the same configuration, so both are re-read with it. @@ -31,6 +33,15 @@ internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String { } } return remember(formatter) { - { time -> formatter.format(LocalTime.of(time.hour, time.minute)) } + { time -> formatter.format(time) } + } +} + +/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */ +@Composable +internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String { + val formatLocalTime = rememberLocalTimeFormatter() + return remember(formatLocalTime) { + { time -> formatLocalTime(LocalTime.of(time.hour, time.minute)) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt new file mode 100644 index 0000000..cb23809 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt @@ -0,0 +1,15 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal object WorldClockDefaults { + /** 200 ms — the second hand is never a fifth of a second behind (D18). */ + val Tick: Duration = 200.milliseconds + + val HeroFaceSize: Dp = 200.dp + + val ListBottomPadding: Dp = 8.dp +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt new file mode 100644 index 0000000..674d3fa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt @@ -0,0 +1,153 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.toPath +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph +import de.jeanlucmakiola.clockula.domain.worldclock.AnalogFace +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import java.time.LocalTime + +/** + * The app's one deliberate showpiece: a `MaterialShapes.Circle`↔`Sunny` dial + * whose morph progress is [dayFraction], twelve hour marks and three hands. + * + * The morph carries **information**, which is the whole reason it is here + * (`docs/PLAN.md` §8, D5): a plain circle in the middle of the night there, a + * sun at midday. "Is it the middle of the night where they are" is precisely + * the question a world clock exists to answer, and answering it with shape + * rather than a third line of text is what "refinement comes from shape, + * colour, space and motion" means. M7 refused a decorative arc on the stopwatch + * for the mirror-image reason: there, there was nothing to be a fraction of. + * + * Scheme tokens only, no literal colour. The semantics are set outright — an + * analog face has nothing a screen reader can read off it — and deliberately + * **not** a live region: at 1 Hz it would recite the clock for as long as the + * tab was open, the mistake M4 already refused for the live pill. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun WorldClockFace( + time: LocalTime, + dayFraction: Float, + contentDescription: String, + modifier: Modifier = Modifier, +) { + val dialColor = MaterialTheme.colorScheme.secondaryContainer + val markColor = MaterialTheme.colorScheme.onSecondaryContainer + val accentColor = MaterialTheme.colorScheme.primary + val reduceMotion = rememberReduceMotion() + + // Built once and the path re-derived into a reused Path per frame: the + // morph itself is geometry, not state, and allocating one a frame would be + // sixty throwaway objects a second. + val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Sunny) } + val dialPath = remember { Path() } + val dialMatrix = remember { Matrix() } + + // Reduce motion touches the morph, not the hands: the second hand is a + // discrete position change carrying information, and a clock whose second + // hand stops is a broken clock (D19). + val progress by animateFloatAsState( + targetValue = dayFraction, + animationSpec = if (reduceMotion) { + snap() + } else { + MaterialTheme.motionScheme.slowSpatialSpec() + }, + label = "dayNight", + ) + + Canvas( + modifier = modifier + .size(WorldClockDefaults.HeroFaceSize) + .clearAndSetSemantics { this.contentDescription = contentDescription }, + ) { + drawDial(morph, progress, dialPath, dialMatrix, dialColor) + drawTicks(markColor) + drawHands(time, markColor, accentColor) + } +} + +/** + * The morphing dial, fitted onto the face. The shape library works in its own + * normalised space, so the path is measured and mapped rather than assumed — + * a morph's bounds move as it travels between two shapes, and a hard-coded + * scale would make the sun breathe. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun DrawScope.drawDial(morph: Morph, progress: Float, path: Path, matrix: Matrix, color: Color) { + path.rewind() + morph.toPath(progress, path) + val bounds = path.getBounds() + val extent = maxOf(bounds.width, bounds.height) + if (extent <= 0f) return + matrix.reset() + matrix.translate(center.x, center.y) + matrix.scale(size.minDimension / extent, size.minDimension / extent) + matrix.translate(-bounds.center.x, -bounds.center.y) + path.transform(matrix) + drawPath(path, color) +} + +private fun DrawScope.drawTicks(color: Color) { + val radius = size.minDimension / 2f + val width = 2.dp.toPx() + AnalogFace.tickAngles.forEachIndexed { index, angle -> + val length = if (AnalogFace.isQuarterTick(index)) radius * 0.14f else radius * 0.08f + val outer = radius * 0.86f + rotate(angle) { + drawLine( + // Reduced alpha: the marks orient the eye, the hands carry the + // reading, and both in full strength would fight. + color = color.copy(alpha = if (AnalogFace.isQuarterTick(index)) 0.7f else 0.4f), + start = center + Offset(0f, -outer), + end = center + Offset(0f, -outer + length), + strokeWidth = width, + cap = StrokeCap.Round, + ) + } + } +} + +private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) { + val radius = size.minDimension / 2f + hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor) + hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor) + hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) + drawCircle(accentColor, radius = 4.dp.toPx(), center = center) + drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx())) +} + +private fun DrawScope.hand(angle: Float, length: Float, width: Float, color: Color) { + rotate(angle) { + drawLine( + color = color, + // A short tail past the centre, as a real hand is balanced. + start = center + Offset(0f, length * 0.12f), + end = center + Offset(0f, -length), + strokeWidth = width, + cap = StrokeCap.Round, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt new file mode 100644 index 0000000..3392326 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt @@ -0,0 +1,180 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel +import de.jeanlucmakiola.clockula.domain.worldclock.OffsetLabel +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.Position +import java.time.LocalTime + +/** + * One city inside a `GroupedSurface` at `ReorderableRowHeight` — a bespoke + * `Row` rather than a `GroupedRow`, for the same reason M7's `LapRow` is: + * `ListItem` imposes its own 72dp two-line minimum and would fight the + * reorderable column's 64dp pitch, and the row already needs a drag handle in + * the leading slot (D14). + * + * A drag-only reorder is inaccessible — TalkBack cannot press-and-drag and + * neither can a keyboard — so the row carries **Move up**, **Move down** and + * **Remove** as custom accessibility actions alongside the handle (D13). + * + * A row whose zone the device's tzdata no longer knows shows no time, no + * offset and no day: it is kept and shown as broken, never rewritten into + * something the app invented. + */ +@Composable +internal fun WorldClockRow( + row: WorldClockRowState, + position: Position, + formatTime: (LocalTime) -> String, + dragHandle: Modifier, + isDragging: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val summary = summaryFor(row) + val time = row.time?.let(formatTime) + // One sentence for TalkBack, in the order the row is read aloud in. A row + // whose zone is gone has no time, and its summary already *is* the + // unknown-zone sentence, so naming it twice would be a stutter. + val description = if (time != null) { + stringResource(R.string.world_clock_row_description, row.name, time, summary) + } else { + stringResource(R.string.world_clock_row_description_unknown, row.name, summary) + } + val actions = listOf( + CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_up)) { + onMoveUp() + true + }, + CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_down)) { + onMoveDown() + true + }, + CustomAccessibilityAction(stringResource(R.string.world_clock_row_remove)) { + onRemove() + true + }, + ) + + GroupedSurface( + position = position, + // The column owns the pitch: a gap here would put every row half a + // pixel out of step with the drag maths. + gapBelow = false, + onLongClick = onRemove, + modifier = modifier.padding(horizontal = GroupedListInset), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 20.dp) + .semantics(mergeDescendants = true) { + // Spoken as one sentence rather than as three merged child + // texts in child order — and it is the only place the + // unknown-zone fallback is announced at all. + contentDescription = description + customActions = actions + }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.world_clock_row_drag, row.name), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = dragHandle.size(24.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (time != null) { + Text( + text = time, + // Tabular, because M7 made the headline role tabular + // app-wide: a column of times must not jitter as it ticks. + style = MaterialTheme.typography.headlineSmall, + color = if (isDragging) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } + } +} + +/** + * The offset, and the day only when it differs: "Same time as home" stands + * alone, and "Today" after it would be a sentence saying nothing twice. + */ +@Composable +private fun summaryFor(row: WorldClockRowState): String { + val offset = row.offset ?: return stringResource(R.string.world_clock_unknown_zone) + val offsetText = when (offset) { + OffsetLabel.Same -> stringResource(R.string.world_clock_offset_same) + is OffsetLabel.Ahead -> stringResource(R.string.world_clock_ahead, magnitudeOf(offset.hours, offset.minutes)) + is OffsetLabel.Behind -> stringResource(R.string.world_clock_behind, magnitudeOf(offset.hours, offset.minutes)) + } + val day = row.day ?: DayLabel.TODAY + if (day == DayLabel.TODAY) return offsetText + return stringResource(R.string.world_clock_summary, offsetText, dayLabel(day)) +} + +/** Hours and minutes as words, so Kathmandu reads "4 hours 45 minutes", not "285". */ +@Composable +private fun magnitudeOf(hours: Int, minutes: Int): String { + val hourText = pluralStringResource(R.plurals.world_clock_hours, hours, hours) + val minuteText = pluralStringResource(R.plurals.world_clock_minutes, minutes, minutes) + return when { + minutes == 0 -> hourText + hours == 0 -> minuteText + else -> stringResource(R.string.world_clock_hours_minutes, hourText, minuteText) + } +} + +@Composable +private fun dayLabel(day: DayLabel): String = when (day) { + DayLabel.YESTERDAY -> stringResource(R.string.world_clock_day_yesterday) + DayLabel.TODAY -> stringResource(R.string.world_clock_day_today) + DayLabel.TOMORROW -> stringResource(R.string.world_clock_day_tomorrow) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt new file mode 100644 index 0000000..fce2531 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt @@ -0,0 +1,115 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.isKnownZone +import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel +import de.jeanlucmakiola.clockula.domain.worldclock.DayNight +import de.jeanlucmakiola.clockula.domain.worldclock.OffsetLabel +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneCatalog +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneComparisons +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat +import java.time.LocalTime +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlin.time.Instant +import kotlin.time.toJavaInstant + +internal data class WorldClockRowState( + val id: Long, + val zoneId: String, + /** The user's label when set, else the ICU city, else the id's last segment. Never blank. */ + val name: String, + /** Truncated to the minute. Non-null **exactly** when [known]. */ + val time: LocalTime?, + /** Non-null exactly when [known]. */ + val offset: OffsetLabel?, + /** Non-null exactly when [known]. */ + val day: DayLabel?, + /** ICU's long zone name; null when ICU has none. Content description only. */ + val zoneName: String?, + /** False for a zone id the device's tzdata no longer knows. */ + val known: Boolean, +) + +internal data class HomeFaceState( + val zoneId: String, + val city: String, + val zoneName: String?, + /** Truncated to the second. */ + val time: LocalTime, + /** 0f night … 1f day — what the dial's shape morph carries. */ + val dayFraction: Float, +) + +internal sealed interface WorldClockUiState { + /** Before the first emission. Not "empty". */ + data object Loading : WorldClockUiState + + data class Loaded( + val home: HomeFaceState, + /** In the repository's order — `sort_order`, then `id`. Never re-sorted here. */ + val rows: List, + val canAdd: Boolean, + ) : WorldClockUiState +} + +internal object WorldClockRows { + + /** + * Pure; never throws. An id absent from [entries] falls back to + * `ZoneCatalog.cityFallbackFor`, and a zone the device's tzdata no longer + * knows keeps its row with all three readings blank rather than being + * rewritten into something the app invented. + * + * The time is truncated to the **minute**, which is what makes the 200 ms + * tick cheap: four ticks in five change nothing a row can show. + */ + fun from( + clocks: List, + homeZone: ZoneId, + now: Instant, + entries: Map, + zoneNames: Map, + ): List = clocks.map { clock -> + val known = clock.isKnownZone + val comparison = if (known) { + ZoneComparisons.compare(homeZone, ZoneId.of(clock.zoneId), now) + } else { + null + } + WorldClockRowState( + id = clock.id, + zoneId = clock.zoneId, + name = clock.label ?: cityOf(clock.zoneId, entries), + time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(ChronoUnit.MINUTES), + offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) }, + day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) }, + zoneName = zoneNames[clock.zoneId], + known = known, + ) + } + + /** Pure; never throws. */ + fun home( + homeZone: ZoneId, + now: Instant, + entries: Map, + zoneNames: Map, + ): HomeFaceState { + // To the second, not the millisecond: the second hand steps, so a state + // that carried more precision would differ on every tick and change + // nothing anybody could see. + val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(ChronoUnit.SECONDS) + return HomeFaceState( + zoneId = homeZone.id, + city = cityOf(homeZone.id, entries), + zoneName = zoneNames[homeZone.id], + time = local, + dayFraction = DayNight.fractionAt(local), + ) + } + + private fun cityOf(zoneId: String, entries: Map): String = + entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index 7f89607..a560eed 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -1,17 +1,264 @@ package de.jeanlucmakiola.clockula.ui.worldclock +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R -import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen +import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks +import de.jeanlucmakiola.clockula.ui.common.rememberLocalTimeFormatter +import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.ReorderableColumn +import java.time.LocalTime -/** A top-level tab: its title bar and its empty state. The zone list is M8's. */ +/** + * The World clock tab: a hero analog face for **home**, then the cities, each + * with its offset and day relative to home. + * + * The face is the home readout and the digital time is deliberately not + * repeated under it — an analog face that also prints its own time is a face + * nobody looks at (D11). The caption is where the ICU long zone name is shown, + * which is the one place it earns its length. + * + * `scrollable` stays true: `ReorderableColumn` is not lazy and cannot live + * inside a `LazyColumn`, and a list capped at twenty-four rows is exactly the + * shape it was written for (D12). + */ @Composable -fun WorldClockScreen(modifier: Modifier = Modifier) { - EmptyTabScreen( +fun WorldClockScreen( + modifier: Modifier = Modifier, + viewModel: WorldClockViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val picker by viewModel.picker.collectAsStateWithLifecycle() + val pillInset = LocalLivePillInset.current + val formatTime = rememberLocalTimeFormatter() + val loaded = state as? WorldClockUiState.Loaded + + // Saveable, not remembered: a rotation mid-confirmation must not lose the + // question it was asking (D13). + var pendingRemoval by rememberSaveable { mutableStateOf(null) } + + val addLabel = stringResource(R.string.world_clock_add) + val limitLabel = stringResource(R.string.world_clock_limit, WorldClocks.MAX) + + CollapsingScaffold( title = stringResource(R.string.tab_world_clock), - message = stringResource(R.string.world_clocks_empty), modifier = modifier, + actions = { + IconButton(onClick = viewModel::onOpenHomePicker) { + Icon( + imageVector = Icons.Rounded.Home, + contentDescription = stringResource(R.string.world_clock_home_action), + ) + } + }, + floatingActionButton = { + val canAdd = loaded?.canAdd != false + ExtendedFloatingActionButton( + onClick = { if (canAdd) viewModel.onOpenAddPicker() }, + // The pill is bottom-centre and the FAB bottom-end, so on a + // narrow phone they would otherwise overlap (M5 D6). + modifier = Modifier + .padding(bottom = pillInset) + // Past the cap the FAB says why rather than going missing: + // a control that vanishes is a control nobody can ask about. + .semantics { if (!canAdd) contentDescription = limitLabel }, + ) { + Icon(Icons.Rounded.Add, contentDescription = null) + Text(addLabel, modifier = Modifier.padding(start = 8.dp)) + } + }, + ) { + if (loaded != null) { + HomeFace(home = loaded.home, formatTime = formatTime) + + if (loaded.rows.isEmpty()) { + EmptyWorldClocks() + } else { + ReorderableColumn( + items = loaded.rows, + keyOf = { it.id }, + onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) }, + modifier = Modifier.fillMaxWidth(), + ) { row, position, dragHandle, isDragging -> + WorldClockRow( + row = row, + position = position, + formatTime = formatTime, + dragHandle = dragHandle, + isDragging = isDragging, + onMoveUp = { viewModel.onMoveUp(row.id) }, + onMoveDown = { viewModel.onMoveDown(row.id) }, + onRemove = { pendingRemoval = row.id }, + ) + } + } + + // Past the cap the FAB refuses, and a refusal only a screen reader + // can hear is a dead button to everybody else. + if (!loaded.canAdd) { + LimitNote(label = limitLabel) + } + } + Spacer(Modifier.height(WorldClockDefaults.ListBottomPadding + FabBand + pillInset)) + } + + picker?.let { open -> + ZonePickerScreen( + state = open, + onQueryChange = viewModel::onQueryChange, + onPickZone = viewModel::onPickZone, + onPickDeviceZone = viewModel::onPickDeviceZone, + onDismiss = viewModel::onDismissPicker, + ) + } + + // The cheap moment to ask is before, so there is no undo chip — the same + // trade M5 and M6 made for alarms and timers (D13). + val removing = pendingRemoval?.let { id -> loaded?.rows?.firstOrNull { it.id == id } } + if (removing != null) { + AlertDialog( + onDismissRequest = { pendingRemoval = null }, + title = { Text(stringResource(R.string.world_clock_remove_title, removing.name)) }, + text = { Text(stringResource(R.string.world_clock_remove_message)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.onRemove(removing.id) + pendingRemoval = null + }, + ) { + Text(stringResource(R.string.action_remove)) + } + }, + dismissButton = { + TextButton(onClick = { pendingRemoval = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + WorldClockFace( + time = home.time, + dayFraction = home.dayFraction, + contentDescription = stringResource( + R.string.world_clock_face_description, + home.city, + formatTime(home.time), + ), + modifier = Modifier.padding(bottom = 12.dp), + ) + // The face has no other label saying whose time it is, and a city here + // is otherwise indistinguishable from a city in the list below. + Text( + text = stringResource(R.string.world_clock_home), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = home.city, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + // The one place the ICU long zone name earns its length. + home.zoneName?.let { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +/** A real empty state: nothing is seeded, so a fresh install really has no rows. */ +@Composable +private fun EmptyWorldClocks() { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.world_clocks_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.world_clock_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The cap, in words a sighted user can read. M3's `ExtendedFloatingActionButton` + * has no `enabled` parameter to dim the FAB with, so the reason the Add button + * does nothing is said on the screen as well as in its content description + * (D12). + */ +@Composable +private fun LimitNote(label: String) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset, vertical = 12.dp), ) } + +/** What the extended FAB keeps clear below the last row. */ +private val FabBand = 72.dp diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt new file mode 100644 index 0000000..f77254a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt @@ -0,0 +1,133 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.data.zones.ZoneDirectory +import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.time.Ticker +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider +import de.jeanlucmakiola.clockula.domain.worldclock.HomeZone +import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +/** Repository + prefs + directory + clocks + ticker → the tab's state. */ +@Singleton +internal class WorldClockSource @Inject constructor( + private val worldClocks: WorldClockRepository, + private val settings: SettingsPrefs, + private val directory: ZoneDirectory, + private val zones: ZoneProvider, + private val wallClock: WallClock, + private val ticker: Ticker, +) { + /** What the picker needs before it can draw a row; null while it is still being built. */ + private data class Catalog(val entries: List, val deviceCity: String) + + private val storedHomeZoneId: Flow = + settings.defaults.map { it.homeZoneId }.distinctUntilChanged() + + /** + * The tab's state. Both the instant and the device zone are re-read on + * **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED` + * free: the tab self-heals within one tick and `SystemEventReceiver` needs + * no new branch (D2). + * + * The cost of a 200 ms cadence is paid back by the state being stable — + * the face is truncated to the second and the rows to the minute — so + * `distinctUntilChanged` collapses five emissions a second into about one + * (D18). + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun state(): Flow = + combine(worldClocks.worldClocks(), storedHomeZoneId, ::Pair) + .flatMapLatest { (clocks, storedHome) -> + ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome) } + } + .distinctUntilChanged() + + /** + * The picker's state for [mode], re-derived when [query] or the stored list + * changes. `loading` is true until `ZoneDirectory.entries()` lands — the + * catalog is ~450 ids and three ICU calls each, and it is built the first + * time a picker opens rather than when the tab does (D4). + */ + fun pickerState(mode: ZonePickerMode, query: Flow): Flow { + // Read once, at the instant the picker opened: a ticking picker is a + // ticking picker, and the GMT labels would all move together anyway. + val openedAt = wallClock.now() + val deviceZone = zones.current() + val catalog: Flow = flow { + emit(null) + emit(Catalog(directory.entries(), directory.entryFor(deviceZone.id).city)) + } + return combine( + catalog, + query, + worldClocks.worldClocks(), + storedHomeZoneId, + ) { built, text, clocks, storedHome -> + ZonePickerUiState( + mode = mode, + query = text, + loading = built == null, + rows = built?.let { + ZonePickerRows.from( + mode = mode, + entries = it.entries, + query = text, + now = openedAt, + selectedZoneIds = selectedZoneIds(mode, clocks, storedHome, deviceZone), + deviceCity = it.deviceCity, + // Nothing stored means home follows the device, which is + // a selection of its own rather than an absence. + deviceZoneSelected = storedHome == null, + ) + }.orEmpty(), + ) + } + } + + private suspend fun build( + clocks: List, + storedHome: String?, + ): WorldClockUiState.Loaded { + val now = wallClock.now() + val homeZone = HomeZone.resolve(storedHome, zones.current()) + val zoneIds = (clocks.map { it.zoneId } + homeZone.id).distinct() + // Both memos live inside the directory, keyed on the locale it reports: + // a cache here would survive a per-app language change and keep every + // city in the old language while the ICU zone name switched. Both are + // asked in one batch, so a tick costs two dispatches, not two per row. + val entries = directory.entriesFor(zoneIds) + val zoneNames = directory.displayNamesOf(zoneIds, now) + return WorldClockUiState.Loaded( + home = WorldClockRows.home(homeZone, now, entries, zoneNames), + rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames), + canAdd = clocks.size < WorldClocks.MAX, + ) + } + + private fun selectedZoneIds( + mode: ZonePickerMode, + clocks: List, + storedHome: String?, + deviceZone: ZoneId, + ): Set = when (mode) { + ZonePickerMode.ADD -> clocks.mapTo(mutableSetOf()) { it.zoneId } + // Only an explicitly stored home checks a row; following the device + // checks the device row instead. + ZonePickerMode.HOME -> + storedHome?.let { setOf(HomeZone.resolve(it, deviceZone).id) }.orEmpty() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt new file mode 100644 index 0000000..3a6f516 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt @@ -0,0 +1,141 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.domain.Zones +import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The tab's state, the picker's state and every verb. There is **no engine**: + * a world clock schedules nothing, wakes nothing and rings nothing, so each + * verb is a plain repository or preference write with nothing to actuate + * afterwards (D1). + */ +@HiltViewModel +class WorldClockViewModel @Inject internal constructor( + private val worldClocks: WorldClockRepository, + private val settings: SettingsPrefs, + private val source: WorldClockSource, +) : ViewModel() { + + /** Null while the picker is closed; the mode it was opened in while it is open. */ + private val mode = MutableStateFlow(null) + + private val query = MutableStateFlow("") + + /** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */ + internal val state: StateFlow = source.state() + // `WhileSubscribed`, not `Eagerly`: the upstream is a 200 ms ticker and + // eager sharing would keep it running with nobody looking. The timeout + // is what gives rotation its resilience, as on every other tab. + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), + WorldClockUiState.Loading, + ) + + /** + * Null while the picker is closed. Shared eagerly, because while it is + * closed the upstream is a single `null` and costs nothing — and when it + * opens the catalog build should already be under way rather than waiting + * for the dialog's first composition. + */ + @OptIn(ExperimentalCoroutinesApi::class) + internal val picker: StateFlow = mode + .flatMapLatest { open -> if (open == null) flowOf(null) else source.pickerState(open, query) } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun onOpenAddPicker() = openPicker(ZonePickerMode.ADD) + + fun onOpenHomePicker() = openPicker(ZonePickerMode.HOME) + + /** Closes the picker and clears the query, so re-opening starts blank. */ + fun onDismissPicker() { + mode.value = null + query.value = "" + } + + fun onQueryChange(query: String) { + this.query.value = query + } + + /** + * ADD: adds the zone, idempotently, and refuses past [WorldClocks.MAX]. + * HOME: stores it as `home_zone_id`. Both close the picker. A blank or + * non-IANA id writes nothing and does not throw. + */ + fun onPickZone(zoneId: String) { + val open = mode.value ?: return + val normalised = Zones.normalise(zoneId) + onDismissPicker() + if (normalised == null) return + viewModelScope.launch { + when (open) { + ZonePickerMode.ADD -> addZone(normalised) + ZonePickerMode.HOME -> settings.setHomeZoneId(normalised) + } + } + } + + /** HOME only: clears `home_zone_id`, so home follows the device again. */ + fun onPickDeviceZone() { + onDismissPicker() + viewModelScope.launch { settings.setHomeZoneId(null) } + } + + fun onRemove(id: Long) { + viewModelScope.launch { worldClocks.remove(id) } + } + + fun onMoveUp(id: Long) = move(id, WorldClocks::moveUp) + + fun onMoveDown(id: Long) = move(id, WorldClocks::moveDown) + + /** From the drag. Writes the order it is given, through `WorldClockRepository.reorder`. */ + fun onReorder(idsInOrder: List) { + viewModelScope.launch { worldClocks.reorder(idsInOrder) } + } + + private fun openPicker(open: ZonePickerMode) { + query.value = "" + mode.value = open + } + + /** + * The cap is enforced here rather than in the repository: a zone the tab + * already holds is not a twenty-fifth city, and `add` is idempotent on + * `zone_id`, so re-picking one must keep working at the limit. + */ + private suspend fun addZone(zoneId: String) { + val stored = worldClocks.worldClocks().first() + if (stored.size >= WorldClocks.MAX && stored.none { it.zoneId == zoneId }) return + worldClocks.add(zoneId) + } + + /** A move that changes nothing writes nothing — a no-op is not a reorder. */ + private fun move(id: Long, moved: (List, Long) -> List) { + viewModelScope.launch { + val ids = worldClocks.worldClocks().first().map { it.id } + val reordered = moved(ids, id) + if (reordered != ids) worldClocks.reorder(reordered) + } + } + + private companion object { + /** The window a rotation has to re-subscribe in before the ticker is dropped. */ + const val SUBSCRIPTION_GRACE_MS = 5_000L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerScreen.kt new file mode 100644 index 0000000..a0a0c8a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerScreen.kt @@ -0,0 +1,204 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf + +/** + * The zone picker: `FullScreenPicker(scrollable = false)` over a `LazyColumn`, + * M5's ringtone-picker precedent and for the same reason — the catalog is ~450 + * rows (D15). + * + * The search field is the family's idiom, not a Material outlined field: the + * kit's `InlineTextField` inside a tonal `Surface` at `CircleShape`, which is + * M3's search-field shape. It is deliberately **not** auto-focused: opening the + * keyboard on arrival hides most of the list, and the list is already sorted by + * city. + * + * No long zone name and no live time: 450 long names is real ICU work for a + * list the user scrolls past, and a ticking picker is a ticking picker. A zone + * already on the tab carries a check and **stays selectable** — `add` is + * idempotent, so tapping it simply closes on the row the user already has. + */ +@Composable +internal fun ZonePickerScreen( + state: ZonePickerUiState, + onQueryChange: (String) -> Unit, + onPickZone: (String) -> Unit, + onPickDeviceZone: () -> Unit, + onDismiss: () -> Unit, +) { + val title = when (state.mode) { + ZonePickerMode.ADD -> stringResource(R.string.zone_picker_add_title) + ZonePickerMode.HOME -> stringResource(R.string.zone_picker_home_title) + } + + FullScreenPicker(title = title, onDismiss = onDismiss, scrollable = false) { + ZoneSearchField( + query = state.query, + onQueryChange = onQueryChange, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp), + ) + when { + state.loading -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + CircularProgressIndicator(modifier = Modifier.padding(GroupedListInset)) + } + + state.rows.isEmpty() -> Text( + text = stringResource(R.string.zone_picker_no_results, state.query.trim()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 16.dp), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + zoneRows(state.rows, onPickZone, onPickDeviceZone) + } + } + } +} + +private fun LazyListScope.zoneRows( + rows: List, + onPickZone: (String) -> Unit, + onPickDeviceZone: () -> Unit, +) { + rows.forEachIndexed { index, row -> + when (row) { + is ZonePickerRow.DeviceZone -> item(key = "device-zone") { + GroupedRow( + title = stringResource(R.string.zone_picker_device_zone), + summary = row.city, + position = positionOf(index, rows.size), + selected = row.selected, + leading = { + Icon( + imageVector = Icons.Rounded.Public, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = if (row.selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onPickDeviceZone, + ) + } + + is ZonePickerRow.Zone -> item(key = row.zoneId) { + GroupedRow( + title = row.city, + // The zone id when ICU has no country: an id is at least + // true, and a blank second line reads as a bug. + summary = row.country ?: row.zoneId, + position = positionOf(index, rows.size), + selected = row.selected, + trailing = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = row.gmt, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (row.selected) SelectedCheck() + } + }, + onClick = { onPickZone(row.zoneId) }, + ) + } + } + } +} + +@Composable +private fun ZoneSearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val placeholder = stringResource(R.string.zone_picker_search) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = CircleShape, + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(start = 20.dp, end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + // Merged so the placeholder names the field for a screen + // reader: a borderless input with no label is otherwise an + // unnamed edit box. + modifier = Modifier + .weight(1f) + .semantics(mergeDescendants = true) {}, + ) { + InlineTextField( + value = query, + onValueChange = onQueryChange, + placeholder = placeholder, + textStyle = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + ) + } + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.zone_picker_search_clear), + ) + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerState.kt new file mode 100644 index 0000000..6246f83 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerState.kt @@ -0,0 +1,70 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneComparisons +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneSearch +import java.time.ZoneId +import kotlin.time.Instant + +internal enum class ZonePickerMode { ADD, HOME } + +internal sealed interface ZonePickerRow { + /** Only in [ZonePickerMode.HOME], only while the query is blank, always first. */ + data class DeviceZone(val city: String, val selected: Boolean) : ZonePickerRow + + data class Zone( + val zoneId: String, + val city: String, + /** The localised country, or null — the screen falls back to the zone id. */ + val country: String?, + /** `"GMT+5:45"`, computed once at the instant the picker opened. */ + val gmt: String, + /** Already on the tab (ADD), or the current home (HOME). */ + val selected: Boolean, + ) : ZonePickerRow +} + +internal data class ZonePickerUiState( + val mode: ZonePickerMode, + val query: String, + /** True until the directory's first build lands. */ + val loading: Boolean, + val rows: List, +) + +internal object ZonePickerRows { + /** + * Pure; never throws. An entry whose zone id `ZoneId` cannot parse is + * dropped — a picker row nobody can pick is worse than one fewer row. + * + * The device-zone row belongs to [ZonePickerMode.HOME] alone, and only + * while the query is blank: a row that survives a search for "tokyo" is + * noise. + */ + fun from( + mode: ZonePickerMode, + entries: List, + query: String, + now: Instant, + selectedZoneIds: Set, + deviceCity: String, + deviceZoneSelected: Boolean, + ): List { + val zones = ZoneSearch.filter(entries, query) + .distinctBy { it.zoneId } + .mapNotNull { entry -> + val zone = runCatching { ZoneId.of(entry.zoneId) }.getOrNull() ?: return@mapNotNull null + ZonePickerRow.Zone( + zoneId = entry.zoneId, + city = entry.city, + country = entry.country, + gmt = ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(zone, now)), + selected = entry.zoneId in selectedZoneIds, + ) + } + val deviceRow = ZonePickerRow.DeviceZone(city = deviceCity, selected = deviceZoneSelected) + .takeIf { mode == ZonePickerMode.HOME && query.isBlank() } + return listOfNotNull(deviceRow) + zones + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f751c6..02cb16f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,4 +190,44 @@ %1$d lap %1$d laps + + Add city + Add up to %1$d cities + Home time zone + Home + Add a city to compare its time with your own. + Same time as home + %1$s ahead + %1$s behind + %1$s %2$s + %1$s \u00b7 %2$s + Yesterday + Today + Tomorrow + This time zone is no longer on your device + %1$s, %2$s + %1$s, %2$s, %3$s + + %1$s, %2$s + Reorder %1$s + Move up + Move down + Remove + Remove %1$s? + The city will be removed from your world clocks. + Remove + Add city + Home time zone + Search cities + Clear search + No city matches \u201c%1$s\u201d + Device time zone + + %d hour + %d hours + + + %d minute + %d minutes + diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt new file mode 100644 index 0000000..e823c70 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt @@ -0,0 +1,87 @@ +package de.jeanlucmakiola.clockula.testing + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import de.jeanlucmakiola.clockula.data.prefs.ClockPrefs +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepositoryImpl +import de.jeanlucmakiola.clockula.data.zones.ZoneDirectory +import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource +import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockViewModel +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import java.nio.file.Path +import kotlin.time.Instant + +/** + * The **real** [WorldClockViewModel] over the real [WorldClockSource], the real + * [ZoneDirectory], the real [WorldClockRepositoryImpl] across [FakeWorldClockDao] + * (which *extends* the abstract DAO, so `addIfAbsent` and `reorder`'s real + * `@Transaction` bodies are under test) and a real DataStore under a `@TempDir` + * — `StopwatchEngineHarness`'s arrangement, for a tab with no engine (M8 D23). + */ +class WorldClockHarness( + tempDir: Path, + storeScope: CoroutineScope, + io: CoroutineDispatcher, + now: Instant = T0, + val dao: FakeWorldClockDao = FakeWorldClockDao(), +) { + val wallClock: FakeWallClock = FakeWallClock(now) + val zones: FakeZoneProvider = FakeZoneProvider() + val ticker: FakeTicker = FakeTicker() + val names: FakeZoneNames = FakeZoneNames() + + val store: PrefStore = PrefStore( + PreferenceDataStoreFactory.create( + scope = storeScope, + produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() }, + ), + ) + + val settings: SettingsPrefs = SettingsPrefs(store) + + val worldClocks: WorldClockRepository = WorldClockRepositoryImpl(dao) + + val directory: ZoneDirectory = ZoneDirectory(names, zones, io) + + internal val source: WorldClockSource = WorldClockSource( + worldClocks = worldClocks, + settings = settings, + directory = directory, + zones = zones, + wallClock = wallClock, + ticker = ticker, + ) + + val viewModel: WorldClockViewModel = WorldClockViewModel( + worldClocks = worldClocks, + settings = settings, + source = source, + ) + + /** The table as storage holds it right now, in `sort_order` then `id`. */ + suspend fun stored(): List = worldClocks.worldClocks().first() + + /** `home_zone_id` as the store holds it right now. */ + suspend fun storedHomeZoneId(): String? = store.flow(ClockPrefs.homeZoneId).first() +} + +/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */ +fun TestScope.worldClockHarness( + tempDir: Path, + now: Instant = T0, + dao: FakeWorldClockDao = FakeWorldClockDao(), +): WorldClockHarness = WorldClockHarness( + tempDir = tempDir, + storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + io = UnconfinedTestDispatcher(testScheduler), + now = now, + dao = dao, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt new file mode 100644 index 0000000..892ae24 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt @@ -0,0 +1,246 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel +import de.jeanlucmakiola.clockula.domain.worldclock.OffsetLabel +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry +import de.jeanlucmakiola.clockula.testing.BERLIN +import de.jeanlucmakiola.clockula.testing.T0 +import de.jeanlucmakiola.clockula.testing.UTC_ZONE +import org.junit.jupiter.api.Test +import java.time.LocalTime +import java.time.ZoneId +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * §5.7 — 18 cases. The tab as pure data: the row's name, its offset and day + * relative to **home**, the truncation that makes the 200 ms tick cheap (D18), + * and the row whose zone the device no longer knows (D14). + */ +class WorldClockRowsTest { + + private val newYork = "America/New_York" + private val kolkata = "Asia/Kolkata" + private val honolulu = "Pacific/Honolulu" + private val kiritimati = "Pacific/Kiritimati" + private val sydney = "Australia/Sydney" + private val berlin = "Europe/Berlin" + + /** 2024-01-15T12:00:00Z. */ + private val january: Instant = Instant.fromEpochMilliseconds(1_705_320_000_000L) + + /** 2024-07-15T12:00:00Z. */ + private val july: Instant = Instant.fromEpochMilliseconds(1_721_044_800_000L) + + /** 2023-11-14T12:00:00Z — midday in UTC. */ + private val midday: Instant = Instant.fromEpochMilliseconds(1_699_963_200_000L) + + private val entries: Map = listOf( + ZoneEntry(berlin, "Berlin", "Germany"), + ZoneEntry(newYork, "New York", "United States"), + ZoneEntry(kolkata, "Kolkata", "India"), + ZoneEntry(honolulu, "Honolulu", "United States"), + ZoneEntry(kiritimati, "Kiritimati", "Kiribati"), + ZoneEntry(sydney, "Sydney", "Australia"), + ).associateBy { it.zoneId } + + private fun clock( + id: Long = 1L, + zoneId: String, + label: String? = null, + sortOrder: Int = 0, + ) = WorldClock(id = id, zoneId = zoneId, label = label, sortOrder = sortOrder) + + private fun rows( + clocks: List, + homeZone: ZoneId = BERLIN, + now: Instant = T0, + zoneNames: Map = emptyMap(), + ) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames) + + // --- from --- + + /** §5.7 #1 */ + @Test + fun `an unlabelled row is named by ICU's city`() { + val row = rows(listOf(clock(zoneId = berlin))).single() + + assertThat(row.name).isEqualTo("Berlin") + } + + /** §5.7 #2 */ + @Test + fun `the user's label wins over the ICU city`() { + val row = rows(listOf(clock(zoneId = berlin, label = "Büro"))).single() + + assertThat(row.name).isEqualTo("Büro") + } + + /** §5.7 #3 */ + @Test + fun `a zone with no entry falls back to its last segment`() { + val row = rows(listOf(clock(zoneId = "Asia/Ho_Chi_Minh"))).single() + + assertThat(row.name).isEqualTo("Ho Chi Minh") + } + + /** §5.7 #4 */ + @Test + fun `the rows keep the repository's order`() { + val clocks = listOf( + clock(id = 1L, zoneId = newYork, sortOrder = 0), + clock(id = 2L, zoneId = kolkata, sortOrder = 1), + clock(id = 3L, zoneId = honolulu, sortOrder = 2), + ) + + val built = rows(clocks) + + assertThat(built.map { it.id }).containsExactly(1L, 2L, 3L).inOrder() + } + + /** §5.7 #5 */ + @Test + fun `a western city reads as behind home, on the same day`() { + val row = rows(listOf(clock(zoneId = newYork))).single() + + assertThat(row.time).isEqualTo(LocalTime.of(17, 13)) + assertThat(row.offset).isEqualTo(OffsetLabel.Behind(6, 0)) + assertThat(row.day).isEqualTo(DayLabel.TODAY) + } + + /** §5.7 #6 */ + @Test + fun `an eastern city reads as ahead of home, already tomorrow`() { + val row = rows(listOf(clock(zoneId = kolkata))).single() + + assertThat(row.time).isEqualTo(LocalTime.of(3, 43)) + assertThat(row.offset).isEqualTo(OffsetLabel.Ahead(4, 30)) + assertThat(row.day).isEqualTo(DayLabel.TOMORROW) + } + + /** §5.7 #7 */ + @Test + fun `a city a day behind home reads as yesterday`() { + val row = rows(listOf(clock(zoneId = honolulu)), homeZone = ZoneId.of(kiritimati)).single() + + assertThat(row.day).isEqualTo(DayLabel.YESTERDAY) + } + + /** §5.7 #8 */ + @Test + fun `home's own row reads as the same time, today`() { + val row = rows(listOf(clock(zoneId = berlin))).single() + + assertThat(row.offset).isEqualTo(OffsetLabel.Same) + assertThat(row.day).isEqualTo(DayLabel.TODAY) + } + + /** §5.7 #9 */ + @Test + fun `a row's time is truncated to the minute, so half the ticks change nothing`() { + val clocks = listOf(clock(zoneId = newYork)) + + val before = rows(clocks, now = T0) + val after = rows(clocks, now = T0 + 30.seconds) + + assertThat(after).isEqualTo(before) + } + + /** §5.7 #10 */ + @Test + fun `the truncation does not freeze the row`() { + val clocks = listOf(clock(zoneId = newYork)) + + val before = rows(clocks, now = T0) + val after = rows(clocks, now = T0 + 2.minutes) + + assertThat(after).isNotEqualTo(before) + } + + /** §5.7 #11 */ + @Test + fun `a zone the device no longer knows is kept, blank and named from its id`() { + val row = rows(listOf(clock(zoneId = "Mars/Olympus"))).single() + + assertThat(row.known).isFalse() + assertThat(row.time).isNull() + assertThat(row.offset).isNull() + assertThat(row.day).isNull() + assertThat(row.name).isEqualTo("Olympus") + } + + /** §5.7 #12 */ + @Test + fun `a known zone carries all three readings`() { + val row = rows(listOf(clock(zoneId = newYork))).single() + + assertThat(row.known).isTrue() + assertThat(row.time).isNotNull() + assertThat(row.offset).isNotNull() + assertThat(row.day).isNotNull() + } + + /** §5.7 #13 */ + @Test + fun `the long zone name follows the map it is given`() { + val clocks = listOf(clock(zoneId = newYork)) + + val named = rows(clocks, zoneNames = mapOf(newYork to "Eastern Standard Time")).single() + val unnamed = rows(clocks).single() + + assertThat(named.zoneName).isEqualTo("Eastern Standard Time") + assertThat(unnamed.zoneName).isNull() + } + + /** §5.7 #14 */ + @Test + fun `no clocks is no rows`() { + assertThat(rows(emptyList())).isEmpty() + } + + /** §5.7 #15 */ + @Test + fun `the offset is read at the instant, so both DST seasons are right`() { + val clocks = listOf(clock(zoneId = sydney)) + + val inJanuary = rows(clocks, now = january).single() + val inJuly = rows(clocks, now = july).single() + + assertThat(inJanuary.offset).isEqualTo(OffsetLabel.Ahead(10, 0)) + assertThat(inJuly.offset).isEqualTo(OffsetLabel.Ahead(8, 0)) + } + + // --- home --- + + /** §5.7 #16 */ + @Test + fun `the hero face is home, to the second`() { + val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) + + assertThat(home.zoneId).isEqualTo(berlin) + assertThat(home.city).isEqualTo("Berlin") + assertThat(home.time).isEqualTo(LocalTime.of(23, 13, 20)) + } + + /** §5.7 #17 */ + @Test + fun `a home zone with no entry falls back to its last segment`() { + val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap()) + + assertThat(home.city).isEqualTo("Ho Chi Minh") + assertThat(home.zoneName).isNull() + } + + /** §5.7 #18 */ + @Test + fun `the dial's morph carries whether it is the middle of the night there`() { + val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) + val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap()) + + assertThat(night.dayFraction).isWithin(0.001f).of(0f) + assertThat(day.dayFraction).isWithin(0.001f).of(1f) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt new file mode 100644 index 0000000..f80f4e6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt @@ -0,0 +1,418 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity +import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks +import de.jeanlucmakiola.clockula.testing.FakeWorldClockDao +import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension +import de.jeanlucmakiola.clockula.testing.WorldClockHarness +import de.jeanlucmakiola.clockula.testing.worldClockHarness +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.time.ZoneId +import kotlin.time.Duration.Companion.seconds + +/** + * §5.9 — 22 cases over the **real** [WorldClockViewModel], the real source, the + * real directory, the real repository across [FakeWorldClockDao] (so the real + * `addIfAbsent` and `reorder` transactions run) and a real DataStore under a + * `@TempDir`. There is no engine to go through: every verb is a repository or + * preference write (D1). + */ +class WorldClockViewModelTest { + + @JvmField + @RegisterExtension + val main = MainDispatcherExtension() + + private val europeanZoneIds: List = + ZoneId.getAvailableZoneIds().filter { it.startsWith("Europe/") }.sorted() + + private fun storedClocks(count: Int): List = + europeanZoneIds.take(count).mapIndexed { index, zoneId -> + WorldClockEntity(id = index + 1L, zoneId = zoneId, label = null, sortOrder = index) + } + + /** Two harnesses in one test need two DataStore files: one file, one owner. */ + private fun storeDir(tempDir: Path, name: String): Path = + Files.createDirectories(tempDir.resolve(name)) + + private fun TestScope.collecting(harness: WorldClockHarness): WorldClockHarness = harness.also { + backgroundScope.launch { it.viewModel.state.collect {} } + } + + private fun WorldClockHarness.loaded(): WorldClockUiState.Loaded = + viewModel.state.value as WorldClockUiState.Loaded + + /** §5.9 #1 */ + @Test + fun `the tab starts out loading, then lands empty and ready to add`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val harness = collecting(worldClockHarness(tempDir)) + + assertThat(harness.viewModel.state.value).isEqualTo(WorldClockUiState.Loading) + advanceUntilIdle() + assertThat(harness.loaded().rows).isEmpty() + assertThat(harness.loaded().canAdd).isTrue() + } + + /** §5.9 #2 */ + @Test + fun `with no home zone stored the face is the device's own`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + + advanceUntilIdle() + + assertThat(harness.loaded().home.zoneId).isEqualTo(harness.zones.current().id) + } + + /** §5.9 #3 */ + @Test + fun `picking a city in ADD mode stores it and shows it`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.viewModel.onOpenAddPicker() + advanceUntilIdle() + + harness.viewModel.onPickZone("Europe/Berlin") + advanceUntilIdle() + + assertThat(harness.stored().map { it.zoneId }).containsExactly("Europe/Berlin") + assertThat(harness.loaded().rows.map { it.zoneId }).containsExactly("Europe/Berlin") + } + + /** §5.9 #4 */ + @Test + fun `picking a city the tab already has adds nothing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao( + listOf(WorldClockEntity(id = 1L, zoneId = "Europe/Berlin", label = null, sortOrder = 0)), + ) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + harness.viewModel.onOpenAddPicker() + advanceUntilIdle() + + harness.viewModel.onPickZone("Europe/Berlin") + advanceUntilIdle() + + assertThat(harness.stored()).hasSize(1) + } + + /** §5.9 #5 */ + @Test + fun `the cap is reached at twenty-four cities, not before`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val full = collecting( + worldClockHarness( + storeDir(tempDir, "full"), + dao = FakeWorldClockDao(storedClocks(WorldClocks.MAX)), + ), + ) + advanceUntilIdle() + + assertThat(full.loaded().canAdd).isFalse() + + val room = collecting( + worldClockHarness( + storeDir(tempDir, "room"), + dao = FakeWorldClockDao(storedClocks(WorldClocks.MAX - 1)), + ), + ) + advanceUntilIdle() + + assertThat(room.loaded().canAdd).isTrue() + } + + /** §5.9 #6 */ + @Test + fun `a twenty-fifth city is refused rather than written`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting( + worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(WorldClocks.MAX))), + ) + harness.viewModel.onOpenAddPicker() + advanceUntilIdle() + + harness.viewModel.onPickZone("Pacific/Kiritimati") + advanceUntilIdle() + + assertThat(harness.stored()).hasSize(WorldClocks.MAX) + assertThat(harness.stored().map { it.zoneId }).doesNotContain("Pacific/Kiritimati") + } + + /** §5.9 #7 */ + @Test + fun `a blank or non-IANA id writes nothing and does not throw`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.viewModel.onOpenAddPicker() + advanceUntilIdle() + + harness.viewModel.onPickZone("") + harness.viewModel.onPickZone("Not/AZone") + advanceUntilIdle() + + assertThat(harness.stored()).isEmpty() + } + + /** §5.9 #8 */ + @Test + fun `removing a row leaves the others in their order`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting( + worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(3))), + ) + advanceUntilIdle() + + harness.viewModel.onRemove(2L) + advanceUntilIdle() + + assertThat(harness.stored().map { it.id }).containsExactly(1L, 3L).inOrder() + } + + /** §5.9 #9 */ + @Test + fun `moving a row up rewrites the stored order and the emitted one`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting( + worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(3))), + ) + advanceUntilIdle() + + harness.viewModel.onMoveUp(2L) + advanceUntilIdle() + + assertThat(harness.stored().map { it.id }).containsExactly(2L, 1L, 3L).inOrder() + assertThat(harness.loaded().rows.map { it.id }).containsExactly(2L, 1L, 3L).inOrder() + } + + /** §5.9 #10 */ + @Test + fun `moving the first row up changes nothing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao(storedClocks(3)) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + advanceUntilIdle() + val before = dao.stored + + harness.viewModel.onMoveUp(1L) + advanceUntilIdle() + + assertThat(dao.stored).isEqualTo(before) + } + + /** §5.9 #11 */ + @Test + fun `moving the last row down changes nothing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao(storedClocks(3)) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + advanceUntilIdle() + val before = dao.stored + + harness.viewModel.onMoveDown(3L) + advanceUntilIdle() + + assertThat(dao.stored).isEqualTo(before) + } + + /** §5.9 #12 */ + @Test + fun `a drag writes the order it was given`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting( + worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(3))), + ) + advanceUntilIdle() + + harness.viewModel.onReorder(listOf(3L, 1L, 2L)) + advanceUntilIdle() + + assertThat(harness.stored().map { it.id }).containsExactly(3L, 1L, 2L).inOrder() + } + + /** §5.9 #13 */ + @Test + fun `picking a city in HOME mode stores it as the home zone`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.viewModel.onOpenHomePicker() + advanceUntilIdle() + + harness.viewModel.onPickZone("Asia/Tokyo") + advanceUntilIdle() + + assertThat(harness.storedHomeZoneId()).isEqualTo("Asia/Tokyo") + assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo") + } + + /** §5.9 #14 */ + @Test + fun `picking the device zone clears the stored home zone`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.settings.setHomeZoneId("Asia/Tokyo") + harness.viewModel.onOpenHomePicker() + advanceUntilIdle() + + harness.viewModel.onPickDeviceZone() + advanceUntilIdle() + + assertThat(harness.storedHomeZoneId()).isNull() + assertThat(harness.loaded().home.zoneId).isEqualTo(harness.zones.current().id) + } + + /** §5.9 #15 */ + @Test + fun `a zone change needs no receiver, only the next tick`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + advanceUntilIdle() + + harness.zones.zone = ZoneId.of("Asia/Tokyo") + harness.ticker.tick() + advanceUntilIdle() + + assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo") + } + + /** §5.9 #16 */ + @Test + fun `a home zone the device no longer knows falls back to the device's own`( + @TempDir tempDir: Path, + ) = runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + + harness.settings.setHomeZoneId("Mars/Olympus") + advanceUntilIdle() + + assertThat(harness.loaded().home.zoneId).isEqualTo(harness.zones.current().id) + } + + /** §5.9 #17 */ + @Test + fun `the picker's query filters its rows and clearing it restores them`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.names.cities = mapOf( + "America/New_York" to "New York", + "Europe/Berlin" to "Berlin", + ) + harness.zones.availableIds = setOf("America/New_York", "Europe/Berlin") + harness.viewModel.onOpenAddPicker() + advanceUntilIdle() + val all = harness.viewModel.picker.value?.rows.orEmpty().size + + harness.viewModel.onQueryChange("york") + advanceUntilIdle() + val filtered = harness.viewModel.picker.value?.rows.orEmpty() + + assertThat(filtered).hasSize(1) + + harness.viewModel.onQueryChange("") + advanceUntilIdle() + + assertThat(harness.viewModel.picker.value?.rows.orEmpty()).hasSize(all) + } + + /** §5.9 #18 */ + @Test + fun `dismissing the picker closes it and forgets the query`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.viewModel.onOpenAddPicker() + harness.viewModel.onQueryChange("york") + advanceUntilIdle() + + harness.viewModel.onDismissPicker() + advanceUntilIdle() + + assertThat(harness.viewModel.picker.value).isNull() + + harness.viewModel.onOpenAddPicker() + advanceUntilIdle() + + assertThat(harness.viewModel.picker.value?.query).isEmpty() + } + + /** §5.9 #19 */ + @Test + fun `the cadence is the ticker's, never a delay at a call site`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + + advanceUntilIdle() + + assertThat(harness.ticker.requestedPeriods).contains(WorldClockDefaults.Tick) + } + + /** §5.9 #20 */ + @Test + fun `a second of wall clock moves the face and leaves the rows alone`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting( + worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(1))), + ) + advanceUntilIdle() + val before = harness.loaded() + + harness.wallClock.advance(1.seconds) + harness.ticker.tick() + advanceUntilIdle() + + val after = harness.loaded() + assertThat(after.home.time).isEqualTo(before.home.time.plusSeconds(1)) + assertThat(after.rows).isEqualTo(before.rows) + } + + /** §5.9 #21 */ + @Test + fun `one rotten zone id does not take the tab down`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao( + listOf(WorldClockEntity(id = 1L, zoneId = "Mars/Olympus", label = null, sortOrder = 0)), + ) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + + advanceUntilIdle() + + assertThat(harness.loaded().rows.single().known).isFalse() + } + + /** + * §5.9 #22 — the cities follow a per-app language change. The ViewModel and + * its upstream survive the Activity recreation AppCompat does for + * `core-locale`, so a cache above `ZoneDirectory` would keep every row in + * the old language while the ICU zone name under the face switched. + */ + @Test + fun `a language change renames the rows on the next tick`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao( + listOf(WorldClockEntity(id = 1L, zoneId = "Europe/Berlin", label = null, sortOrder = 0)), + ) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + harness.names.cities = mapOf("Europe/Berlin" to "Berlin") + advanceUntilIdle() + assertThat(harness.loaded().rows.single().name).isEqualTo("Berlin") + + harness.names.localeTag = "uk" + harness.names.cities = mapOf("Europe/Berlin" to "Берлін") + harness.ticker.tick() + advanceUntilIdle() + + assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerRowsTest.kt new file mode 100644 index 0000000..1ce0802 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/ZonePickerRowsTest.kt @@ -0,0 +1,184 @@ +package de.jeanlucmakiola.clockula.ui.worldclock + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry +import de.jeanlucmakiola.clockula.testing.T0 +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +/** + * §5.8 — 15 cases. One picker, two modes (D16): the device-zone row belongs to + * HOME and only while the query is blank, the check follows the mode, and the + * GMT label is read once at the instant the picker opened (D15). + */ +class ZonePickerRowsTest { + + private val berlin = ZoneEntry("Europe/Berlin", "Berlin", "Germany") + private val newYork = ZoneEntry("America/New_York", "New York", "United States") + private val tokyo = ZoneEntry("Asia/Tokyo", "Tokyo", "Japan") + private val kolkata = ZoneEntry("Asia/Kolkata", "Kolkata", "India") + private val utc = ZoneEntry("UTC", "UTC", null) + + private val entries = listOf(berlin, newYork, tokyo) + + /** 2024-07-15T12:00:00Z — Berlin on summer time. */ + private val july: Instant = Instant.fromEpochMilliseconds(1_721_044_800_000L) + + private fun rows( + mode: ZonePickerMode = ZonePickerMode.ADD, + entries: List = this.entries, + query: String = "", + now: Instant = T0, + selectedZoneIds: Set = emptySet(), + deviceCity: String = "Berlin", + deviceZoneSelected: Boolean = false, + ) = ZonePickerRows.from( + mode = mode, + entries = entries, + query = query, + now = now, + selectedZoneIds = selectedZoneIds, + deviceCity = deviceCity, + deviceZoneSelected = deviceZoneSelected, + ) + + private fun List.zones(): List = + filterIsInstance() + + private fun List.deviceZone(): ZonePickerRow.DeviceZone? = + filterIsInstance().singleOrNull() + + /** §5.8 #1 */ + @Test + fun `adding a city offers the cities and nothing else`() { + val built = rows(mode = ZonePickerMode.ADD) + + assertThat(built.zones().map { it.zoneId }) + .containsExactly(berlin.zoneId, newYork.zoneId, tokyo.zoneId).inOrder() + assertThat(built.deviceZone()).isNull() + } + + /** §5.8 #2 */ + @Test + fun `choosing a home zone offers the device's own first`() { + val built = rows(mode = ZonePickerMode.HOME) + + assertThat(built.first()).isInstanceOf(ZonePickerRow.DeviceZone::class.java) + assertThat(built.zones()).hasSize(3) + } + + /** §5.8 #3 */ + @Test + fun `a search hides the device-zone row`() { + val built = rows(mode = ZonePickerMode.HOME, query = "tokyo") + + assertThat(built.deviceZone()).isNull() + } + + /** §5.8 #4 */ + @Test + fun `whitespace is a blank query, so the device-zone row stays`() { + val built = rows(mode = ZonePickerMode.HOME, query = " ") + + assertThat(built.deviceZone()).isNotNull() + } + + /** §5.8 #5 */ + @Test + fun `following the device is a selection of its own`() { + val built = rows(mode = ZonePickerMode.HOME, deviceZoneSelected = true) + + assertThat(built.deviceZone()?.selected).isTrue() + assertThat(built.zones().filter { it.selected }).isEmpty() + } + + /** §5.8 #6 */ + @Test + fun `a zone already on the tab carries the check`() { + val built = rows(mode = ZonePickerMode.ADD, selectedZoneIds = setOf(berlin.zoneId)) + + assertThat(built.zones().filter { it.selected }.map { it.zoneId }) + .containsExactly(berlin.zoneId) + } + + /** §5.8 #7 */ + @Test + fun `the stored home zone carries the check instead of the device row`() { + val built = rows( + mode = ZonePickerMode.HOME, + selectedZoneIds = setOf(tokyo.zoneId), + deviceZoneSelected = false, + ) + + assertThat(built.zones().filter { it.selected }.map { it.zoneId }) + .containsExactly(tokyo.zoneId) + assertThat(built.deviceZone()?.selected).isFalse() + } + + /** §5.8 #8 */ + @Test + fun `the query filters the rows through the catalog's own search`() { + val built = rows(query = "york") + + assertThat(built.zones().map { it.zoneId }).containsExactly(newYork.zoneId) + } + + /** §5.8 #9 */ + @Test + fun `a query matching nothing yields no rows at all`() { + val built = rows(query = "zzz") + + assertThat(built).isEmpty() + } + + /** §5.8 #10 */ + @Test + fun `each row carries its GMT label`() { + val built = rows(entries = listOf(berlin, kolkata, utc)).zones().associateBy { it.zoneId } + + assertThat(built.getValue(berlin.zoneId).gmt).isEqualTo("GMT+1") + assertThat(built.getValue(kolkata.zoneId).gmt).isEqualTo("GMT+5:30") + assertThat(built.getValue(utc.zoneId).gmt).isEqualTo("GMT") + } + + /** §5.8 #11 */ + @Test + fun `the GMT label is read at the instant the picker opened`() { + val built = rows(entries = listOf(berlin), now = july).zones().single() + + assertThat(built.gmt).isEqualTo("GMT+2") + } + + /** §5.8 #12 */ + @Test + fun `an entry no ZoneId can parse is dropped rather than thrown on`() { + val built = rows(entries = listOf(berlin, ZoneEntry("Mars/Olympus", "Olympus", null))) + + assertThat(built.zones().map { it.zoneId }).containsExactly(berlin.zoneId) + } + + /** §5.8 #13 */ + @Test + fun `the device-zone row is summarised with the device's current city`() { + val built = rows(mode = ZonePickerMode.HOME, deviceCity = "Berlin") + + assertThat(built.deviceZone()?.city).isEqualTo("Berlin") + } + + /** §5.8 #14 */ + @Test + fun `a duplicated entry yields one row`() { + val built = rows(entries = listOf(berlin, berlin, newYork)) + + assertThat(built.zones().map { it.zoneId }) + .containsExactly(berlin.zoneId, newYork.zoneId) + } + + /** §5.8 #15 */ + @Test + fun `a missing country stays missing, for the screen to decide`() { + val built = rows(entries = listOf(utc)).zones().single() + + assertThat(built.country).isNull() + } +}