diff --git a/docs/02-architecture.md b/docs/02-architecture.md index c8bb1f09..5e1df819 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -98,6 +98,38 @@ Each feature owns its own data, domain, and presentation layers. Shared code goe --- +## Sheets — one door, one dressing + +Every bottom sheet opens through `showAppSheet` (`lib/core/widgets/app_sheet.dart`), +and a guard test fails the build on one opened anywhere else. The chrome is +identical across all nine sheet types the design specifies, which is why one +function serves them all — callers supply only what is inside. + +- **The barrier** is `OverlayColors.dimModal`, the app's one blocking overlay, + and the corners are `AppRadii.chrome`, which names bottom sheets among the + surfaces it is for. The dim arrives as an `AppOverlay` — colour *and* the + design's 5px blur — through `OverlayBarrier`. That is why the route is pushed + by hand: the theme and `showModalBottomSheet` can each carry a barrier colour, + and neither can carry the blur that goes with it. +- **`title` is the sheet's only name.** It is the heading every sheet opens on + and the accessible name of the sheet as a region; one string feeds both so + they cannot drift, which a second label parameter had already allowed. +- **`eyebrow`** is the kicker the design sets over a sheet's title. The design + has two — the how-to-play drawer's *How to play* and the duel's *SHARE TO* — + of which the app has built the first. + +Two rules the design states are deliberately **not** implemented, because +Flutter satisfies both for free and porting them would re-solve a DOM problem: + +- *Sheets stack.* The design lifts its gate sheet onto a higher z-index pair + because the web has no navigator stack. Here the navigator stack **is** the + z-order, so a sheet opened from inside a sheet already renders above it. +- *Root-level sheets are dismissed on navigation.* Flutter does this in all + three navigation shapes this app performs — a route change, a shell branch + switch, and a push inside a branch. + +--- + ## The type ladder — `AppText` `lib/shared/theme/app_text.dart` is the only place a font size exists. Ten diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4652a58f..4ad31cec 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -44,6 +44,15 @@ You can always edit this file by hand instead — the helpers just save effort. ### Added +- **A card says what it is, and shows you how to play it.** The ten formats + with instructions — multiple choice, select all, match, calibrate, put in + order, true or false, tasting, taste fix, blind bag and complete the sentence + — now open on their name in the accent, with a `?` beside it. Tapping it + opens the design's how-to-play drawer: what the format is, in a sentence, and + the three steps to play it. The words are the design's own. The five kinds + that carry an authored eyebrow instead are untouched, and a round in a + mini-game reads exactly as it does in a lesson. + - **Checks run before code leaves the machine.** `tool/git-hooks/` fails a commit on an unformatted Dart file or an over-long comment, and a push on anything the metrics, a guard test, the comment check or the changelog diff --git a/docs/design/03-design-system.md b/docs/design/03-design-system.md index 63a61ae0..9a9a400b 100644 --- a/docs/design/03-design-system.md +++ b/docs/design/03-design-system.md @@ -92,6 +92,7 @@ app this axis is `AppTracking` (`lib/shared/theme/app_text.dart`). | 0.08 | `meta` | A meta line or figure that must read as one run — at 0.14em a count's numerals drift apart: `.lesson-row .meta`, `.challenge-pill`, `.bag-opt-s`. | | 0.10 | `tag` | A word set as a pill naming a state: the tastefix card's symptom chips and its Balanced state, `.cheer-points`. | | 0.12 | `hint` | The sequence card's out-of-place hint `.seq-hint`, and the practice shelf's meta line and count. | +| 0.14 | `smallcaps` | The smallcaps rule itself, named for a rung that does not default to it — the card kind cue, set at the support step. | | 0.16 | `marker` | A mono micro line marking what a thing *is*, wider than the smallcaps rule so a two-word label reads as discrete: the dictionary's status chip, `.collect-card .cc-sub`. | | 0.18 | `chrome` | The app's own frame, a step wider than the pages it frames: the tab bar's label, and the sticky header's compact-title eyebrow. | diff --git a/docs/design/06-content.md b/docs/design/06-content.md index f37fa204..79cf668e 100644 --- a/docs/design/06-content.md +++ b/docs/design/06-content.md @@ -65,8 +65,9 @@ became active ones. `intro` and `takeaway` are the leftovers of that change and are candidates for deletion rather than authoring. **Help drawer.** `CARD_KIND_HELP` (`lesson.jsx:9`) holds title + blurb + 3 -numbered steps, surfaced from a "?" button in the lesson top bar. It has **10 -entries and does not cover every kind**: `mcq` · `multi` · `match` · `slider` · +numbered steps, surfaced from a "?" button **beside the card's kind cue** — +not in the lesson top bar, which earlier versions of this doc claimed. It has +**10 entries and does not cover every kind**: `mcq` · `multi` · `match` · `slider` · `sequence` · `tastefix` · `bagpick` · `fill` (the concept fill-in-the-blank) · plus `quiz` and `flavor` for the mini-games. The "?" button simply does not render for a kind with no entry — so `decision`, `recall`, `predict`, `visual` diff --git a/docs/design/11-open-items.md b/docs/design/11-open-items.md index ae0e8fc9..c408c939 100644 --- a/docs/design/11-open-items.md +++ b/docs/design/11-open-items.md @@ -141,6 +141,8 @@ disagreement rule by rule, and built at | Item | Detail | |---|---| +| **The card cue sits taller than the design's** | The design pulls its `?` back onto the cue line with `margin: '-13px -8px'`, so a 44px touch area does not set the height of a one-line eyebrow. CSS hit-tests the padding box whatever the layout size; Flutter rejects a tap outside the parent's own box, so porting the bleed with an `OverflowBox` silently shrinks the target to 28×18 — measured, not assumed. The 44 target wins and the line is taller by that much. **No action:** this is the trade, not a gap | +| **The help drawer ships without its icon well** | Seven of the ten kinds with help carry a mark; `mcq`, `multi` and `fill` do not, and a well filled on seven rows beside three empty ones is the fault [#436](https://github.com/maximsan/brewpath/issues/436) already ruled out. Owned by [#567](https://github.com/maximsan/brewpath/issues/567), which draws the three marks and adds the well | | **`tools/extract-facts.js` reads a dead field** | Line 86 pulls `L[i].xp`, which is `null` for every lesson — per-lesson points live on the module entries. Harmless, but this reference's own tooling still carries the legacy name | | ~~**`CLAUDE.md` points at the wrong app path**~~ **RESOLVED** | It documented the Flutter app as living in `coffee_quest/` while `lib/` and `test/` are at the repo root. Both halves are now fixed: the app was renamed to package `brew_path` ([#41](https://github.com/maximsan/brewpath/issues/41)) and the project instructions were corrected to the real root-relative layout ([#35](https://github.com/maximsan/brewpath/issues/35)). The note that project instructions sit outside this reference's remit still holds — recorded here only because this table raised it | diff --git a/lib/core/widgets/app_sheet.dart b/lib/core/widgets/app_sheet.dart index a2fb54b6..61a00841 100644 --- a/lib/core/widgets/app_sheet.dart +++ b/lib/core/widgets/app_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:brew_path/core/widgets/overlay_barrier.dart'; +import 'package:brew_path/core/widgets/smallcaps_label.dart'; import 'package:brew_path/shared/theme/app_overlay.dart'; import 'package:brew_path/shared/theme/app_radii.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; @@ -17,39 +18,15 @@ const double _maxHeightFraction = 0.78; /// Presents [builder] as a bottom sheet, wearing the app's one sheet dressing. /// -/// **Every sheet opens through here.** The chrome below is identical across all -/// nine sheet types the design specifies, which is why one function can serve -/// them all — callers supply only what is inside. A guard test fails the build -/// on a sheet opened anywhere else, because the first sheet carried a comment -/// inviting the second to generalise it and the second was written raw -/// anyway. -/// -/// The barrier is [OverlayColors.dimModal], whose own doc names this as the -/// app's one blocking overlay, and the corners are [AppRadii.chrome], which -/// names bottom sheets among the surfaces it is for. The dim arrives as an -/// [AppOverlay] — colour *and* the design's 5px blur — through -/// [OverlayBarrier], which is why the route below is pushed by hand: the theme -/// and `showModalBottomSheet` can both carry a barrier colour, and neither can -/// carry the blur that goes with it. -/// -/// [title] is required and is the sheet's *only* name: it is rendered as the -/// heading every sheet opens on, and it is the accessible name of the sheet as -/// a region. One string feeds both so they cannot drift — which is what a -/// second, separate label parameter had already allowed. -/// -/// Two rules the design states are **not** implemented here, because Flutter -/// satisfies both for free and porting them would be re-solving a DOM problem: -/// -/// - *Sheets stack.* The design lifts its gate sheet onto a higher z-index pair -/// because the web has no navigator stack. Here the navigator stack **is** -/// the z-order, so a sheet from inside a sheet already renders above it. -/// - *Root-level sheets are dismissed on navigation.* Flutter does this in all -/// three navigation shapes this app performs — a route change, a shell branch -/// switch, and a push inside a branch. +/// **Every sheet opens through here**, and a guard test fails the build on one +/// opened anywhere else. [title] is the sheet's *only* name — the heading and +/// the accessible name both — and [eyebrow] is the kicker the design sets over +/// it. The chrome and what it deliberately omits: `docs/02-architecture.md`. Future showAppSheet({ required BuildContext context, required String title, required WidgetBuilder builder, + String? eyebrow, }) { final mood = context.mood; final settleAtOnce = _restingControllerForReducedMotion(context); @@ -85,6 +62,7 @@ Future showAppSheet({ label: title, child: _SheetFrame( title: title, + eyebrow: eyebrow, child: Builder(builder: builder), ), ), @@ -103,12 +81,9 @@ Future showAppSheet({ /// An already-elapsed controller when the platform asks for reduced motion, /// or null to let the default transition run. /// -/// `ModalBottomSheetRoute` does not consult -/// [MediaQueryData.disableAnimations] — measured, not assumed: the slide is -/// identical either way. Handing it a -/// zero-duration controller is the only supported hook, and it lands the sheet -/// at rest on the first frame. It needs a [TickerProvider], which a top-level -/// function does not have and the navigator does. +/// `ModalBottomSheetRoute` does not consult [MediaQueryData.disableAnimations] +/// — measured, not assumed. A zero-duration controller is the only supported +/// hook, and it needs the [TickerProvider] the navigator has. AnimationController? _restingControllerForReducedMotion(BuildContext context) { if (!MediaQuery.disableAnimationsOf(context)) return null; @@ -120,9 +95,14 @@ AnimationController? _restingControllerForReducedMotion(BuildContext context) { /// The chrome every sheet wears: handle, title, insets and a scrolling cap. class _SheetFrame extends StatelessWidget { - const _SheetFrame({required this.title, required this.child}); + const _SheetFrame({ + required this.title, + required this.child, + this.eyebrow, + }); final String title; + final String? eyebrow; final Widget child; @override @@ -155,6 +135,10 @@ class _SheetFrame extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.md), + if (eyebrow != null) ...[ + SmallcapsLabel(eyebrow!, color: mood.accent), + const SizedBox(height: AppSpacing.xxs), + ], // "Every sheet opens on its title, in the same display face at // the same size" — the design's own rule, enforceable only from // in here. diff --git a/lib/features/lessons/presentation/cards/bagpick_card_view.dart b/lib/features/lessons/presentation/cards/bagpick_card_view.dart index 194793da..a84c2e96 100644 --- a/lib/features/lessons/presentation/cards/bagpick_card_view.dart +++ b/lib/features/lessons/presentation/cards/bagpick_card_view.dart @@ -2,6 +2,7 @@ import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/core/widgets/dashed_rounded_border.dart'; import 'package:brew_path/features/lessons/presentation/cards/bagpick_bean_view.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_tints.dart'; import 'package:brew_path/features/lessons/presentation/cards/choice_list.dart'; @@ -24,20 +25,10 @@ const Map _processLabels = { /// An unlabelled bag, a sample of three seeds, and three things to inspect. /// -/// The only kind here that is not a picker with decoration. Its mechanic is -/// **investigate, then call it**, and three details carry that: -/// -/// * the bag's process is *withheld* until the learner commits, because a card -/// showing the answer while asking the question is not asking anything; -/// * each cue is hidden until tapped, and tapping is optional — someone who is -/// sure may call it from the beans alone, and confidence is worth rewarding; -/// * the feedback names **which cue was the real tell**, which is the round's -/// whole teaching payload. A version that graded the pick and skipped the -/// tell would satisfy every other rule here and teach nothing: the lesson is -/// "this is what you should have looked at", not "you were wrong". -/// -/// Option identity is the **process key**, never the position — the seeded -/// order moves what is on screen and nothing keys off an index. +/// **Investigate, then call it**: the process is withheld until the learner +/// commits, each cue is hidden until tapped and tapping is optional, and the +/// feedback names which cue was the real tell — the round's whole teaching +/// payload. Option identity is the process key, never the position. class BagpickCardView extends StatefulWidget { /// Creates a [BagpickCardView]. const BagpickCardView({ @@ -110,6 +101,7 @@ class _BagpickCardViewState extends State { final card = widget.card; return CardShell( + cue: CardCue.bagpick, latched: _latched, onContinue: widget.onContinue, children: [ diff --git a/lib/features/lessons/presentation/cards/card_cue.dart b/lib/features/lessons/presentation/cards/card_cue.dart new file mode 100644 index 00000000..4f1d4ecb --- /dev/null +++ b/lib/features/lessons/presentation/cards/card_cue.dart @@ -0,0 +1,62 @@ +import 'package:brew_path/shared/models/content/card_kind_help.dart'; +import 'package:brew_path/shared/repositories/card_kind_help_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'card_cue.g.dart'; + +/// The line a card opens on, naming the format it is asking the learner to +/// play — and the key to the drawer explaining how. +/// +/// The ten kinds with help are exactly the ten values here, so a card that +/// carries a cue always has something behind its `?`. The other five kinds +/// open on their own authored eyebrow and take no cue at all. +enum CardCue { + /// Pick one of four. + mcq('Multiple choice · pick one'), + + /// Pick every answer that belongs, then check them together. + multi('Select all that apply'), + + /// Drag each trait onto what it belongs to. + match('Match · drag to pair'), + + /// Dial a slider to where the answer lands. + slider('Calibrate · dial to the target'), + + /// Tap the items into their right order. + sequence('Put in order · tap in sequence'), + + /// Decide whether a statement holds. + quiz('True or false'), + + /// Name the note behind a tasting clue. + flavor('Tasting · name the note'), + + /// Choose the fix for a cup that came out wrong. + tastefix('Taste Fix'), + + /// Call the process from an unlabelled bag's beans. + bagpick('Blind bag · read the beans'), + + /// Fill the blanks in a sentence. + fill('Complete the sentence'); + + const CardCue(this.phrase); + + /// What the cue reads, as the design writes it. Set upper case by the type + /// rule rather than here, so assistive technology is given it as written. + final String phrase; + + /// The `kind` this cue's entry carries in the bundled help bank, which is + /// the enum's own name for all ten. + String get helpKey => name; +} + +/// How the kind behind [cue] is played. +/// +/// Keyed on the cue rather than a raw kind string: every [CardCue] has an +/// entry, so a caller cannot ask this about a format the design never wrote +/// help for. Null only if the bundled bank has lost the entry. +@riverpod +Future cardKindHelp(Ref ref, CardCue cue) => + ref.watch(cardKindHelpRepositoryProvider).getForKind(cue.helpKey); diff --git a/lib/features/lessons/presentation/cards/card_cue.g.dart b/lib/features/lessons/presentation/cards/card_cue.g.dart new file mode 100644 index 00000000..4c761f32 --- /dev/null +++ b/lib/features/lessons/presentation/cards/card_cue.g.dart @@ -0,0 +1,113 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'card_cue.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// How the kind behind [cue] is played. +/// +/// Keyed on the cue rather than a raw kind string: every [CardCue] has an +/// entry, so a caller cannot ask this about a format the design never wrote +/// help for. Null only if the bundled bank has lost the entry. + +@ProviderFor(cardKindHelp) +final cardKindHelpProvider = CardKindHelpFamily._(); + +/// How the kind behind [cue] is played. +/// +/// Keyed on the cue rather than a raw kind string: every [CardCue] has an +/// entry, so a caller cannot ask this about a format the design never wrote +/// help for. Null only if the bundled bank has lost the entry. + +final class CardKindHelpProvider + extends + $FunctionalProvider< + AsyncValue, + CardKindHelp?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// How the kind behind [cue] is played. + /// + /// Keyed on the cue rather than a raw kind string: every [CardCue] has an + /// entry, so a caller cannot ask this about a format the design never wrote + /// help for. Null only if the bundled bank has lost the entry. + CardKindHelpProvider._({ + required CardKindHelpFamily super.from, + required CardCue super.argument, + }) : super( + retry: null, + name: r'cardKindHelpProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cardKindHelpHash(); + + @override + String toString() { + return r'cardKindHelpProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as CardCue; + return cardKindHelp(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is CardKindHelpProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$cardKindHelpHash() => r'be617581732c9f64259c169ef2d332ef2d740dd9'; + +/// How the kind behind [cue] is played. +/// +/// Keyed on the cue rather than a raw kind string: every [CardCue] has an +/// entry, so a caller cannot ask this about a format the design never wrote +/// help for. Null only if the bundled bank has lost the entry. + +final class CardKindHelpFamily extends $Family + with $FunctionalFamilyOverride, CardCue> { + CardKindHelpFamily._() + : super( + retry: null, + name: r'cardKindHelpProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// How the kind behind [cue] is played. + /// + /// Keyed on the cue rather than a raw kind string: every [CardCue] has an + /// entry, so a caller cannot ask this about a format the design never wrote + /// help for. Null only if the bundled bank has lost the entry. + + CardKindHelpProvider call(CardCue cue) => + CardKindHelpProvider._(argument: cue, from: this); + + @override + String toString() => r'cardKindHelpProvider'; +} diff --git a/lib/features/lessons/presentation/cards/card_cue_row.dart b/lib/features/lessons/presentation/cards/card_cue_row.dart new file mode 100644 index 00000000..f4f4a83f --- /dev/null +++ b/lib/features/lessons/presentation/cards/card_cue_row.dart @@ -0,0 +1,101 @@ +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; +import 'package:brew_path/features/lessons/presentation/cards/help_drawer.dart'; +import 'package:brew_path/shared/theme/app_radii.dart'; +import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// The mark the help button carries, which is the design's own. +const String _helpMark = '?'; + +/// The ring the mark sits in, and the target around it. +const double _markRing = 20; +const double _target = 44; + +/// A card's kind, named in the accent, with the `?` that explains it. +/// +/// The cue is set at the support step rather than the label step the app's +/// other eyebrows use — the design writes `fontSize: 'var(--t-support)'` over +/// its smallcaps rule — which is also why it keeps the accent rather than the +/// darkened mix that covers accent text at the label step. +class CardCueRow extends StatelessWidget { + /// Creates a [CardCueRow] for [cue]. + const CardCueRow({required this.cue, super.key}); + + /// Which format this card is playing. + final CardCue cue; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + + return Row( + children: [ + Flexible( + child: Semantics( + label: cue.phrase, + excludeSemantics: true, + child: Text( + cue.phrase.toUpperCase(), + // `lineHeight: 1` — the cue is one line of chrome over the + // card, not a paragraph, and the rung's 1.4 would pad it. + style: AppText.support( + face: AppFace.control, + color: mood.accent, + tracking: AppTracking.smallcaps, + ).copyWith(height: 1), + ), + ), + ), + const SizedBox(width: AppSpacing.xs), + _HelpButton(cue: cue), + ], + ); + } +} + +/// The `?` beside a cue. Draws nothing until the bank hands back the entry it +/// would open, so the affordance and what it opens arrive together. +class _HelpButton extends ConsumerWidget { + const _HelpButton({required this.cue}); + + final CardCue cue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mood = context.mood; + final help = ref.watch(cardKindHelpProvider(cue)).asData?.value; + if (help == null) return const SizedBox.shrink(); + + return Semantics( + button: true, + label: howToPlayLabel, + excludeSemantics: true, + child: InkResponse( + onTap: () => showHelpDrawer(context, help), + radius: _target / 2, + child: SizedBox( + width: _target, + height: _target, + child: Center( + child: Container( + width: _markRing, + height: _markRing, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: mood.rule), + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + child: Text( + _helpMark, + style: AppText.label(face: AppFace.mono, color: mood.inkMute), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/lessons/presentation/cards/card_shell.dart b/lib/features/lessons/presentation/cards/card_shell.dart index 61293a8a..44518667 100644 --- a/lib/features/lessons/presentation/cards/card_shell.dart +++ b/lib/features/lessons/presentation/cards/card_shell.dart @@ -1,6 +1,8 @@ import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/widgets/primary_button.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue_row.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; @@ -24,22 +26,17 @@ class CardCommit { /// The frame every card renders inside. /// -/// It owns one rule, and it owns it in one place so no card can forget it: -/// continuing is gated on the card having latched. A card that has not been -/// answered has no way forward, and there is no second label for a wrong -/// answer — moving on is moving on, whatever the learner scored. -/// -/// A kind that commits **separately** from answering — `multi`, where a set is -/// not finished until the learner says so — passes [commit]. The shell then -/// shows that action in place of Continue until the card latches, which is the -/// design's single swapping button and keeps the rule here rather than letting -/// a card draw a second one of its own. +/// It owns one rule so no card can forget it: continuing is gated on the card +/// having latched, and a wrong answer gets no second label. A kind that commits +/// separately from answering passes [commit], and the shell swaps that action +/// for Continue rather than letting the card draw a second way forward. class CardShell extends StatelessWidget { /// Creates a [CardShell]. const CardShell({ required this.latched, required this.onContinue, required this.children, + this.cue, this.label, this.title, this.commit, @@ -55,6 +52,11 @@ class CardShell extends StatelessWidget { /// The card's own body. final List children; + /// The format this card is playing, named in the accent with the `?` that + /// explains it. The ten kinds the design writes help for pass one; the five + /// that carry an authored eyebrow instead pass [label]. + final CardCue? cue; + /// Small-caps eyebrow, e.g. `CONCEPT` or `AT THE SHELF`. final String? label; @@ -74,6 +76,10 @@ class CardShell extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (cue case final cue?) ...[ + CardCueRow(cue: cue), + const SizedBox(height: AppSpacing.sm), + ], if (label != null) ...[ Text( label!, diff --git a/lib/features/lessons/presentation/cards/concept_card_view.dart b/lib/features/lessons/presentation/cards/concept_card_view.dart index 932a2596..1ff0e59c 100644 --- a/lib/features/lessons/presentation/cards/concept_card_view.dart +++ b/lib/features/lessons/presentation/cards/concept_card_view.dart @@ -3,6 +3,7 @@ import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/core/widgets/fill_slot.dart'; import 'package:brew_path/features/lessons/domain/concept_card_parts.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/concept_fill_bank.dart'; import 'package:brew_path/features/lessons/presentation/cards/concept_fill_state.dart'; @@ -75,7 +76,10 @@ class _ConceptCardViewState extends State { return CardShell( latched: nothingToFill || _checked, onContinue: widget.onContinue, - label: card.label, + // The design branches the same way: a card with blanks opens on the fill + // cue, one without keeps the eyebrow its author wrote. + cue: nothingToFill ? null : CardCue.fill, + label: nothingToFill ? card.label : null, title: card.title, commit: nothingToFill ? null diff --git a/lib/features/lessons/presentation/cards/content_card_view.dart b/lib/features/lessons/presentation/cards/content_card_view.dart index 726ac0a6..3ead95b4 100644 --- a/lib/features/lessons/presentation/cards/content_card_view.dart +++ b/lib/features/lessons/presentation/cards/content_card_view.dart @@ -3,6 +3,7 @@ import 'package:brew_path/features/lessons/domain/card_seed.dart'; import 'package:brew_path/features/lessons/domain/held_guess.dart'; import 'package:brew_path/features/lessons/presentation/cards/bagpick_card_view.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/choice_list.dart'; import 'package:brew_path/features/lessons/presentation/cards/concept_card_view.dart'; import 'package:brew_path/features/lessons/presentation/cards/graded_picker.dart'; @@ -155,6 +156,7 @@ List _quizOptions(QuizCard card) => [ /// and produced a round nobody could win, caught only because the options half /// already had a name to test against. These are the other half. PickerCopy _mcqCopy(McqCard card) => PickerCopy( + cue: CardCue.mcq, prompt: card.prompt, explain: ({required wasCorrect}) => card.explanation, ); @@ -185,6 +187,7 @@ PickerCopy _decisionCopy(DecisionCard card) => PickerCopy( ); PickerCopy _quizCopy(QuizCard card) => PickerCopy( + cue: CardCue.quiz, prompt: card.statement, explain: ({required wasCorrect}) => card.explanation, ); @@ -211,6 +214,7 @@ TastefixReaction _cupAfter(PickOutcome outcome) => switch (outcome) { /// Only the question and what closes it: the symptoms and the setup are the /// cup's, drawn in the panel above rather than in a copy slot. PickerCopy _tastefixCopy(TastefixCard card) => PickerCopy( + cue: CardCue.tastefix, prompt: card.prompt, explain: ({required wasCorrect}) => card.explanation, // A fix that worked, not an answer that was right. @@ -220,6 +224,7 @@ PickerCopy _tastefixCopy(TastefixCard card) => PickerCopy( /// The tasting clue takes the scenario slot: it is what the learner is reading /// *from*, set out before the question rather than being part of it. PickerCopy _flavorCopy(FlavorCard card) => PickerCopy( + cue: CardCue.flavor, scenario: card.clue, prompt: card.prompt, explain: ({required wasCorrect}) => card.explanation, diff --git a/lib/features/lessons/presentation/cards/graded_picker.dart b/lib/features/lessons/presentation/cards/graded_picker.dart index d35c7cf6..9ceaea61 100644 --- a/lib/features/lessons/presentation/cards/graded_picker.dart +++ b/lib/features/lessons/presentation/cards/graded_picker.dart @@ -1,5 +1,6 @@ import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/choice_list.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; @@ -29,6 +30,7 @@ class PickerCopy { const PickerCopy({ required this.prompt, required this.explain, + this.cue, this.label, this.title, this.scenario, @@ -44,6 +46,9 @@ class PickerCopy { /// right, because `decision` authors a separate reading for each outcome. final PickerLine explain; + /// The format this kind is playing, named above the question. + final CardCue? cue; + /// Small-caps eyebrow. final String? label; @@ -155,6 +160,7 @@ class _GradedPickerState extends State { return CardShell( latched: _latched, onContinue: widget.onContinue, + cue: copy.cue, label: copy.label, title: copy.title, children: [ diff --git a/lib/features/lessons/presentation/cards/help_drawer.dart b/lib/features/lessons/presentation/cards/help_drawer.dart new file mode 100644 index 00000000..4766e4c1 --- /dev/null +++ b/lib/features/lessons/presentation/cards/help_drawer.dart @@ -0,0 +1,92 @@ +import 'package:brew_path/core/widgets/app_sheet.dart'; +import 'package:brew_path/core/widgets/primary_button.dart'; +import 'package:brew_path/shared/models/content/card_kind_help.dart'; +import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/off_token.dart'; +import 'package:flutter/material.dart'; + +/// The kicker over the drawer's title. +const String howToPlayLabel = 'How to play'; + +/// What closes the drawer, which is the only thing it offers. +const String _dismissLabel = 'Got it'; + +/// Opens the drawer explaining how [help]'s format is played. +Future showHelpDrawer(BuildContext context, CardKindHelp help) => + showAppSheet( + context: context, + eyebrow: howToPlayLabel, + title: help.title, + builder: (_) => HelpDrawerBody(help: help), + ); + +/// What the drawer says: the format in a sentence, then the three steps. +/// +/// The words are the design's, extracted rather than written here, so a format +/// is explained the same way wherever it is met. +class HelpDrawerBody extends StatelessWidget { + /// Creates a [HelpDrawerBody]. + const HelpDrawerBody({required this.help, super.key}); + + /// The entry this drawer is reading. + final CardKindHelp help; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + help.blurb, + style: AppText.body(mood: mood, color: mood.inkMute), + ), + SizedBox(height: OffTokens.helpStepsTop.value), + for (final (index, step) in help.steps.indexed) ...[ + if (index > 0) SizedBox(height: OffTokens.helpStepGap.value), + _Step(ordinal: index + 1, text: step), + ], + const SizedBox(height: AppSpacing.lg), + PrimaryButton( + label: _dismissLabel, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + } +} + +/// One numbered step: its `01` in the accent, then what to do. +class _Step extends StatelessWidget { + const _Step({required this.ordinal, required this.text}); + + final int ordinal; + final String text; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$ordinal'.padLeft(2, '0'), + style: AppText.support( + face: AppFace.mono, + color: mood.accent, + tracking: AppTracking.count, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text(text, style: AppText.support(color: mood.ink)), + ), + ], + ); + } +} diff --git a/lib/features/lessons/presentation/cards/match_board_view.dart b/lib/features/lessons/presentation/cards/match_board_view.dart index 8f7b4d9a..947ac35d 100644 --- a/lib/features/lessons/presentation/cards/match_board_view.dart +++ b/lib/features/lessons/presentation/cards/match_board_view.dart @@ -1,5 +1,6 @@ import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/match_board.dart'; import 'package:brew_path/features/lessons/presentation/cards/match_fact_tile.dart'; @@ -20,14 +21,10 @@ const String _clearedNotClean = /// A board of facts and the answers they sort into. /// -/// Tap a fact, then tap where it belongs. A right placement locks the fact -/// away; a wrong one says so and leaves the fact in play, because the board -/// is finished by clearing it rather than by surviving it. The card pays its -/// one success signal only on a board cleared without a wrong drop. -/// -/// Nothing here knows what is hosting it: the mini-game player and the lesson -/// player both get this renderer unchanged, which is why it sits in the shared -/// card layer. +/// Tap a fact, then tap where it belongs. A right placement locks it away, a +/// wrong one leaves it in play — the board is finished by clearing it rather +/// than surviving it — and success is paid only on a board cleared with no +/// wrong drop. Hosted unchanged by the lesson player and the mini-games. class MatchBoardView extends StatefulWidget { /// Creates a [MatchBoardView]. const MatchBoardView({ @@ -127,7 +124,7 @@ class _MatchBoardViewState extends State { return CardShell( latched: _cleared, onContinue: widget.onContinue, - label: 'MATCH', + cue: CardCue.match, children: [ Text(widget.prompt, style: theme.textTheme.titleMedium), const SizedBox(height: AppSpacing.md), diff --git a/lib/features/lessons/presentation/cards/multi_card_view.dart b/lib/features/lessons/presentation/cards/multi_card_view.dart index 76c10490..b01e49cd 100644 --- a/lib/features/lessons/presentation/cards/multi_card_view.dart +++ b/lib/features/lessons/presentation/cards/multi_card_view.dart @@ -1,6 +1,7 @@ import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/choice_list.dart'; import 'package:brew_path/features/lessons/presentation/cards/multi_choice_list.dart'; @@ -10,9 +11,6 @@ import 'package:brew_path/shared/theme/app_text.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; -/// The cue above a multi card's prompt. -const String _cue = 'Select all that apply'; - /// Verdicts, which name the all-or-nothing rule rather than a score. const String _allCorrect = 'All correct'; @@ -97,7 +95,7 @@ class _MultiCardViewState extends State { return CardShell( latched: _submitted, onContinue: widget.onContinue, - label: _cue, + cue: CardCue.multi, commit: CardCommit( label: AppLabels.checkAnswers, onCommit: _selected.isEmpty ? null : _check, diff --git a/lib/features/lessons/presentation/cards/sequence_card_view.dart b/lib/features/lessons/presentation/cards/sequence_card_view.dart index d7ae68d9..21782a84 100644 --- a/lib/features/lessons/presentation/cards/sequence_card_view.dart +++ b/lib/features/lessons/presentation/cards/sequence_card_view.dart @@ -1,6 +1,7 @@ import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/core/widgets/link_button.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_option_tile.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_tints.dart'; @@ -12,9 +13,6 @@ import 'package:brew_path/shared/theme/app_text.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; -/// The cue above a sequence card's prompt. -const String _cue = 'PUT IN ORDER · TAP IN SEQUENCE'; - /// The commit affordance, before the run has been submitted. const String _submitLabel = 'Submit'; @@ -35,18 +33,10 @@ const String _arrow = ' → '; /// Put the steps in order: tap them into place, then commit the whole run. /// -/// Two things separate it from the picking kinds. Tapping **assigns a -/// position** rather than answering — tapping an assigned step takes it back -/// out and the rest renumber — and the card is not answered until the learner -/// says so, which is the shell's swapping button rather than a second action -/// drawn here. It is graded all-or-nothing: the run is the authored order or it -/// is not. See `card_boundary.dart`. -/// -/// It takes its steps **already in display order**, like every other card whose -/// order is seeded, so one place owns the seed and a replay moves every card -/// the same way. That matters more here than anywhere else — see -/// [sequenceDisplayOrder] for why a sequence card's shuffle is the one that can -/// hand the learner the answer. +/// Tapping **assigns a position** rather than answering, and the run is graded +/// all-or-nothing against the authored order (`card_boundary.dart`). It takes +/// its steps already in display order — [sequenceDisplayOrder] says why this +/// card's shuffle is the one that can hand the learner the answer. class SequenceCardView extends StatefulWidget { /// Creates a [SequenceCardView]. const SequenceCardView({ @@ -116,7 +106,7 @@ class _SequenceCardViewState extends State { return CardShell( latched: _submitted, onContinue: widget.onContinue, - label: _cue, + cue: CardCue.sequence, commit: CardCommit( label: _submitLabel, onCommit: _allPlaced ? _submit : null, @@ -141,11 +131,9 @@ class _SequenceCardViewState extends State { /// One step, in the shared option frame. /// - /// Its mark says three different things across the card's life: which - /// position the learner gave it, whether that position was right, and — when - /// it was not — where the step actually belonged. The last is the only place - /// a wrong run says anything per-step, which is the card's reaction to a - /// wrong answer happening inside it. + /// Its mark says three things across the card's life: the position the + /// learner gave it, whether that was right, and where the step belonged — + /// the only place a wrong run says anything per-step. Widget _step(MoodColors mood, int index) { final item = widget.items[index]; final position = _run.indexOf(index); diff --git a/lib/features/lessons/presentation/cards/slider_card_view.dart b/lib/features/lessons/presentation/cards/slider_card_view.dart index 3fdf78ad..7914437d 100644 --- a/lib/features/lessons/presentation/cards/slider_card_view.dart +++ b/lib/features/lessons/presentation/cards/slider_card_view.dart @@ -2,6 +2,7 @@ import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/widgets/answer_feedback.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart'; import 'package:brew_path/features/lessons/presentation/cards/grinder_dial_view.dart'; import 'package:brew_path/features/lessons/presentation/cards/slider_dial.dart'; @@ -12,9 +13,6 @@ import 'package:brew_path/shared/theme/app_text.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; -/// The cue above a calibrate card's prompt. -const String _cue = 'CALIBRATE · DIAL TO THE TARGET'; - /// The commit affordance, before the setting has been checked. const String _checkLabel = 'Check answer'; @@ -25,25 +23,20 @@ const String _dialedIn = 'Dialed in'; const String _yourSetting = 'Your setting'; const String _target = 'Target'; -/// Floor under the readout, so committing an answer never shifts the track out -/// from under the learner's finger. +/// Floor under the readout, so committing never shifts the track out from +/// under the learner's finger. /// -/// A floor rather than a fixed height, as the design source has it: the two -/// readings are one and two lines at the shipped text size, and at a large one -/// they are longer. A fixed height would hold the track still by clipping the -/// answer. +/// A floor rather than a fixed height, as the design has it: the readings run +/// one and two lines at the shipped text size and longer at a large one, and a +/// fixed height would hold the track still by clipping the answer. const double _readoutMinHeight = 58; /// Calibrate: drag to a value, then check it against a target band. /// /// Graded all-or-nothing — inside the band or not — because the boundary has -/// no way to say "close", and a distance score would have to mean something to -/// mastery. The rules it is judged by are in `slider_dial.dart`, with no widget -/// attached. See `card_boundary.dart`. -/// -/// Nothing here knows what is hosting it: the mini-game player and the lesson -/// player both get this renderer unchanged, which is why it sits in the shared -/// card layer. +/// no way to say "close" (`card_boundary.dart`). The rules it is judged by are +/// in `slider_dial.dart`, with no widget attached. Hosted unchanged by the +/// lesson player and the mini-games. class SliderCardView extends StatefulWidget { /// Creates a [SliderCardView]. const SliderCardView({ @@ -116,7 +109,7 @@ class _SliderCardViewState extends State { return CardShell( latched: _checked, onContinue: widget.onContinue, - label: _cue, + cue: CardCue.slider, commit: CardCommit( label: _checkLabel, onCommit: _touched ? _check : null, diff --git a/lib/shared/models/content/card_kind_help.dart b/lib/shared/models/content/card_kind_help.dart new file mode 100644 index 00000000..14922e29 --- /dev/null +++ b/lib/shared/models/content/card_kind_help.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'card_kind_help.freezed.dart'; +part 'card_kind_help.g.dart'; + +/// How one card kind is played, as the drawer behind a card's `?` reads it. +/// +/// Authored in the design and extracted verbatim, so the words a learner is +/// given for a format are the design's rather than the app's. +@freezed +abstract class CardKindHelp with _$CardKindHelp { + /// Creates a [CardKindHelp]. + const factory CardKindHelp({ + required String kind, + required String title, + required String blurb, + required List steps, + }) = _CardKindHelp; + + /// Creates a [CardKindHelp] from decoded JSON. + factory CardKindHelp.fromJson(Map json) => + _$CardKindHelpFromJson(json); +} diff --git a/lib/shared/models/content/card_kind_help.freezed.dart b/lib/shared/models/content/card_kind_help.freezed.dart new file mode 100644 index 00000000..8ee58803 --- /dev/null +++ b/lib/shared/models/content/card_kind_help.freezed.dart @@ -0,0 +1,292 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'card_kind_help.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CardKindHelp { + + String get kind; String get title; String get blurb; List get steps; +/// Create a copy of CardKindHelp +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CardKindHelpCopyWith get copyWith => _$CardKindHelpCopyWithImpl(this as CardKindHelp, _$identity); + + /// Serializes this CardKindHelp to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CardKindHelp&&(identical(other.kind, kind) || other.kind == kind)&&(identical(other.title, title) || other.title == title)&&(identical(other.blurb, blurb) || other.blurb == blurb)&&const DeepCollectionEquality().equals(other.steps, steps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,kind,title,blurb,const DeepCollectionEquality().hash(steps)); + +@override +String toString() { + return 'CardKindHelp(kind: $kind, title: $title, blurb: $blurb, steps: $steps)'; +} + + +} + +/// @nodoc +abstract mixin class $CardKindHelpCopyWith<$Res> { + factory $CardKindHelpCopyWith(CardKindHelp value, $Res Function(CardKindHelp) _then) = _$CardKindHelpCopyWithImpl; +@useResult +$Res call({ + String kind, String title, String blurb, List steps +}); + + + + +} +/// @nodoc +class _$CardKindHelpCopyWithImpl<$Res> + implements $CardKindHelpCopyWith<$Res> { + _$CardKindHelpCopyWithImpl(this._self, this._then); + + final CardKindHelp _self; + final $Res Function(CardKindHelp) _then; + +/// Create a copy of CardKindHelp +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? kind = null,Object? title = null,Object? blurb = null,Object? steps = null,}) { + return _then(_self.copyWith( +kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,blurb: null == blurb ? _self.blurb : blurb // ignore: cast_nullable_to_non_nullable +as String,steps: null == steps ? _self.steps : steps // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CardKindHelp]. +extension CardKindHelpPatterns on CardKindHelp { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CardKindHelp value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CardKindHelp() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CardKindHelp value) $default,){ +final _that = this; +switch (_that) { +case _CardKindHelp(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CardKindHelp value)? $default,){ +final _that = this; +switch (_that) { +case _CardKindHelp() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String kind, String title, String blurb, List steps)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CardKindHelp() when $default != null: +return $default(_that.kind,_that.title,_that.blurb,_that.steps);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String kind, String title, String blurb, List steps) $default,) {final _that = this; +switch (_that) { +case _CardKindHelp(): +return $default(_that.kind,_that.title,_that.blurb,_that.steps);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String kind, String title, String blurb, List steps)? $default,) {final _that = this; +switch (_that) { +case _CardKindHelp() when $default != null: +return $default(_that.kind,_that.title,_that.blurb,_that.steps);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _CardKindHelp implements CardKindHelp { + const _CardKindHelp({required this.kind, required this.title, required this.blurb, required final List steps}): _steps = steps; + factory _CardKindHelp.fromJson(Map json) => _$CardKindHelpFromJson(json); + +@override final String kind; +@override final String title; +@override final String blurb; + final List _steps; +@override List get steps { + if (_steps is EqualUnmodifiableListView) return _steps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_steps); +} + + +/// Create a copy of CardKindHelp +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CardKindHelpCopyWith<_CardKindHelp> get copyWith => __$CardKindHelpCopyWithImpl<_CardKindHelp>(this, _$identity); + +@override +Map toJson() { + return _$CardKindHelpToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CardKindHelp&&(identical(other.kind, kind) || other.kind == kind)&&(identical(other.title, title) || other.title == title)&&(identical(other.blurb, blurb) || other.blurb == blurb)&&const DeepCollectionEquality().equals(other._steps, _steps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,kind,title,blurb,const DeepCollectionEquality().hash(_steps)); + +@override +String toString() { + return 'CardKindHelp(kind: $kind, title: $title, blurb: $blurb, steps: $steps)'; +} + + +} + +/// @nodoc +abstract mixin class _$CardKindHelpCopyWith<$Res> implements $CardKindHelpCopyWith<$Res> { + factory _$CardKindHelpCopyWith(_CardKindHelp value, $Res Function(_CardKindHelp) _then) = __$CardKindHelpCopyWithImpl; +@override @useResult +$Res call({ + String kind, String title, String blurb, List steps +}); + + + + +} +/// @nodoc +class __$CardKindHelpCopyWithImpl<$Res> + implements _$CardKindHelpCopyWith<$Res> { + __$CardKindHelpCopyWithImpl(this._self, this._then); + + final _CardKindHelp _self; + final $Res Function(_CardKindHelp) _then; + +/// Create a copy of CardKindHelp +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? kind = null,Object? title = null,Object? blurb = null,Object? steps = null,}) { + return _then(_CardKindHelp( +kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,blurb: null == blurb ? _self.blurb : blurb // ignore: cast_nullable_to_non_nullable +as String,steps: null == steps ? _self._steps : steps // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/shared/models/content/card_kind_help.g.dart b/lib/shared/models/content/card_kind_help.g.dart new file mode 100644 index 00000000..660dad5b --- /dev/null +++ b/lib/shared/models/content/card_kind_help.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'card_kind_help.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CardKindHelp _$CardKindHelpFromJson(Map json) => + _CardKindHelp( + kind: json['kind'] as String, + title: json['title'] as String, + blurb: json['blurb'] as String, + steps: (json['steps'] as List).map((e) => e as String).toList(), + ); + +Map _$CardKindHelpToJson(_CardKindHelp instance) => + { + 'kind': instance.kind, + 'title': instance.title, + 'blurb': instance.blurb, + 'steps': instance.steps, + }; diff --git a/lib/shared/repositories/card_kind_help_repository.dart b/lib/shared/repositories/card_kind_help_repository.dart new file mode 100644 index 00000000..fc3c4801 --- /dev/null +++ b/lib/shared/repositories/card_kind_help_repository.dart @@ -0,0 +1,35 @@ +import 'package:brew_path/shared/models/content/card_kind_help.dart'; +import 'package:brew_path/shared/repositories/bank_loader.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'card_kind_help_repository.g.dart'; + +/// The ten how-to-play entries, loaded off the bundled bank and cached. +/// +/// Its own repository rather than another method on `ContentRepository`, which +/// sits at the method budget the metrics gate enforces — the same reason the +/// dictionary and the visual guides have one. +class CardKindHelpRepository { + Map? _byKind; + + /// Every entry, keyed by the card kind it explains. + Future> getByKind() async { + _byKind ??= { + for (final help in await loadBank( + 'assets/content/generated/card_kind_help.json', + CardKindHelp.fromJson, + )) + help.kind: help, + }; + return _byKind!; + } + + /// How [kind] is played, or null when the bank carries no entry for it. + Future getForKind(String kind) async => + (await getByKind())[kind]; +} + +/// The app-wide [CardKindHelpRepository]. +@riverpod +CardKindHelpRepository cardKindHelpRepository(Ref ref) => + CardKindHelpRepository(); diff --git a/lib/shared/repositories/card_kind_help_repository.g.dart b/lib/shared/repositories/card_kind_help_repository.g.dart new file mode 100644 index 00000000..2d3a56eb --- /dev/null +++ b/lib/shared/repositories/card_kind_help_repository.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'card_kind_help_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// The app-wide [CardKindHelpRepository]. + +@ProviderFor(cardKindHelpRepository) +final cardKindHelpRepositoryProvider = CardKindHelpRepositoryProvider._(); + +/// The app-wide [CardKindHelpRepository]. + +final class CardKindHelpRepositoryProvider + extends + $FunctionalProvider< + CardKindHelpRepository, + CardKindHelpRepository, + CardKindHelpRepository + > + with $Provider { + /// The app-wide [CardKindHelpRepository]. + CardKindHelpRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'cardKindHelpRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cardKindHelpRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CardKindHelpRepository create(Ref ref) { + return cardKindHelpRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CardKindHelpRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$cardKindHelpRepositoryHash() => + r'c4bce102003cfb8155ade2300d1f4ce0b4e2969c'; diff --git a/lib/shared/theme/app_text.dart b/lib/shared/theme/app_text.dart index fad887ea..68192689 100644 --- a/lib/shared/theme/app_text.dart +++ b/lib/shared/theme/app_text.dart @@ -84,6 +84,10 @@ enum AppTracking { /// too. hint(0.12), + /// 0.14em — the design's smallcaps rule, named so it can be asked for on a + /// rung that does not carry it: the card cue sets it at the support step. + smallcaps(0.14), + /// 0.16em — a mono micro line marking what a thing *is*, or where it sits in /// a set, rather than heading the content under it. Wider than the smallcaps /// rule so a two-word label reads as discrete: the dictionary's status chip diff --git a/lib/shared/theme/off_token.dart b/lib/shared/theme/off_token.dart index 372083ff..c1874be4 100644 --- a/lib/shared/theme/off_token.dart +++ b/lib/shared/theme/off_token.dart @@ -337,6 +337,18 @@ abstract final class OffTokens { reason: "the question under the cup sets `margin: '20px 0 0'`", ); + /// The gap between two how-to-play steps. + static const OffToken helpStepGap = OffToken( + 11, + reason: 'the drawer stacks its steps at `gap: 11`', + ); + + /// The room over the how-to-play steps, under the blurb. + static const OffToken helpStepsTop = OffToken( + 20, + reason: 'the step stack opens at `marginTop: 20`', + ); + /// The leading a fill slot sets for itself, tighter than the paragraph it /// sits in so the rule stays under the word rather than under the line. static const OffToken fillSlotLeading = OffToken( @@ -404,6 +416,8 @@ abstract final class OffTokens { tastefixPanelPadding, tastefixPanelGap, tastefixPanelToPrompt, + helpStepGap, + helpStepsTop, fillSlotLeading, fillGroupGap, fillOptionGap, diff --git a/test/unit/features/lessons/card_cue_test.dart b/test/unit/features/lessons/card_cue_test.dart new file mode 100644 index 00000000..27c8fbfe --- /dev/null +++ b/test/unit/features/lessons/card_cue_test.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List> _bank() { + final raw = File('assets/content/generated/card_kind_help.json'); + final decoded = jsonDecode(raw.readAsStringSync()) as Map; + return (decoded['items'] as List).cast>(); +} + +void main() { + group('CardCue', () { + test('every cue has an entry in the bundled help bank', () { + final kinds = _bank().map((item) => item['kind']).toSet(); + + for (final cue in CardCue.values) { + expect( + kinds, + contains(cue.helpKey), + reason: '${cue.name} would draw a ? with nothing behind it', + ); + } + }); + + test('the bank explains nothing the cues cannot reach', () { + final reachable = CardCue.values.map((cue) => cue.helpKey).toSet(); + + for (final item in _bank()) { + expect( + reachable, + contains(item['kind']), + reason: '${item['kind']} has help no card can open', + ); + } + }); + + test('carries the design phrase for every kind', () { + const written = { + CardCue.mcq: 'Multiple choice · pick one', + CardCue.multi: 'Select all that apply', + CardCue.match: 'Match · drag to pair', + CardCue.slider: 'Calibrate · dial to the target', + CardCue.sequence: 'Put in order · tap in sequence', + CardCue.quiz: 'True or false', + CardCue.flavor: 'Tasting · name the note', + CardCue.tastefix: 'Taste Fix', + CardCue.bagpick: 'Blind bag · read the beans', + CardCue.fill: 'Complete the sentence', + }; + + expect(written.keys, containsAll(CardCue.values)); + written.forEach((cue, phrase) => expect(cue.phrase, phrase)); + }); + + test('no phrase is pre-shouted — the type rule sets the case', () { + for (final cue in CardCue.values) { + expect( + cue.phrase, + isNot(cue.phrase.toUpperCase()), + reason: '${cue.name} carries its own casing', + ); + } + }); + }); +} diff --git a/test/unit/shared/repositories/card_kind_help_repository_test.dart b/test/unit/shared/repositories/card_kind_help_repository_test.dart new file mode 100644 index 00000000..054a9736 --- /dev/null +++ b/test/unit/shared/repositories/card_kind_help_repository_test.dart @@ -0,0 +1,31 @@ +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; +import 'package:brew_path/shared/repositories/card_kind_help_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'reads the bundled bank, so the ? is never drawn over nothing', + () async { + final help = await CardKindHelpRepository().getByKind(); + + for (final cue in CardCue.values) { + final entry = help[cue.helpKey]; + expect(entry, isNotNull, reason: '${cue.name} has no bundled help'); + expect(entry!.title, isNotEmpty); + expect(entry.blurb, isNotEmpty); + expect(entry.steps, hasLength(3)); + } + }, + ); + + test('caches, so a card opening twice reads the bundle once', () async { + final repository = CardKindHelpRepository(); + + expect( + identical(await repository.getByKind(), await repository.getByKind()), + isTrue, + ); + }); +} diff --git a/test/unit/shared/theme/tracking_axis_test.dart b/test/unit/shared/theme/tracking_axis_test.dart index 30998640..c9d917f9 100644 --- a/test/unit/shared/theme/tracking_axis_test.dart +++ b/test/unit/shared/theme/tracking_axis_test.dart @@ -104,6 +104,7 @@ void main() { expect(AppTracking.meta.em, 0.08); expect(AppTracking.tag.em, 0.10); expect(AppTracking.hint.em, 0.12); + expect(AppTracking.smallcaps.em, 0.14); expect(AppTracking.marker.em, 0.16); expect(AppTracking.chrome.em, 0.18); }); diff --git a/test/widget/features/lessons/card_cue_row_test.dart b/test/widget/features/lessons/card_cue_row_test.dart new file mode 100644 index 00000000..e6b2d6ad --- /dev/null +++ b/test/widget/features/lessons/card_cue_row_test.dart @@ -0,0 +1,137 @@ +import 'package:brew_path/app/app_theme.dart'; +import 'package:brew_path/core/widgets/primary_button.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue_row.dart'; +import 'package:brew_path/features/lessons/presentation/cards/help_drawer.dart'; +import 'package:brew_path/shared/models/content/card_kind_help.dart'; +import 'package:brew_path/shared/repositories/card_kind_help_repository.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _matchHelp = CardKindHelp( + kind: 'match', + title: 'Match pairs', + blurb: 'Drag each trait onto the item it belongs to.', + steps: [ + 'Drag a trait onto its match', + 'It locks in when it is right', + 'Clear the board with no wrong drops', + ], +); + +class _FakeHelp extends CardKindHelpRepository { + _FakeHelp({this.entry = _matchHelp}); + + final CardKindHelp? entry; + + @override + Future> getByKind() async { + final entry = this.entry; + return entry == null ? {} : {entry.kind: entry}; + } +} + +void main() { + Future pump( + WidgetTester tester, { + CardKindHelp? entry = _matchHelp, + ThemeData? theme, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + cardKindHelpRepositoryProvider.overrideWith( + (ref) => _FakeHelp(entry: entry), + ), + ], + child: MaterialApp( + theme: theme ?? AppTheme.darkRoast, + home: const Scaffold(body: CardCueRow(cue: CardCue.match)), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('names the format in the design phrase, set upper case', ( + tester, + ) async { + await pump(tester); + + expect(find.text('MATCH · DRAG TO PAIR'), findsOneWidget); + }); + + testWidgets('is announced as written, not shouted', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester); + + expect(find.bySemanticsLabel('Match · drag to pair'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('takes the accent, which is what the design colours it', ( + tester, + ) async { + for (final theme in [AppTheme.cupping, AppTheme.darkRoast]) { + await pump(tester, theme: theme); + + final cue = tester.widget(find.text('MATCH · DRAG TO PAIR')); + expect(cue.style?.color, theme.extension()!.accent); + } + }); + + group('the help button', () { + testWidgets('carries a 44x44 target and the How to play label', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pump(tester); + + expect(find.bySemanticsLabel(howToPlayLabel), findsOneWidget); + expect( + tester.getSize(find.bySemanticsLabel(howToPlayLabel)), + const Size(44, 44), + ); + + handle.dispose(); + }); + + testWidgets('opens the drawer on the bank entry behind it', (tester) async { + await pump(tester); + + await tester.tap(find.text('?')); + await tester.pumpAndSettle(); + + expect(find.text(howToPlayLabel.toUpperCase()), findsOneWidget); + expect(find.text('Match pairs'), findsOneWidget); + expect(find.text(_matchHelp.blurb), findsOneWidget); + for (final step in _matchHelp.steps) { + expect(find.text(step), findsOneWidget); + } + expect(find.text('01'), findsOneWidget); + expect(find.text('03'), findsOneWidget); + }); + + testWidgets('Got it is the only way out, and it closes', (tester) async { + await pump(tester); + await tester.tap(find.text('?')); + await tester.pumpAndSettle(); + + expect(find.byType(PrimaryButton), findsOneWidget); + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + expect(find.text('Match pairs'), findsNothing); + }); + + testWidgets('never draws with nothing behind it', (tester) async { + await pump(tester, entry: null); + + expect(find.text('?'), findsNothing); + expect(find.text('MATCH · DRAG TO PAIR'), findsOneWidget); + }); + }); +} diff --git a/test/widget/features/lessons/content_card_view_test.dart b/test/widget/features/lessons/content_card_view_test.dart index 96086913..1f280c94 100644 --- a/test/widget/features/lessons/content_card_view_test.dart +++ b/test/widget/features/lessons/content_card_view_test.dart @@ -5,6 +5,7 @@ import 'package:brew_path/features/companion/domain/roasty_state.dart'; import 'package:brew_path/features/companion/presentation/roasty.dart'; import 'package:brew_path/features/lessons/domain/card_seed.dart'; import 'package:brew_path/features/lessons/domain/held_guess.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; import 'package:brew_path/features/lessons/presentation/cards/concept_fill_bank.dart'; import 'package:brew_path/features/lessons/presentation/cards/content_card_view.dart'; import 'package:brew_path/features/lessons/presentation/cards/recall_payoff.dart'; @@ -14,6 +15,7 @@ import 'package:brew_path/shared/models/content/card_parts.dart'; import 'package:brew_path/shared/models/content/content_card.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; /// Counts what crossed the card boundary. The whole contract is here: success @@ -175,15 +177,17 @@ Widget _host( int nonce = 1, HeldGuess? prediction, ValueChanged? onGuess, -}) => MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: contentCardView( - card, - seed: cardSeed(nonce: nonce, cardIndex: 0), - onSolved: () => signals.solved++, - onContinue: () => signals.advanced++, - guess: GuessLoop(held: prediction, onGuess: onGuess), +}) => ProviderScope( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: contentCardView( + card, + seed: cardSeed(nonce: nonce, cardIndex: 0), + onSolved: () => signals.solved++, + onContinue: () => signals.advanced++, + guess: GuessLoop(held: prediction, onGuess: onGuess), + ), ), ), ), @@ -1176,7 +1180,7 @@ void main() { testWidgets('asks for every answer, and offers them all', (tester) async { await tester.pumpWidget(_host(_multi, _Signals())); - expect(find.text('Select all that apply'), findsOneWidget); + expect(find.text(CardCue.multi.phrase.toUpperCase()), findsOneWidget); expect(find.text(_multi.prompt), findsOneWidget); for (final choice in _multi.choices) { expect(find.text(choice.text), findsOneWidget); diff --git a/test/widget/features/lessons/sequence_card_view_test.dart b/test/widget/features/lessons/sequence_card_view_test.dart index 473e65b5..82145954 100644 --- a/test/widget/features/lessons/sequence_card_view_test.dart +++ b/test/widget/features/lessons/sequence_card_view_test.dart @@ -2,6 +2,7 @@ import 'package:brew_path/features/lessons/presentation/cards/sequence_card_view import 'package:brew_path/features/lessons/presentation/cards/sequence_step_number.dart'; import 'package:brew_path/shared/models/content/card_parts.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; /// A shipped round, handed to the card in a display order that is not the @@ -32,14 +33,16 @@ class _Signals { /// shared card layer is that it needs none. Future _pumpCard(WidgetTester tester, _Signals signals) async { await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: SequenceCardView( - prompt: 'Order the journey from farm to cup', - items: _shown, - onSolved: () => signals.solved++, - onContinue: () => signals.continued++, + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SequenceCardView( + prompt: 'Order the journey from farm to cup', + items: _shown, + onSolved: () => signals.solved++, + onContinue: () => signals.continued++, + ), ), ), ), diff --git a/test/widget/features/lessons/slider_card_view_test.dart b/test/widget/features/lessons/slider_card_view_test.dart index 7fad651a..29e2d885 100644 --- a/test/widget/features/lessons/slider_card_view_test.dart +++ b/test/widget/features/lessons/slider_card_view_test.dart @@ -4,6 +4,7 @@ import 'package:brew_path/features/lessons/presentation/cards/slider_dial.dart'; import 'package:brew_path/shared/models/content/content_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; /// A shipped grind round — the axis that draws the collar, with the answer at @@ -58,13 +59,15 @@ Future _pumpCard( _Signals signals, ) async { await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: SliderCardView( - card: card, - onSolved: () => signals.solved++, - onContinue: () => signals.continued++, + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SliderCardView( + card: card, + onSolved: () => signals.solved++, + onContinue: () => signals.continued++, + ), ), ), ), @@ -240,16 +243,18 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - MediaQuery( - // The largest step iOS offers without the accessibility sizes. - data: const MediaQueryData(textScaler: TextScaler.linear(2)), - child: MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: SliderCardView( - card: _espressoGrind, - onSolved: () {}, - onContinue: () {}, + ProviderScope( + child: MediaQuery( + // The largest step iOS offers without the accessibility sizes. + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SliderCardView( + card: _espressoGrind, + onSolved: () {}, + onContinue: () {}, + ), ), ), ), diff --git a/test/widget/match_board_view_test.dart b/test/widget/match_board_view_test.dart index 8639a6d5..fa717319 100644 --- a/test/widget/match_board_view_test.dart +++ b/test/widget/match_board_view_test.dart @@ -2,6 +2,7 @@ import 'package:brew_path/features/lessons/presentation/cards/match_board.dart'; import 'package:brew_path/features/lessons/presentation/cards/match_board_view.dart'; import 'package:brew_path/shared/models/content/card_parts.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; const _pairs = [ @@ -17,14 +18,16 @@ Future _pumpBoard( }) async { var solved = 0; await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: MatchBoardView( - prompt: 'Match each trait to its species', - pairs: _pairs, - targets: matchTargets(_pairs), - onSolved: () => solved++, - onContinue: () {}, + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: MatchBoardView( + prompt: 'Match each trait to its species', + pairs: _pairs, + targets: matchTargets(_pairs), + onSolved: () => solved++, + onContinue: () {}, + ), ), ), ), @@ -58,15 +61,17 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: MatchBoardView( - prompt: 'Match each origin to its profile', - pairs: _wideBoard, - targets: matchTargets(_wideBoard), - onSolved: () {}, - onContinue: () {}, + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MatchBoardView( + prompt: 'Match each origin to its profile', + pairs: _wideBoard, + targets: matchTargets(_wideBoard), + onSolved: () {}, + onContinue: () {}, + ), ), ), ), diff --git a/test/widget/mini_game_flow_test.dart b/test/widget/mini_game_flow_test.dart index d1e1e5f3..c318797d 100644 --- a/test/widget/mini_game_flow_test.dart +++ b/test/widget/mini_game_flow_test.dart @@ -4,6 +4,8 @@ import 'package:brew_path/core/icons/replay_mark.dart'; import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/ghost_button.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart'; +import 'package:brew_path/features/lessons/presentation/cards/card_cue_row.dart'; import 'package:brew_path/features/mini_games/presentation/mini_game_intro_screen.dart'; import 'package:brew_path/features/mini_games/presentation/mini_game_player_screen.dart'; import 'package:brew_path/features/mini_games/presentation/mini_games_catalog_widget.dart'; @@ -676,6 +678,20 @@ void main() { expect(find.text('Done'), findsOneWidget); }); + testWidgets('a round names its kind here too, as the lesson player does', ( + tester, + ) async { + await _pump(tester); + await tester.tap(find.text('True or false')); + await _settle(tester); + await tester.tap(find.text('Play')); + await _settle(tester); + + // The cue is the shared card shell's, so a game and a lesson cannot drift. + expect(find.text(CardCue.quiz.phrase.toUpperCase()), findsOneWidget); + expect(find.byType(CardCueRow), findsOneWidget); + }); + testWidgets('continue is gated until the round latches', (tester) async { await _pump(tester); await tester.tap(find.text('True or false'));