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
19 changes: 19 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,22 @@ Promote to release → 1.4.2-beta.1 → 1.4.2 (versionCode +1)
- Minimum tap target: 44×44dp
- Never use `fallbackToDestructiveMigration` in the Room database config
- **Never use en dashes (–) or em dashes (—) in user-facing text.** They read as robotic. Use a period, colon, or reword the sentence instead. Hyphens in genuine compound words ("in-app", "4-digit", "built-in", "30-day") are fine.

## Accessibility Rules (enforced by `a11y_check.py` in CI)

Every `.clickable {}` or `.combinedClickable {}` modifier **must** carry a matching `.semantics { role = Role.<Type> }` in the same modifier chain. Use the role that best describes the element:

| Role | Use for |
|---|---|
| `Role.Button` | Navigation, generic action, expand/collapse, dialog dismiss |
| `Role.RadioButton` | Mutually exclusive single-select (theme pickers, icon pickers, format selectors) |
| `Role.Checkbox` | Toggle with two named states where the element acts as a row wrapping a Checkbox |
| `Role.Switch` | Toggle with two named states; pair with `stateDescription` to announce current state |

Additional rules:
- Place `.semantics { role = }` **before** `.clickable {}` in the chain when the clickable lambda is longer than a few lines, so the CI window check can find it.
- When the parent Row/Box handles the click, set the inner `Checkbox` / `RadioButton` to `onClick = null` to prevent double-focus.
- `clearAndSetSemantics { }` must also include `role = Role.<Type>` — it replaces all child semantics, so the role must be re-declared there.
- Status text that appears or changes in response to user action needs `Modifier.semantics { liveRegion = LiveRegionMode.Assertive }` (errors) or `LiveRegionMode.Polite` (non-urgent feedback).
- Icon-only interactive controls (FABs, icon-only buttons outside of `IconButton`) need `Modifier.semantics { contentDescription = "<action label>" }` on the container itself.
- Run `python3 a11y_check.py` locally before pushing to confirm no new violations.
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Accessibility role check
run: python3 a11y_check.py --fails-only

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ Rules:
- Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward

---
## [0.22.4-beta.1] - 2026-06-02

### Fixed
- Additional accessibility role fixes missed in the initial audit: archive-dialog "Don't show this again" row (`Role.Checkbox`), alarm-permission list item in reminder settings (`Role.Button`), export-scope and export-format radio rows (`Role.RadioButton` with `RadioButton(onClick = null)`), theme-mode selector (`Role.RadioButton`), palette picker (`Role.RadioButton`), app-icon picker (`Role.RadioButton`), calendar day cells (`role = Role.Button` inside `clearAndSetSemantics`), and speed-dial scrim dismiss overlay (`Role.Button` + contentDescription).

### Added
- `a11y_check.py`: Python CI script that scans all Compose source files and fails if any `.clickable` or `.combinedClickable` modifier chain is missing a `.semantics { role = Role.* }` declaration. Runs as an early fast-fail step on every PR.
- CI workflow (`build.yml`) now runs the accessibility role check before the build step so violations are caught before a full Gradle build.
- Accessibility coding rules added to `.claude/CLAUDE.md` so future AI-assisted changes follow the same patterns automatically.

## [0.22.3-beta.1] - 2026-06-02

### Fixed
Expand Down
108 changes: 108 additions & 0 deletions a11y_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
a11y_check.py — Accessibility role checker for GoFlo.

Scans Compose source files for .clickable / .combinedClickable modifier chains
that lack a companion .semantics { role = Role.* } declaration.

Every custom clickable element must declare an explicit role so TalkBack,
Switch Access, and keyboard navigation can discover and activate it.
See LESSONS.md (Android / Compose) for the correct pattern.

Usage:
python3 a11y_check.py # check all source files
python3 a11y_check.py --fails-only # same (alias, kept for parity)
python3 a11y_check.py path/to/file.kt # check a single file

Exit: 0 = no violations | 1 = one or more violations found
"""

import os
import re
import sys
from pathlib import Path

SEARCH_ROOTS = ["app/src/main/java"]

# Lines either side of a .clickable call to search for a role declaration.
# Modifier chains are typically 2-10 lines; 15 gives comfortable headroom.
ROLE_WINDOW = 15

# Matches genuine method calls, not mentions inside comments or strings.
CLICKABLE_RE = re.compile(r"\.(clickable|combinedClickable)\s*[{(]")
ROLE_RE = re.compile(r"\brole\s*=\s*Role\.")


def find_kt_files(roots: list[str]) -> list[Path]:
files: list[Path] = []
for root in roots:
for dirpath, _, names in os.walk(root):
for name in names:
if name.endswith(".kt"):
files.append(Path(dirpath) / name)
return sorted(files)


def check_file(path: Path) -> list[tuple[int, str]]:
"""Return (1-based line number, stripped text) for each violation."""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return []

violations: list[tuple[int, str]] = []
for i, line in enumerate(lines):
stripped = line.strip()

# Skip comment lines
if stripped.startswith("//") or stripped.startswith("*"):
continue

if not CLICKABLE_RE.search(line):
continue

# Check surrounding window for a role declaration
lo = max(0, i - ROLE_WINDOW)
hi = min(len(lines), i + ROLE_WINDOW + 1)
window = "\n".join(lines[lo:hi])

if not ROLE_RE.search(window):
violations.append((i + 1, stripped))

return violations


def main() -> int:
args = sys.argv[1:]
file_args = [a for a in args if not a.startswith("--")]

files = [Path(f) for f in file_args] if file_args else find_kt_files(SEARCH_ROOTS)

checked = 0
violations = 0

for path in files:
hits = check_file(path)
checked += 1
if hits:
violations += len(hits)
print(f"\n{path}")
for lineno, text in hits:
print(f" line {lineno:4d}: {text}")

print(f"\n{'═'*70}")
print(f" {checked} file(s) checked")
if violations:
print(f" {violations} violation(s) ✗")
print(f"\n Each .clickable / .combinedClickable must carry")
print(f" .semantics {{ role = Role.<Type> }} in the same modifier chain.")
print(f" See LESSONS.md (Android / Compose) for the correct pattern.")
else:
print(f" All clean ✓")
print(f"{'═'*70}")

return 1 if violations else 0


if __name__ == "__main__":
sys.exit(main())
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 = 60
versionName = "0.22.3-beta.1"
versionCode = 61
versionName = "0.22.4-beta.1"
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import java.time.LocalDate
Expand Down Expand Up @@ -296,7 +298,7 @@ private fun DayCell(
)
// clearAndSetSemantics replaces all child semantics so TalkBack reads
// only this description, not the raw day-number Text inside the Box.
.clearAndSetSemantics { contentDescription = cellDescription }
.clearAndSetSemantics { contentDescription = cellDescription; role = Role.Button }
.padding(2.dp),
contentAlignment = Alignment.Center
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,13 @@ fun ManageCategoriesScreen(
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { doNotShowAgain = !doNotShowAgain }
modifier = Modifier
.clickable { doNotShowAgain = !doNotShowAgain }
.semantics { role = Role.Checkbox }
) {
Checkbox(
checked = doNotShowAgain,
onCheckedChange = { doNotShowAgain = it }
onCheckedChange = null
)
Spacer(Modifier.width(4.dp))
Text("Don't show this again", style = MaterialTheme.typography.bodySmall)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,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.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.mapgie.goflo.BuildConfig
Expand Down Expand Up @@ -274,6 +276,7 @@ fun HomeScreen(
indication = null,
onClick = { showLogMenu = false; logMenuTargetDate = null }
)
.semantics { role = Role.Button; contentDescription = "Close menu" }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1009,14 +1009,16 @@ private fun RemindersSubScreen(
headlineColor = MaterialTheme.colorScheme.onErrorContainer,
supportingColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier = Modifier.clickable {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.startActivity(
Intent(AndroidSettings.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(AndroidSettings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
.semantics { role = Role.Button }
)
HorizontalDivider()
}
Expand Down Expand Up @@ -1500,10 +1502,11 @@ private fun ExportDataSubScreen(
modifier = Modifier
.fillMaxWidth()
.clickable { fullBackup = false }
.semantics { role = Role.RadioButton }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !fullBackup, onClick = { fullBackup = false })
RadioButton(selected = !fullBackup, onClick = null)
Spacer(Modifier.width(4.dp))
Column {
Text("Data only", style = MaterialTheme.typography.bodyMedium)
Expand All @@ -1518,10 +1521,11 @@ private fun ExportDataSubScreen(
modifier = Modifier
.fillMaxWidth()
.clickable { fullBackup = true }
.semantics { role = Role.RadioButton }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = fullBackup, onClick = { fullBackup = true })
RadioButton(selected = fullBackup, onClick = null)
Spacer(Modifier.width(4.dp))
Column {
Text("Full backup", style = MaterialTheme.typography.bodyMedium)
Expand Down Expand Up @@ -1624,10 +1628,11 @@ private fun ExportDataSubScreen(
modifier = Modifier
.fillMaxWidth()
.clickable { format = f }
.semantics { role = Role.RadioButton }
.padding(vertical = 2.dp),
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)
Expand Down Expand Up @@ -1839,6 +1844,7 @@ private fun CompactThemePicker(
if (selected) MaterialTheme.colorScheme.primary
else Color.Transparent
)
.semantics { role = Role.RadioButton }
.clickable {
when (mode) {
ThemeMode.SYSTEM -> {
Expand Down Expand Up @@ -1945,6 +1951,7 @@ private fun PaletteOption(palette: StandardPalette, selected: Boolean, onClick:
Column(
modifier = Modifier
.clickable(onClick = onClick)
.semantics { role = Role.RadioButton }
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
Expand Down Expand Up @@ -2105,6 +2112,7 @@ private fun IconChoiceCell(
Column(
modifier = Modifier
.clickable(onClick = onClick)
.semantics { role = Role.RadioButton }
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
Expand Down
Loading