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
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ You can always edit this file by hand instead — the helpers just save effort.

### Fixed

- **Buttons a screen reader announced but could not press now press.** The
idiom that reads a tappable row as one announcement dropped the tap along
with the text it hid, leaving twenty-seven controls flagged as buttons with
no action to carry — the drill and lesson rows, the studio and appearance
pickers, the flashcard, the pronounce button and the welcome sound toggle
among them. A guard now reads the sources and fails if the pair comes apart.

- **A half-translated language folder is refused, not shown.** The fallback to
English reaches every depth of an entry, so a folder can translate one of a
lesson's cards or one of a help entry's steps and leave the rest in English.
Expand Down
12 changes: 7 additions & 5 deletions lib/core/widgets/bordered_tap_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import 'package:flutter/material.dart';
/// A surface on the ground, ruled and tappable: the frame the design's chips
/// and slim entry rows share.
///
/// Only the frame. What goes inside is the caller's — a chip pairs a name with
/// a count, a row pairs a name with a chevron — and the shapes stay separate
/// components because the design draws them as two. What they must not do is
/// disagree about the surface, the rule and the corner they sit on, which is
/// the part that lives here.
/// Only the frame; what goes inside is the caller's. The chip and the row stay
/// separate components because the design draws them as two, but they must not
/// disagree about the surface, the rule and the corner, which live here.
class BorderedTapRow extends StatelessWidget {
/// Creates a [BorderedTapRow].
const BorderedTapRow({
Expand Down Expand Up @@ -40,6 +38,10 @@ class BorderedTapRow extends StatelessWidget {
return Semantics(
button: true,
label: semanticsLabel,
// The action, not just the flag: `excludeSemantics` drops the InkWell's
// tap along with the text, and a node saying "button" with no tap to
// carry announces something a screen reader cannot press (#487).
onTap: onTap,
excludeSemantics: true,
child: Material(
color: mood.surface,
Expand Down
8 changes: 7 additions & 1 deletion lib/core/widgets/disclosure.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,17 @@ class Disclosure extends StatelessWidget {
}
if (onToggle == null) return row;

// The tap is carried here only when the child's own is being dropped with
// its text (#487). Beside a live InkWell it would split the row into two
// nodes, taking the button flag off the one that carries the label.
final announcesAlone = semanticsLabel != null;

return Semantics(
button: true,
expanded: collapsible ? isOpen : null,
label: semanticsLabel,
excludeSemantics: semanticsLabel != null,
onTap: announcesAlone ? onToggle : null,
excludeSemantics: announcesAlone,
child: InkWell(onTap: onToggle, child: row),
);
}
Expand Down
12 changes: 4 additions & 8 deletions lib/core/widgets/reward_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,9 @@ import 'package:flutter/material.dart';
/// One occasional beat on a reward screen: what happened, and a quiet line
/// saying what it means.
///
/// **The one anatomy for every reward-list row** — a label, a muted detail,
/// and at most one trailing affordance. The freeze earn, a new card and the
/// Coffee Challenge offer are all this shape, which is what makes them read as
/// one list rather than three unrelated announcements.
///
/// Deliberately plain: no well, no accent kicker, no card of its own. The
/// screen already carries the celebration; a row that decorated itself would
/// compete with the tree above it.
/// The one anatomy for every reward-list row — a label, a muted detail and at
/// most one trailing affordance — so the freeze earn, a new card and the
/// Coffee Challenge offer read as one list rather than three announcements.
class RewardRow extends StatelessWidget {
/// Creates a [RewardRow].
const RewardRow({
Expand Down Expand Up @@ -98,6 +93,7 @@ class RewardRow extends StatelessWidget {
return Semantics(
button: true,
label: detail == null ? label : '$label. $detail',
onTap: onPress,
excludeSemantics: true,
child: InkWell(onTap: onPress, child: row),
);
Expand Down
21 changes: 5 additions & 16 deletions lib/features/challenges/presentation/challenge_offer_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,10 @@ import 'package:flutter/material.dart';

/// The Coffee Challenge offer, as one row of a reward screen.
///
/// The app's port of the design's `ChallengeSuggestion`. It is not the only
/// one yet: the lesson ending still runs [ChallengeSuggestion], an older
/// bordered card with its own *Save for later*, and converging the two onto
/// this row is #490's job rather than this one's. Until then the confirmation
/// sentence and the write exist in both places, deliberately.
///
/// **One affordance, and no way to say no.** The design gives the row a go
/// button and nothing else: declining is walking past it, because the challenge
/// waits on the Path either way. That is why there is no *not now* here — a
/// dismissal would have to mean something, and there is nothing for it to mean.
///
/// **The row is the button**, not the circle inside it — the anatomy every
/// actionable reward row shares (*"Span, not button: the row itself is the
/// button"*). The design's own offer wires the tap to the 38-px circle alone,
/// which is under every platform's minimum touch target; the row is well over
/// it and the circle still reads as the affordance.
/// The row is the button, not the 38-px circle inside it: the design wires the
/// tap to that circle alone, which is under every platform's minimum touch
/// target. The lesson ending still runs the older [ChallengeSuggestion] card,
/// and converging the two onto this row is #490's job rather than this one's.
class ChallengeOfferRow extends StatelessWidget {
/// Creates a [ChallengeOfferRow].
const ChallengeOfferRow({
Expand Down Expand Up @@ -74,6 +62,7 @@ class ChallengeOfferRow extends StatelessWidget {
return Semantics(
button: true,
label: '$kicker. $_detail. $startAction',
onTap: onStart,
excludeSemantics: true,
child: InkWell(
onTap: onStart,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class _Chip extends StatelessWidget {
button: true,
selected: selected,
label: label,
onTap: onTap,
excludeSemantics: true,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadii.pill),
Expand Down
17 changes: 7 additions & 10 deletions lib/features/companion/presentation/roasty_moment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,9 @@ import 'package:flutter/material.dart';
/// The full-screen beat a reward route opens on: the companion, an eyebrow, a
/// headline, and a bar that runs down to the content behind it.
///
/// **A beat, not a screen.** It owns no content and no navigation — it holds
/// the frame for [hold], then hands over through [onDone]. Every reward route
/// in the design opens on one, so it is built shared rather than by whichever
/// route landed first.
///
/// ⚠️ **[onDone] fires exactly once, and fires under reduced motion too.**
/// A tap and the timer race by construction, and a host that sequences its
/// screen behind this callback stalls forever if stillness swallows it — the
/// same discipline the tree's growth is held to (ADR-0011).
/// A beat, not a screen: it owns no content and no navigation, holding the
/// frame for [hold] and then handing over through [onDone]. Every reward route
/// in the design opens on one, so it is built shared.
class RoastyMoment extends StatefulWidget {
/// Creates a [RoastyMoment].
const RoastyMoment({
Expand Down Expand Up @@ -47,7 +41,9 @@ class RoastyMoment extends StatefulWidget {
/// The headline, which varies with what the run actually did.
final String title;

/// Called once, when the beat is over — by the timer or by a tap.
/// Called once, when the beat is over — by the timer or by a tap, and under
/// reduced motion too, so a host sequencing behind it never stalls
/// (ADR-0011).
final VoidCallback onDone;

/// The companion's rendered size in the beat.
Expand Down Expand Up @@ -121,6 +117,7 @@ class _RoastyMomentState extends State<RoastyMoment>
button: true,
label: '${widget.eyebrow}. ${widget.title}',
hint: 'Tap to continue',
onTap: _finish,
excludeSemantics: true,
child: Stack(
children: [
Expand Down
1 change: 1 addition & 0 deletions lib/features/dictionary/presentation/category_index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class _CategoryRow extends StatelessWidget {
return Semantics(
button: true,
label: '${category.label}, $count terms. ${category.summary}',
onTap: onOpen,
excludeSemantics: true,
child: InkWell(
onTap: onOpen,
Expand Down
15 changes: 9 additions & 6 deletions lib/features/dictionary/presentation/dictionary_quick_chips.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// The drill row on Dictionary home — one slim chip per practice surface the
/// dictionary owns.
///
/// Both chips now (#97, #98), in the design's own order: Flashcards leads
/// because it drills what the learner chose to keep, and only it carries a
/// count — the design gives *Guess the term* none, since the whole glossary
/// is not a number worth reading.
/// In the design's own order: Flashcards leads because it drills what the
/// learner chose to keep, and only it carries a count — the design gives
/// *Guess the term* none (#97, #98).
class DictionaryQuickChips extends ConsumerWidget {
/// Creates a [DictionaryQuickChips].
const DictionaryQuickChips({super.key});
Expand Down Expand Up @@ -56,19 +55,21 @@ class _FlashcardsChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mood = context.mood;
void open() => unawaited(context.pushActivity(flashcardReview));

return Semantics(
button: true,
label: cards == null
? FlashcardsCopy.title
: '${FlashcardsCopy.title}, ${FlashcardsCopy.deckLine(cards!)}',
onTap: open,
excludeSemantics: true,
child: Material(
color: mood.surface,
borderRadius: BorderRadius.circular(AppRadii.chrome),
child: InkWell(
borderRadius: BorderRadius.circular(AppRadii.chrome),
onTap: () => unawaited(context.pushActivity(flashcardReview)),
onTap: open,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: mood.rule),
Expand Down Expand Up @@ -116,18 +117,20 @@ class _VocabChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mood = context.mood;
void open() => unawaited(context.pushActivity(vocabGame));

return Semantics(
button: true,
label: VocabCopy.title,
hint: VocabCopy.rowSubtitle,
onTap: open,
excludeSemantics: true,
child: Material(
color: mood.surface,
borderRadius: BorderRadius.circular(AppRadii.chrome),
child: InkWell(
borderRadius: BorderRadius.circular(AppRadii.chrome),
onTap: () => unawaited(context.pushActivity(vocabGame)),
onTap: open,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: mood.rule),
Expand Down
1 change: 1 addition & 0 deletions lib/features/dictionary/presentation/flashcard_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class _FlashcardViewState extends State<FlashcardView>
hint: widget.isRevealed
? FlashcardsCopy.tapToSeeTerm
: FlashcardsCopy.tapToReveal,
onTap: widget.onFlip,
excludeSemantics: true,
child: GestureDetector(
onTap: widget.onFlip,
Expand Down
1 change: 1 addition & 0 deletions lib/features/dictionary/presentation/speak_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class _SpeakChipState extends ConsumerState<_SpeakChip> {
return Semantics(
button: true,
label: 'Pronounce ${widget.word}',
onTap: _speak,
excludeSemantics: true,
child: InkWell(
onTap: _speak,
Expand Down
13 changes: 9 additions & 4 deletions lib/features/dictionary/presentation/term_entry_body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,17 +242,22 @@ class _PathBlock extends ConsumerWidget {
// Until the place resolves there is nothing honest to show — an id is not
// an answer, so the row simply has no text yet.
final title = place?.title ?? '';
final open = id == null
? null
: () => unawaited(context.pushActivity(lessonRun(id)));

return _Block(
label: status.pathLabel,
child: Semantics(
button: true,
// Only a button once there is an id to open: before the place
// resolves there is nothing to press, and saying otherwise is the
// announcement this row is being fixed for (#487).
button: open != null,
label: '$title, ${accessible ? 'opens the lesson' : 'locked'}',
onTap: open,
excludeSemantics: true,
child: InkWell(
onTap: id == null
? null
: () => unawaited(context.pushActivity(lessonRun(id))),
onTap: open,
child: Row(
children: [
ClipRRect(
Expand Down
22 changes: 8 additions & 14 deletions lib/features/dictionary/presentation/term_full_entry_gate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,10 @@ import 'package:flutter/material.dart';

/// Where the full entry would be, for a learner without the course.
///
/// The deep explanation, the example, the self-check and the sources come
/// with the course (`docs/decisions.md` §12), so the entry stops at its short
/// explanation and this row stands in for the rest. It is the offer at the
/// moment the learner wants more — ADR-0005's *"targeted course pitch at peak
/// intent"* — and it says what would open it rather than only that it is
/// shut (ADR-0016).
///
/// **The label promises the full entry**, so the tap raises the gate; it must
/// never deliver the short explanation they are already reading.
///
/// One lock, drawn in accent, because it is a purchase lock and not a
/// progression one — ADR-0016's rule for every locked row.
/// The deep explanation, example, self-check and sources come with the course
/// (`docs/decisions.md` §12), so the entry stops at its short explanation and
/// this row stands in — saying what would open it, in accent because it is a
/// purchase lock rather than a progression one (ADR-0016).
class TermFullEntryGate extends StatelessWidget {
/// Creates a [TermFullEntryGate] for the term called [term].
const TermFullEntryGate({required this.term, super.key});
Expand All @@ -38,14 +30,16 @@ class TermFullEntryGate extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mood = context.mood;
void openGate() =>
unawaited(showPlusGate(context, LockedFullEntry(term: term)));

return Semantics(
button: true,
label: TermEntryCopy.gateSemantics,
onTap: openGate,
excludeSemantics: true,
child: InkWell(
onTap: () =>
unawaited(showPlusGate(context, LockedFullEntry(term: term))),
onTap: openGate,
borderRadius: BorderRadius.circular(AppRadii.chrome),
child: Container(
padding: const EdgeInsets.all(AppSpacing.base),
Expand Down
14 changes: 4 additions & 10 deletions lib/features/learn/presentation/practice/replay_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,9 @@ import 'package:flutter/material.dart';
/// One row of the practice shelf: what it drills, its name, what it costs or
/// takes, and the replay mark — or a lock.
///
/// **The row is not a card.** The design draws `.tap-row` flat on the page,
/// with a press highlight and nothing else around it; the shelf reads as one
/// list, not as a stack of boxes. The highlight bleeds a stop past the text
/// on each side (`margin: 0 -8px; padding: 12px 8px`), which is why the shelf
/// sits that stop inside the page gutter and every row pads it back.
///
/// Every practice list draws this one row — a finished lesson, a dictionary
/// drill, a mini-game — with an [icon] only where the design gives the kind
/// one: the lessons and the two drills. A game's kind is on its group's
/// heading, never repeated per row.
/// Flat on the page, not a card: its press highlight bleeds a stop past the
/// text (`margin: 0 -8px; padding: 12px 8px`), so the shelf sits that stop
/// inside the page gutter and every row pads it back.
class ReplayRow extends StatelessWidget {
/// Creates a [ReplayRow].
const ReplayRow({
Expand Down Expand Up @@ -89,6 +82,7 @@ class ReplayRow extends StatelessWidget {
button: true,
label: _announcement,
hint: hint,
onTap: onTap,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ class _CueRow extends StatelessWidget {
label: isTell
? '${cue.label}. $body. This was the tell.'
: '${cue.label}. $body',
onTap: onTap,
excludeSemantics: true,
child: Material(
// A closed cue has to *look* closed, before its words are read: the
Expand Down
4 changes: 3 additions & 1 deletion lib/features/lessons/presentation/cards/card_cue_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ class _HelpButton extends ConsumerWidget {
final mood = context.mood;
final help = ref.watch(cardKindHelpProvider(cue)).asData?.value;
if (help == null) return const SizedBox.shrink();
void openHelp() => showHelpDrawer(context, help);

return Semantics(
button: true,
label: howToPlayLabel,
onTap: openHelp,
excludeSemantics: true,
child: InkResponse(
onTap: () => showHelpDrawer(context, help),
onTap: openHelp,
radius: _target / 2,
child: SizedBox(
width: _target,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class CardOptionTile extends StatelessWidget {
button: true,
enabled: onTap != null,
label: semanticsLabel,
onTap: onTap,
excludeSemantics: true,
child: OutlinedButton(
onPressed: onTap,
Expand Down
1 change: 1 addition & 0 deletions lib/features/lessons/presentation/cards/match_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class MatchTile extends StatelessWidget {
button: onTap != null,
selected: state == MatchTileState.held,
label: semanticsLabel,
onTap: onTap,
excludeSemantics: true,
child: Opacity(
opacity: dragging ? OffTokens.matchDraggingOpacity.value : 1,
Expand Down
1 change: 1 addition & 0 deletions lib/features/lessons/presentation/cards/pick_tile_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class _PickTile extends StatelessWidget {
button: true,
selected: chosen,
label: text,
onTap: onTap,
excludeSemantics: true,
child: OutlinedButton(
onPressed: onTap,
Expand Down
Loading
Loading