Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 23 additions & 4 deletions app/src/main/java/com/mapgie/goflo/ui/screens/auth/LockScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
)
Expand All @@ -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)
)
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -385,6 +387,7 @@ private fun SpeedDialItem(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier = Modifier.semantics { contentDescription = label },
) {
icon()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") },
Expand All @@ -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 }
)
}
}
Expand Down
Loading
Loading