diff --git a/CHANGELOG.md b/CHANGELOG.md index b8971f4..d5beb4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,17 @@ Rules: - Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward --- +## [0.22.3-beta.1] - 2026-06-02 + +### Fixed +- Content descriptions added to speed-dial FABs (category and period log buttons) so TalkBack announces the button's action rather than reading nothing. +- Keyboard and switch-access role semantics (`Role.Button`, `Role.RadioButton`) applied to all custom-clickable elements: Manage screen list items, history period cards, stats chart-type selector, year picker, archive section header, category icon picker, colour swatches, export format rows, alarm permission banner, and settings navigation items. +- Archive and stats-warning expand/collapse controls now report their current state (`stateDescription = "Expanded"/"Collapsed"`) so TalkBack announces the post-tap state. +- PIN entry screens (lock and setup) now announce digit count changes via a polite live region on the dot indicator, and announce PIN errors immediately via an assertive live region. +- Delete key (⌫) on PIN keypads now has `contentDescription = "Delete"` so TalkBack reads the action rather than the raw Unicode symbol. +- Export format radio rows set `RadioButton(onClick = null)` so the wrapping row is the single focusable unit, preventing duplicate TalkBack announcements. +- Accessibility section in `template_requirements.md` expanded with five explicit principles covering content descriptions, keyboard/switch roles, dynamic text scaling, focus order, and live-region announcements. + ## [0.22.2-beta.1] - 2026-06-02 ### Changed diff --git a/LESSONS.md b/LESSONS.md index 5897feb..54a6ab4 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -8,6 +8,15 @@ Entries within each section are ordered by risk to a new project if forgotten: b ### Android / Compose +**`.clickable {}` without a `semantics { role = … }` is invisible to keyboard and switch access** +Compose's high-level interactive components (Button, IconButton, Card with onClick, etc.) declare their role automatically. Any element that uses a raw `.clickable {}` modifier instead — typically a Row, Box, or ListItem acting as a button — has no role by default and is therefore unreachable by keyboard navigation, switch access, and TalkBack's explore-by-touch linear mode. Always append `.semantics { role = Role.Button }` (or `RadioButton`, `Switch`, `Checkbox`) after `.clickable {}`. For toggle controls, also set `stateDescription` to the current state string (e.g. "Expanded") so TalkBack announces the result of the tap, not just the label. Pattern: `Modifier.clickable { … }.semantics { role = Role.Button; stateDescription = "…" }`. + +**Status changes need `liveRegion` — visibility changes alone are silent to screen readers** +When text appears or changes in response to user action (error messages, validation hints, loading confirmators, counts), screen readers only notice if the node carries `liveRegion = LiveRegionMode.Polite` (for non-urgent updates) or `LiveRegionMode.Assertive` (for errors that must interrupt). A Composable that conditionally adds a `Text` to the tree on error will recompose visually but TalkBack will not announce it without the live region. Apply the modifier to the Text itself, not to a wrapper: `modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive }`. + +**Icon-only FABs and SmallFABs need an explicit `contentDescription` on the container, not just the icon** +When a SmallFloatingActionButton contains an Icon with `contentDescription = null` and its label lives in an adjacent Surface (as in a speed-dial layout), TalkBack focuses on the FAB alone and reads nothing. Setting `contentDescription` on the Icon inside fixes the raw Icon composable but TalkBack still reads the FAB as unlabelled if the two are in separate composable trees. The reliable fix is `Modifier.semantics { contentDescription = label }` on the FAB itself, which takes precedence. + **`ModalBottomSheetProperties` requires all parameters explicitly in Material3 1.2.x** The constructor has no default values in this version — passing only `shouldDismissOnBackPress` fails to compile. Always supply all three: `securePolicy = SecureFlagPolicy.Inherit, isFocusable = true, shouldDismissOnBackPress = false`. `SecureFlagPolicy` also needs an explicit import from `androidx.compose.ui.window`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f12fa02..0d5c97d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.mapgie.goflo" minSdk = 26 targetSdk = 34 - versionCode = 59 - versionName = "0.22.2-beta.1" + versionCode = 60 + versionName = "0.22.3-beta.1" } signingConfigs { diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/StatsWarningBanner.kt b/app/src/main/java/com/mapgie/goflo/ui/components/StatsWarningBanner.kt index 01a2fdc..ef26d13 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/StatsWarningBanner.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/StatsWarningBanner.kt @@ -25,6 +25,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp private const val SHORT_TEXT = @@ -65,7 +69,11 @@ fun StatsWarningBanner( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onToggle), + .clickable(onClick = onToggle) + .semantics { + role = Role.Button + stateDescription = if (isExpanded) "Expanded" else "Collapsed" + }, verticalAlignment = Alignment.CenterVertically ) { Icon( diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/auth/LockScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/auth/LockScreen.kt index 4d65b42..2d4ffd4 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/auth/LockScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/auth/LockScreen.kt @@ -31,6 +31,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mapgie.goflo.ui.theme.ComfortaaFamily import androidx.core.content.ContextCompat @@ -87,8 +91,12 @@ fun LockScreen( if (state.isError) { Spacer(Modifier.height(8.dp)) - Text("Incorrect PIN", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error) + Text( + text = "Incorrect PIN", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive } + ) } Spacer(Modifier.height(40.dp)) @@ -113,7 +121,13 @@ fun LockScreen( @Composable private fun PinDots(filledCount: Int, isError: Boolean) { val dotColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.semantics { + contentDescription = "$filledCount of 4 digits entered" + liveRegion = LiveRegionMode.Polite + } + ) { repeat(4) { index -> Box( modifier = Modifier @@ -161,7 +175,12 @@ private fun NumberPad( Spacer(Modifier.size(72.dp)) } PadKey(label = "0", onClick = { onDigit(0) }) - TextButton(onClick = onDelete, modifier = Modifier.size(72.dp)) { + TextButton( + onClick = onDelete, + modifier = Modifier + .size(72.dp) + .semantics { contentDescription = "Delete" } + ) { Text("⌫", style = MaterialTheme.typography.titleLarge) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/auth/PinSetupScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/auth/PinSetupScreen.kt index 2054e73..bbb21e0 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/auth/PinSetupScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/auth/PinSetupScreen.kt @@ -28,6 +28,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @@ -85,8 +89,12 @@ fun PinSetupScreen( if (state.isError) { Spacer(Modifier.height(8.dp)) - Text(state.errorMessage, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error) + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive } + ) } Spacer(Modifier.height(40.dp)) @@ -100,7 +108,13 @@ fun PinSetupScreen( @Composable private fun SetupPinDots(filledCount: Int, isError: Boolean) { val dotColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.semantics { + contentDescription = "$filledCount of 4 digits entered" + liveRegion = LiveRegionMode.Polite + } + ) { repeat(4) { index -> Box( modifier = Modifier @@ -136,7 +150,12 @@ private fun SetupNumberPad(onDigit: (Int) -> Unit, onDelete: () -> Unit) { TextButton(onClick = { onDigit(0) }, modifier = Modifier.size(72.dp)) { Text("0", style = MaterialTheme.typography.headlineMedium) } - TextButton(onClick = onDelete, modifier = Modifier.size(72.dp)) { + TextButton( + onClick = onDelete, + modifier = Modifier + .size(72.dp) + .semantics { contentDescription = "Delete" } + ) { Text("⌫", style = MaterialTheme.typography.titleLarge) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt index ba3bb17..3ef3384 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt @@ -90,6 +90,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -388,7 +394,11 @@ fun ManageCategoriesScreen( modifier = Modifier .fillMaxWidth() .clickable { archivedExpanded = !archivedExpanded } - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .semantics { + role = Role.Button + stateDescription = if (archivedExpanded) "Expanded" else "Collapsed" + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -987,12 +997,17 @@ private fun CategoryIconGrid(selectedKey: String, onSelect: (String) -> Unit) { if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant ) - .clickable { onSelect(icon.key) }, + .clickable { onSelect(icon.key) } + .semantics { + role = Role.RadioButton + selected = isSelected + contentDescription = icon.displayName + }, contentAlignment = Alignment.Center ) { Icon( imageVector = icon.vector, - contentDescription = icon.displayName, + contentDescription = null, tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(26.dp) @@ -1062,13 +1077,18 @@ private fun CategoryColorPicker(selectedToken: String, onSelect: (String) -> Uni Modifier.border(3.dp, MaterialTheme.colorScheme.outline, CircleShape) else Modifier ) - .clickable { onSelect(colorOption.key) }, + .clickable { onSelect(colorOption.key) } + .semantics { + role = Role.RadioButton + selected = isSelected + contentDescription = colorOption.displayName + }, contentAlignment = Alignment.Center ) { if (isSelected) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Selected", + contentDescription = null, tint = onSwatchColor, modifier = Modifier.size(22.dp) ) @@ -1112,13 +1132,18 @@ private fun CategoryColorPicker(selectedToken: String, onSelect: (String) -> Uni Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape) else Modifier ) - .clickable { onSelect(hexKey) }, + .clickable { onSelect(hexKey) } + .semantics { + role = Role.RadioButton + selected = isSelected + contentDescription = "#$hexKey" + }, contentAlignment = Alignment.Center ) { if (isSelected) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Selected", + contentDescription = null, tint = onSwatchColor, modifier = Modifier.size(18.dp) ) @@ -1139,12 +1164,16 @@ private fun CategoryColorPicker(selectedToken: String, onSelect: (String) -> Uni .clip(CircleShape) .background(customColor) .border(3.dp, primaryColor, CircleShape) - .clickable { showFullPicker = true }, + .clickable { showFullPicker = true } + .semantics { + role = Role.Button + contentDescription = "Custom colour (selected). Tap to change" + }, contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Custom colour selected", + contentDescription = null, tint = onCustomColor, modifier = Modifier.size(18.dp) ) @@ -1153,7 +1182,11 @@ private fun CategoryColorPicker(selectedToken: String, onSelect: (String) -> Uni Box( modifier = Modifier .size(38.dp) - .clickable { showFullPicker = true }, + .clickable { showFullPicker = true } + .semantics { + role = Role.Button + contentDescription = "Choose custom colour" + }, contentAlignment = Alignment.Center ) { Canvas(modifier = Modifier.size(38.dp)) { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt index 9122aa3..d18a44c 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt @@ -47,6 +47,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mapgie.goflo.data.database.entities.PeriodEntry import com.mapgie.goflo.data.model.FlowLevel @@ -273,7 +276,8 @@ private fun PeriodCard(period: PeriodEntry, onClick: () -> Unit) { Card( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .semantics { role = Role.Button }, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt index 3d3e408..e76dbf5 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt @@ -55,6 +55,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mapgie.goflo.BuildConfig import com.mapgie.goflo.ui.components.CalendarGrid @@ -385,6 +387,7 @@ private fun SpeedDialItem( onClick = onClick, containerColor = containerColor, contentColor = contentColor, + modifier = Modifier.semantics { contentDescription = label }, ) { icon() } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/manage/ManageScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/manage/ManageScreen.kt index d425c15..d241f83 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/manage/ManageScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/manage/ManageScreen.kt @@ -2,6 +2,9 @@ package com.mapgie.goflo.ui.screens.manage import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -47,7 +50,9 @@ fun ManageScreen( Icon(Icons.Outlined.Category, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, - modifier = Modifier.clickable(onClick = onNavigateToCategories) + modifier = Modifier + .clickable(onClick = onNavigateToCategories) + .semantics { role = Role.Button } ) ListItem( headlineContent = { Text("Reminders") }, @@ -56,7 +61,9 @@ fun ManageScreen( Icon(Icons.Outlined.NotificationsNone, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, - modifier = Modifier.clickable(onClick = onNavigateToReminders) + modifier = Modifier + .clickable(onClick = onNavigateToReminders) + .semantics { role = Role.Button } ) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/manage/RemindersScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/manage/RemindersScreen.kt index fdb3a58..8c3e3a3 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/manage/RemindersScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/manage/RemindersScreen.kt @@ -129,14 +129,16 @@ fun RemindersScreen( headlineColor = MaterialTheme.colorScheme.onErrorContainer, supportingColor = MaterialTheme.colorScheme.onErrorContainer, ), - modifier = Modifier.clickable { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - context.startActivity( - Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) + modifier = Modifier + .clickable { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.startActivity( + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } } - } + .semantics { role = Role.Button } ) HorizontalDivider() } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt index cf98268..d6b7d55 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt @@ -39,6 +39,9 @@ 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.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mapgie.goflo.data.database.entities.TrackingCategory import com.mapgie.goflo.data.export.DateRangePreset @@ -187,10 +190,11 @@ fun ExportOptionsDialog( modifier = Modifier .fillMaxWidth() .clickable { format = f } - .padding(vertical = 2.dp), + .padding(vertical = 2.dp) + .semantics { role = Role.RadioButton }, verticalAlignment = Alignment.CenterVertically ) { - RadioButton(selected = format == f, onClick = { format = f }) + RadioButton(selected = format == f, onClick = null) Spacer(Modifier.width(4.dp)) Column { Text(f.name, style = MaterialTheme.typography.bodyMedium) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt index bf52bb7..eb41cd0 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt @@ -1729,7 +1729,9 @@ private fun SettingsNavItem( tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, - modifier = Modifier.clickable(onClick = onClick) + modifier = Modifier + .clickable(onClick = onClick) + .semantics { role = Role.Button } ) } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt index 6725ccf..7db41bc 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt @@ -73,6 +73,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.mapgie.goflo.data.database.entities.TrackingCategory @@ -426,6 +429,7 @@ private fun TimeRangePicker( onSelect(TimeRange.CalendarYear(year)) showYearDialog = false } + .semantics { role = Role.Button } .padding(vertical = 12.dp, horizontal = 8.dp), style = MaterialTheme.typography.bodyLarge ) @@ -711,7 +715,8 @@ private fun ChartTypeSelector( Card( modifier = Modifier .width(80.dp) - .clickable { onSelect(option.type) }, + .clickable { onSelect(option.type) } + .semantics { role = Role.Button }, colors = CardDefaults.cardColors( containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer diff --git a/template_requirements.md b/template_requirements.md index 9f3badb..a8cb34d 100644 --- a/template_requirements.md +++ b/template_requirements.md @@ -43,8 +43,18 @@ Rules: - [x] All interactive elements have a minimum touch / click target of **48×48dp** (Android) or **44×44pt** (iOS) - [x] Every icon-only control has a **content description** (or equivalent accessible label) — buttons, icon buttons, image-only elements + - Icons that are purely decorative (adjacent text label already describes the control) must use `contentDescription = null` so screen readers skip them + - Icons that are the **sole identifier** of an interactive control (e.g. icon-only FAB, icon-only chip) must carry a meaningful `contentDescription` equal to the action they perform - [x] Text and interactive elements meet **WCAG AA contrast ratios**: 4.5:1 for body text, 3:1 for large text and UI components - [x] The app is navigable without colour alone — selection state, errors, and status are also communicated via shape, label, or icon +- [x] **Every interactive element is keyboard/switch-accessible**: any element that uses a low-level `.clickable {}` modifier instead of a semantic component (Button, IconButton, Card with onClick, etc.) must declare an explicit `.semantics { role = Role.Button }` (or the appropriate role: `Role.RadioButton`, `Role.Switch`, `Role.Checkbox`) so that keyboard focus, switch access, and screen readers can discover and activate it + - `Role.Button` — navigation, generic action, expand/collapse + - `Role.RadioButton` — mutually exclusive single-select options (colour swatches, icon pickers, format selectors) + - `Role.Switch` — toggle with two named states; pair with `stateDescription` to announce the current state + - Combine with `stateDescription` for elements that toggle (e.g. "Expanded" / "Collapsed") so TalkBack announces the post-tap state +- [x] **Supports dynamic text scaling**: all font sizes must use `MaterialTheme.typography.*` styles or `sp` units (never hardcoded `dp` for text); fixed-height containers that hold user-visible text must use `wrapContentHeight()` or `heightIn(min = …)` rather than a hard `height(…)` so text is never clipped at the system's largest font scale +- [x] **Logical focus order**: focus traversal follows the visual reading order (top-to-bottom, then left-to-right); avoid `asReversed()` on displayed lists when it would invert tab order relative to screen position; use `FocusRequester` only when an explicit focus transition is needed (e.g. moving focus into a dialog on open) +- [x] **Status changes are announced via accessibility events**: any text that appears or changes in response to user action (error messages, validation feedback, progress indicators, confirmation notices) must be wrapped in `.semantics { liveRegion = LiveRegionMode.Polite }` (or `Assertive` for errors that must interrupt); do not rely on visibility changes or colour changes alone to communicate status — screen readers observe `liveRegion` nodes, not visual transitions ### Error states