feat(worldclock): show home time as text under the analog face

The World clock tab now prints the home time under the hero face in the
app's 12/24-hour format, reversing the earlier call to leave it out.
Reading the exact minute off a dial is slow, and that minute is what
most visits to the tab are for.

The face no longer has a content description, so TalkBack does not read
the time twice. The readout, "Home", the city and the zone name are one
merged node instead.

Closes #4
This commit is contained in:
2026-10-05 19:36:31 +02:00
parent eb63f02d38
commit 45e5a51c98
5 changed files with 61 additions and 44 deletions
@@ -5,7 +5,6 @@ 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
@@ -94,7 +93,9 @@ class WorldClockInstrumentedTest {
val home = java.time.ZoneId.systemDefault().id.substringAfterLast('/')
composeTestRule.onNodeWithContentDescription(home, substring = true).assertExists()
// The face itself is hidden from TalkBack; the readout and caption
// under it are one merged node that names the city.
composeTestRule.onNodeWithText(home, substring = true).assertExists()
}
/** §5.11 #2 */
@@ -21,7 +21,6 @@ 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
@@ -40,17 +39,15 @@ import java.time.LocalTime
* 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.
* Scheme tokens only, no literal colour. The face is hidden from screen
* readers: the time readout under it says the same time as text, so
* describing the face as well would read the time twice.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun WorldClockFace(
time: LocalTime,
dayFraction: Float,
contentDescription: String,
modifier: Modifier = Modifier,
) {
val dialColor = MaterialTheme.colorScheme.secondaryContainer
@@ -81,7 +78,7 @@ internal fun WorldClockFace(
Canvas(
modifier = modifier
.size(WorldClockDefaults.HeroFaceSize)
.clearAndSetSemantics { this.contentDescription = contentDescription },
.clearAndSetSemantics {},
) {
drawDial(morph, progress, dialPath, dialMatrix, dialColor)
drawTicks(markColor)
@@ -46,10 +46,13 @@ import java.time.LocalTime
* 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.
* Under the face, the home time is repeated as a digital readout in the app's
* 12/24-hour format. This reverses D11, which left it out because an analog
* face that also prints its own time is a face nobody looks at: reading the
* exact minute off a dial is slower than reading it off digits, and that
* minute is what most visits to the tab are for. The face still carries what
* digits cannot: day or night at home, at a glance. 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
@@ -187,40 +190,52 @@ private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) {
.fillMaxWidth()
.padding(horizontal = GroupedListInset, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Hidden from TalkBack: the readout below says the same time.
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 ->
// One TalkBack stop for the time and whose time it is ("14:05, Home,
// Berlin, Central European Standard Time") rather than four. Not a
// live region: it would recite the clock every minute for as long as
// the tab was open, the mistake M4 already refused for the live pill.
Column(
modifier = Modifier.semantics(mergeDescendants = true) {},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
text = formatTime(home.time),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 8.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,
)
}
}
}
}
-1
View File
@@ -227,7 +227,6 @@
<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>
+11 -6
View File
@@ -2383,12 +2383,17 @@ The rest is ordinary `Canvas` work over `AnalogFace`'s pure angles — twelve ho
marks with the four quarters longer, three hands, a centre cap — in scheme
tokens only. The morph is built once and the path re-derived into a reused
`Path` per frame through `toPath(Morph, Float, Path)`, which is not
`@Composable` and is therefore callable from a `DrawScope`. The face is the home
readout and the digital time is **not** repeated under it: an analog face that
also prints its own time is a face nobody looks at. It carries a content
description naming the city and the time, and is deliberately **not** a live
region — at 1 Hz it would recite the clock for as long as the tab was open,
which is the mistake M4 already refused for the live pill (§12).
`@Composable` and is therefore callable from a `DrawScope`. M8 left the digital
time out from under the face on the grounds that an analog face that also
prints its own time is a face nobody looks at (D11). **That is reversed:** the
home time now sits under the face as a `displaySmall` readout in the app's
12/24-hour format, because the exact minute is what most visits to the tab are
for and a dial is the slow way to read it. The face keeps the day/night shape
digits cannot carry. With the time on screen as text, the face is hidden from
TalkBack, and the readout, "Home", the city and the zone name are one merged
node, so the time is read once. Neither is a live region — that would recite
the clock for as long as the tab was open, which is the mistake M4 already
refused for the live pill (§12).
### The cadence