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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+418
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user