feat(worldclock): the tab, the picker, and the face that answers at a glance

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.
This commit is contained in:
2026-09-22 13:05:18 +02:00
parent 5d11921c19
commit 26f1effb4f
16 changed files with 2394 additions and 9 deletions
@@ -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<MainActivity>? = 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<WorldClock> = runBlocking { seams.worldClocks().worldClocks().first() }
private fun given(vararg zoneIds: String) {
runBlocking { zoneIds.forEach { seams.worldClocks().add(it) } }
}
private fun openWorldClockTab() {
ActivityScenario.launch<MainActivity>(
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()
}
}
@@ -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)) }
}
}
@@ -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
}
@@ -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,
)
}
}
@@ -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)
}
@@ -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<WorldClockRowState>,
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<WorldClock>,
homeZone: ZoneId,
now: Instant,
entries: Map<String, ZoneEntry>,
zoneNames: Map<String, String?>,
): List<WorldClockRowState> = 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<String, ZoneEntry>,
zoneNames: Map<String, String?>,
): 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, ZoneEntry>): String =
entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId)
}
@@ -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<Long?>(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
@@ -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<ZoneEntry>, val deviceCity: String)
private val storedHomeZoneId: Flow<String?> =
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<WorldClockUiState.Loaded> =
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<String>): Flow<ZonePickerUiState> {
// 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<Catalog?> = 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<WorldClock>,
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<WorldClock>,
storedHome: String?,
deviceZone: ZoneId,
): Set<String> = 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()
}
}
@@ -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<ZonePickerMode?>(null)
private val query = MutableStateFlow("")
/** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */
internal val state: StateFlow<WorldClockUiState> = 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<ZonePickerUiState?> = 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<Long>) {
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>, Long) -> List<Long>) {
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
}
}
@@ -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<ZonePickerRow>,
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),
)
}
}
}
}
}
@@ -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<ZonePickerRow>,
)
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<ZoneEntry>,
query: String,
now: Instant,
selectedZoneIds: Set<String>,
deviceCity: String,
deviceZoneSelected: Boolean,
): List<ZonePickerRow> {
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
}
}
+40
View File
@@ -190,4 +190,44 @@
<item quantity="one">%1$d lap</item>
<item quantity="other">%1$d laps</item>
</plurals>
<!-- M8: the world clock tab, its rows and the zone picker -->
<string name="world_clock_add">Add city</string>
<string name="world_clock_limit">Add up to %1$d cities</string>
<string name="world_clock_home_action">Home time zone</string>
<string name="world_clock_home">Home</string>
<string name="world_clock_empty_hint">Add a city to compare its time with your own.</string>
<string name="world_clock_offset_same">Same time as home</string>
<string name="world_clock_ahead">%1$s ahead</string>
<string name="world_clock_behind">%1$s behind</string>
<string name="world_clock_hours_minutes">%1$s %2$s</string>
<string name="world_clock_summary">%1$s \u00b7 %2$s</string>
<string name="world_clock_day_yesterday">Yesterday</string>
<string name="world_clock_day_today">Today</string>
<string name="world_clock_day_tomorrow">Tomorrow</string>
<string name="world_clock_unknown_zone">This time zone is no longer on your device</string>
<string name="world_clock_face_description">%1$s, %2$s</string>
<string name="world_clock_row_description">%1$s, %2$s, %3$s</string>
<!-- A row whose zone the device no longer knows has no time to read out. -->
<string name="world_clock_row_description_unknown">%1$s, %2$s</string>
<string name="world_clock_row_drag">Reorder %1$s</string>
<string name="world_clock_row_move_up">Move up</string>
<string name="world_clock_row_move_down">Move down</string>
<string name="world_clock_row_remove">Remove</string>
<string name="world_clock_remove_title">Remove %1$s?</string>
<string name="world_clock_remove_message">The city will be removed from your world clocks.</string>
<string name="action_remove">Remove</string>
<string name="zone_picker_add_title">Add city</string>
<string name="zone_picker_home_title">Home time zone</string>
<string name="zone_picker_search">Search cities</string>
<string name="zone_picker_search_clear">Clear search</string>
<string name="zone_picker_no_results">No city matches \u201c%1$s\u201d</string>
<string name="zone_picker_device_zone">Device time zone</string>
<plurals name="world_clock_hours">
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<plurals name="world_clock_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
</resources>
@@ -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<WorldClock> = 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,
)
@@ -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<String, ZoneEntry> = 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<WorldClock>,
homeZone: ZoneId = BERLIN,
now: Instant = T0,
zoneNames: Map<String, String?> = 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)
}
}
@@ -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<String> =
ZoneId.getAvailableZoneIds().filter { it.startsWith("Europe/") }.sorted()
private fun storedClocks(count: Int): List<WorldClockEntity> =
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("Берлін")
}
}
@@ -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<ZoneEntry> = this.entries,
query: String = "",
now: Instant = T0,
selectedZoneIds: Set<String> = 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<ZonePickerRow>.zones(): List<ZonePickerRow.Zone> =
filterIsInstance<ZonePickerRow.Zone>()
private fun List<ZonePickerRow>.deviceZone(): ZonePickerRow.DeviceZone? =
filterIsInstance<ZonePickerRow.DeviceZone>().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()
}
}