diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1ab11604..34e65aa5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -141,6 +141,23 @@ You can always edit this file by hand instead — the helpers just save effort. ### Changed +- **Every module on Path opens and shuts.** The one you are working through + was pinned open with no caret, because the design ties *cannot collapse* to + *always open*. It now carries a caret like a finished module and folds away + on a tap, while still opening itself on arrival, so your current lesson is in + front of you without being asked for. A module you have not reached keeps no + caret — there is nothing behind one to open. + +- **Every section that opens, opens the same way.** The practice groups, + Path's modules, Reference, the parked brews and the Help answers were five + hand-rolled expanders with four different timings and two different marks. + They are now one: a caret turning 180° for a list, a plus turning 45° into a + cross for an answer, both over the design's 240ms, and a panel that grows + over the same beat. The parked brews collapse for the first time, and start + shut with their count beside the header. A shut panel is not drawn at all, + so a screen reader never finds what is not on screen and a swipe never lands + in it. Reduced motion cuts the move to nothing rather than dropping it. + - **Today's date is shorter.** The Learn header reads *Fri, May 8* rather than *Friday, May 8*, as the design now sets it, so the title takes less of the header. Term of the Day keeps the long form. diff --git a/lib/core/icons/caret_mark.dart b/lib/core/icons/caret_mark.dart deleted file mode 100644 index c5e065b9..00000000 --- a/lib/core/icons/caret_mark.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; -import 'package:flutter/material.dart'; - -/// The expand/collapse caret, pointing down when closed and up when open. -/// -/// The design has **one** caret for this, not a pair: *"Accordions, review -/// toggles. Rotates 180° when open."* Material's `expand_more`/`expand_less` -/// are two glyphs, and swapping between them is what the app did — so this -/// exists to keep the design's rule in one place rather than leaving each -/// accordion to remember it. -/// -/// The turn is instant. The design states the rotation and not an animation -/// for it, and a rotation that animates would need a duration this layer has -/// no token for. -class CaretMark extends StatelessWidget { - /// Creates a caret for a section that is [open] or closed. - const CaretMark({required this.open, this.size, this.color, super.key}); - - /// Whether the section it belongs to is open, which is what it points at. - final bool open; - - /// The box to fit the caret into. Null draws it at the size the design did. - final double? size; - - /// The caret's ink. Null takes the ambient icon colour, as any mark does. - final Color? color; - - /// Half a turn — the design's own 180°. - static const int _openTurns = 2; - - @override - Widget build(BuildContext context) => RotatedBox( - quarterTurns: open ? _openTurns : 0, - child: IconMark(AppIcon.caret, size: size, color: color), - ); -} diff --git a/lib/core/icons/disclosure_mark.dart b/lib/core/icons/disclosure_mark.dart index 2617a017..501f4947 100644 --- a/lib/core/icons/disclosure_mark.dart +++ b/lib/core/icons/disclosure_mark.dart @@ -1,37 +1,121 @@ import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/shared/theme/app_motion.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; -/// The plus that turns into a cross as its answer opens. +/// The mark a disclosure header carries, and what it promises. /// -/// The design's own mark, and its `transition: transform 200ms ease` — unlike -/// `CaretMark`, which the design states without one. The glyph is `close` -/// rotated: that mark is already the symmetric cross this ends on, so the two -/// share one stroke weight rather than two drawings of the same lines. +/// Two on purpose: a caret means *more of the same, below* — things that were +/// already countable while shut — and a plus means *there is an answer here*, +/// prose that did not exist until it was asked for. +enum DisclosureGlyph { + /// Lists: practice groups, Path's modules, a shelf of guides. + caret, + + /// Prose: an FAQ answer. + plus, + + /// No mark, for a header that is a heading rather than a toggle. + none, +} + +/// The glyph on a disclosure header, turning as its panel opens. +/// +/// The caret turns 180°, the plus 45° into a cross — both over the design's +/// `240ms cubic-bezier(.4,0,.2,1)`, cut to nothing when motion is reduced. class DisclosureMark extends StatelessWidget { - /// Creates a mark for a row that is [open] or closed. - const DisclosureMark({required this.open, this.size, this.color, super.key}); + /// Draws [glyph] for a panel that is [open] or shut. + const DisclosureMark({ + required this.glyph, + required this.open, + this.size, + this.color, + super.key, + }); + + /// The caret's drawn size — the design's `size || 18`. + static const double _caretSize = 18; + + /// The plus's drawn size — the design's `size || 12`. + static const double _plusSize = 12; + + /// The caret where a section sets it smaller: the design draws it at 18, or + /// 16 where a site says so, which Path's modules and Reference both do. + static const double sectionCaretSize = 16; + + /// Half a turn: the design's 180° on the caret. + static const double _caretOpenTurns = 0.5; + + /// An eighth: the design's 45°, which makes a plus into a cross. + static const double _plusOpenTurns = 0.125; + + /// Which mark to draw. + final DisclosureGlyph glyph; - /// Whether the row it belongs to is open, which is what it states. + /// Whether the panel it belongs to is open, which is what it points at. final bool open; - /// The box to fit the mark into. Null draws it at the mark's own size. + /// The box to fit the mark into. Null draws it at the size the design did. final double? size; - /// The mark's ink. Null takes the ambient icon colour, as any mark does. + /// The mark's ink. Null takes the muted ink the design gives every glyph. final Color? color; - /// An eighth of a turn — the design's 45°, which makes a cross a plus. - static const double _closedTurns = 0.125; + @override + Widget build(BuildContext context) { + if (glyph == DisclosureGlyph.none) return const SizedBox.shrink(); + + final ink = color ?? context.mood.inkMute; + final isCaret = glyph == DisclosureGlyph.caret; + final openTurns = isCaret ? _caretOpenTurns : _plusOpenTurns; + + return AnimatedRotation( + turns: open ? openTurns : 0, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : AppMotion.disclosure, + curve: AppMotion.disclosureGlyph, + child: isCaret + ? IconMark(AppIcon.caret, size: size ?? _caretSize, color: ink) + : CustomPaint( + size: Size.square(size ?? _plusSize), + painter: _PlusPainter(ink), + ), + ); + } +} + +/// The design's own plus, which no icon in the mark family draws: `M6 1v10M1 +/// 6h10` at `strokeWidth: 1.4` and `strokeOpacity: 0.7` in a 12-unit box. +class _PlusPainter extends CustomPainter { + const _PlusPainter(this.color); + + static const double _box = 12; + static const double _armInset = 1; + static const double _strokeWidth = 1.4; + static const double _strokeOpacity = 0.7; + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final scale = size.width / _box; + final paint = Paint() + ..color = color.withValues(alpha: color.a * _strokeOpacity) + ..strokeWidth = _strokeWidth * scale + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + final middle = size.width / 2; + final start = _armInset * scale; + final end = size.width - start; + + canvas + ..drawLine(Offset(middle, start), Offset(middle, end), paint) + ..drawLine(Offset(start, middle), Offset(end, middle), paint); + } @override - Widget build(BuildContext context) => AnimatedRotation( - turns: open ? 0 : _closedTurns, - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : AppMotion.markTurn, - curve: Curves.ease, - child: IconMark(AppIcon.close, size: size, color: color), - ); + bool shouldRepaint(_PlusPainter oldDelegate) => oldDelegate.color != color; } diff --git a/lib/core/widgets/disclosure.dart b/lib/core/widgets/disclosure.dart new file mode 100644 index 00000000..1caceef6 --- /dev/null +++ b/lib/core/widgets/disclosure.dart @@ -0,0 +1,196 @@ +import 'package:brew_path/core/icons/disclosure_mark.dart'; +import 'package:brew_path/core/widgets/disclosure_panel.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'; + +/// The one expandable section: a header, and a panel that opens under it. +/// +/// Every expandable in the app is this one — the practice groups, Path's +/// modules, Reference, For later and the FAQ — so the glyph, the timing and +/// the rule that a shut panel is not there at all are settled in one place. +class Disclosure extends StatelessWidget { + /// Creates a disclosure whose panel is [isOpen], holding [child]. + const Disclosure({ + required this.isOpen, + required this.child, + this.label, + this.header, + this.below, + this.trailing, + this.onToggle, + this.glyph = DisclosureGlyph.caret, + this.glyphSize, + this.collapsible = true, + this.divider = false, + this.semanticsLabel, + this.headerPadding = defaultHeaderPadding, + this.headerMinHeight, + this.panelPadding = EdgeInsets.zero, + this.trailingGap = defaultTrailingGap, + super.key, + }) : assert( + (label == null) != (header == null), + 'a header is a label or a widget of its own, never both or neither', + ); + + /// The design's `padding: 16px 0` on the header button. + static const EdgeInsets defaultHeaderPadding = EdgeInsets.symmetric( + vertical: AppSpacing.md, + ); + + /// The design's `trailingGap = 9` between the trailing slot and the glyph. + static const double defaultTrailingGap = 9; + + /// The design's `gap: 12` between the header and its trailing cluster. + static const double _headerGap = AppSpacing.sm; + + /// Whether the panel is showing, which the header cannot overrule. + /// + /// The design infers this from `collapsible`, and that misfires at its own + /// Reference site: it passes shut-while-locked and the component forces it + /// open onto a promise the caption above already makes. Stated, not + /// inferred, so a site cannot be overruled about its own panel. + final bool isOpen; + + /// What the panel holds. Mounted only while the panel is open or closing. + final Widget child; + + /// The header, when it is a plain line of body text. Excludes [header]. + final String? label; + + /// The header, when it is a widget of its own. Excludes [label]. + final Widget? header; + + /// A line under the header, inside the tappable row — a lock's reason, or a + /// section's caption. + final Widget? below; + + /// What sits before the glyph: a count, a lock. + final Widget? trailing; + + /// What a tap on the header does. Null leaves it untappable; with + /// [collapsible] false it is still a tap, but not one that opens the panel — + /// Reference's purchase lock opens the offer instead. + final VoidCallback? onToggle; + + /// Which mark the header carries. Ignored while [collapsible] is false, + /// which draws none. + final DisclosureGlyph glyph; + + /// The glyph's drawn size. Null takes the size the design draws it at. + final double? glyphSize; + + /// Whether the header opens and shuts the panel. False renders it as a plain + /// heading with no glyph and no expanded state. + final bool collapsible; + + /// Whether a hairline closes the section off, under the panel. + final bool divider; + + /// Read out in place of the header's own contents. Null lets them through. + final String? semanticsLabel; + + /// The room inside the header row. + final EdgeInsets headerPadding; + + /// The least the header row may be, padding included — a tap target. + final double? headerMinHeight; + + /// The room inside the panel, which a shut panel does not contribute. + final EdgeInsets panelPadding; + + /// The gap between [trailing] and the glyph. + final double trailingGap; + + /// The mark this header actually draws — none at all when it cannot toggle. + DisclosureGlyph get _shownGlyph => collapsible ? glyph : DisclosureGlyph.none; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _header(context), + DisclosurePanel(isOpen: isOpen, padding: panelPadding, child: child), + if (divider) _rule(context), + ], + ); + } + + /// The header: a button while it has something to do, a heading otherwise — + /// a heading in a button would announce an action that does not exist. + Widget _header(BuildContext context) { + Widget row = Padding(padding: headerPadding, child: _headerRow(context)); + if (headerMinHeight case final height?) { + row = ConstrainedBox( + constraints: BoxConstraints(minHeight: height), + child: row, + ); + } + if (onToggle == null) return row; + + return Semantics( + button: true, + expanded: collapsible ? isOpen : null, + label: semanticsLabel, + excludeSemantics: semanticsLabel != null, + child: InkWell(onTap: onToggle, child: row), + ); + } + + Widget _headerRow(BuildContext context) { + final cluster = _trailingCluster(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Centred, never `CrossAxisAlignment.baseline`. The design aligns the + // header on a baseline, but a mark reports none, and Flutter pins a + // child with no baseline to the top of the row. + Row( + children: [ + Expanded( + child: + header ?? + Text(label!, style: AppText.body(mood: context.mood)), + ), + ?cluster, + ], + ), + ?below, + ], + ); + } + + /// The trailing slot and the glyph, or null when the header has neither. + Widget? _trailingCluster() { + final mark = _shownGlyph; + final hasGlyph = mark != DisclosureGlyph.none; + if (trailing == null && !hasGlyph) return null; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: _headerGap), + ?trailing, + if (trailing != null && hasGlyph) SizedBox(width: trailingGap), + if (hasGlyph) + DisclosureMark(glyph: mark, open: isOpen, size: glyphSize), + ], + ); + } + + /// The hairline that closes the section off, lined up with the header it + /// belongs to rather than with rows that are free to bleed past both. + Widget _rule(BuildContext context) => Padding( + padding: EdgeInsets.only( + left: headerPadding.left, + right: headerPadding.right, + ), + child: Divider(height: 1, thickness: 1, color: context.mood.rule), + ); +} diff --git a/lib/core/widgets/disclosure_panel.dart b/lib/core/widgets/disclosure_panel.dart new file mode 100644 index 00000000..fca692c4 --- /dev/null +++ b/lib/core/widgets/disclosure_panel.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:brew_path/shared/theme/app_motion.dart'; +import 'package:flutter/material.dart'; + +/// The panel under a disclosure header, growing and shrinking as it is asked. +/// +/// Its padding sits inside the clipped box, so a shut panel adds no height, +/// and its contents are dropped once shut — nothing to read out, nothing to +/// tab into. +class DisclosurePanel extends StatefulWidget { + /// Creates a panel holding [child], shown while [isOpen]. + const DisclosurePanel({ + required this.isOpen, + required this.child, + this.padding = EdgeInsets.zero, + super.key, + }); + + /// Whether the panel is open. + final bool isOpen; + + /// The room inside the panel. + final EdgeInsets padding; + + /// What the panel holds. + final Widget child; + + @override + State createState() => _DisclosurePanelState(); +} + +class _DisclosurePanelState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: AppMotion.disclosure, + value: widget.isOpen ? 1 : 0, + ); + + late final CurvedAnimation _heightFactor = CurvedAnimation( + parent: _controller, + curve: AppMotion.disclosurePanel, + ); + + @override + void didUpdateWidget(DisclosurePanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isOpen == oldWidget.isOpen) return; + unawaited(widget.isOpen ? _controller.forward() : _controller.reverse()); + } + + @override + void dispose() { + _heightFactor.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Reduced motion cuts the move rather than dropping the animator, the way + // the Tour's frame does: the panel still has to arrive. + _controller.duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : AppMotion.disclosure; + + return AnimatedBuilder( + animation: _heightFactor, + // Full width by hand: the clip aligns its child, and an aligned child is + // laid out loose — rows that fit their content would centre themselves. + child: SizedBox( + width: double.infinity, + child: Padding(padding: widget.padding, child: widget.child), + ), + builder: (context, panel) { + if (!widget.isOpen && _controller.isDismissed) { + return const SizedBox.shrink(); + } + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: _heightFactor.value, + child: panel, + ), + ); + }, + ); + } +} diff --git a/lib/core/widgets/module_glyph.dart b/lib/core/widgets/module_glyph.dart index d7f05211..b5cda3fa 100644 --- a/lib/core/widgets/module_glyph.dart +++ b/lib/core/widgets/module_glyph.dart @@ -1,24 +1,15 @@ import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/utils/module_icons.dart'; +import 'package:brew_path/shared/theme/app_spacing.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; /// A module's identity glyph, drawn **bare** — no fill, no rounded rect, no -/// icon well. +/// icon well, unlike the `IconBadge` every non-progression badge draws. /// -/// The design draws a module row as a glyph on nothing and carries its state in -/// two places only — `CompactModuleRow` and the Path tab's expandable rows: -/// -/// * **lock** is colour — [MoodColors.inkMute] when locked, [MoodColors.accent] -/// otherwise. The glyph itself stays the module's own either way; the lock -/// mark belongs in the row's trailing slot, not here. -/// * **completion is not signalled at all.** A finished module drops its -/// trailing chevron and its lesson-count line instead of lighting up, so the -/// row goes quiet as the user finishes it. -/// -/// Contrast `IconBadge`, the filled well every *non*-progression badge draws. -/// The two are deliberately different shapes: a fill here would read as a state -/// the design does not have. +/// It carries one state only: [MoodColors.inkMute] when the module is locked, +/// [MoodColors.accent] otherwise. The lock mark belongs in the row's trailing +/// slot, and completion is signalled by what the row drops, never by the glyph. class ModuleGlyph extends StatelessWidget { /// Creates a [ModuleGlyph] for the module whose content declares [iconName]. const ModuleGlyph({ @@ -37,7 +28,11 @@ class ModuleGlyph extends StatelessWidget { /// Width of the column the glyph is centred in. The design draws every module /// glyph in a fixed 32-px box so the titles beside them line up regardless of /// how wide each glyph's own ink runs. - static const double _columnWidth = 32; + static const double columnWidth = 32; + + /// Where a line under a module title starts: the glyph column plus the gap + /// beside it, which is the title's own left edge. + static const double titleInset = columnWidth + AppSpacing.sm; /// The design's module-glyph size. static const double _glyphSize = 26; @@ -47,7 +42,7 @@ class ModuleGlyph extends StatelessWidget { final mood = context.mood; return SizedBox( - width: _columnWidth, + width: columnWidth, child: Center( child: IconMark( moduleMark(iconName), diff --git a/lib/features/challenges/presentation/saved_challenges_list.dart b/lib/features/challenges/presentation/saved_challenges_list.dart index e9e29ca7..260d986f 100644 --- a/lib/features/challenges/presentation/saved_challenges_list.dart +++ b/lib/features/challenges/presentation/saved_challenges_list.dart @@ -1,5 +1,6 @@ import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; +import 'package:brew_path/core/widgets/disclosure.dart'; import 'package:brew_path/core/widgets/section_header.dart'; import 'package:brew_path/features/challenges/domain/challenge_bank.dart'; import 'package:brew_path/features/challenges/domain/challenge_providers.dart'; @@ -13,17 +14,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; const double _iconSm = 18; -/// The brews parked for later. +/// The design's `minHeight: 44` on the header, so a shut list is still a tap +/// target the size of every other row. +const double _headerMinHeight = 44; + +/// The brews parked for later, behind a header that opens them. /// /// Renders nothing at all when the queue is empty — a header over an empty /// list tells the learner they are missing something rather than that there is -/// nothing to miss. -class SavedChallengesList extends ConsumerWidget { +/// nothing to miss. Shut on arrival, as the design has it. +class SavedChallengesList extends ConsumerStatefulWidget { /// Creates a [SavedChallengesList]. const SavedChallengesList({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _SavedChallengesListState(); +} + +class _SavedChallengesListState extends ConsumerState { + bool _isOpen = false; + + @override + Widget build(BuildContext context) { final saved = ref.watch(savedChallengesProvider).asData?.value; if (saved == null || saved.isEmpty) return const SizedBox.shrink(); @@ -31,15 +44,34 @@ class SavedChallengesList extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppSpacing.lg), - const SectionHeader('Saved challenges'), - const SizedBox(height: AppSpacing.sm), - for (final challenge in saved) ...[ - _SavedRow(challenge: challenge), - if (challenge != saved.last) const SizedBox(height: AppSpacing.xs), - ], + Disclosure( + isOpen: _isOpen, + onToggle: () => setState(() => _isOpen = !_isOpen), + semanticsLabel: _semanticsLabel(saved.length), + // The count rides in the header line, as the design writes it — + // a shut list still says how much is parked behind it. + header: SectionHeader('Saved challenges · ${saved.length}'), + // The design's `headerPad: '4px 0'` and `panelStyle.paddingTop: 12`. + headerPadding: const EdgeInsets.symmetric(vertical: AppSpacing.xxs), + headerMinHeight: _headerMinHeight, + panelPadding: const EdgeInsets.only(top: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final challenge in saved) ...[ + _SavedRow(challenge: challenge), + if (challenge != saved.last) + const SizedBox(height: AppSpacing.xs), + ], + ], + ), + ), ], ); } + + String _semanticsLabel(int count) => + 'Saved challenges, $count ${count == 1 ? 'challenge' : 'challenges'}'; } class _SavedRow extends ConsumerWidget { diff --git a/lib/features/learn/presentation/practice/practice_group.dart b/lib/features/learn/presentation/practice/practice_group.dart index 2103c8bf..519b4c3e 100644 --- a/lib/features/learn/presentation/practice/practice_group.dart +++ b/lib/features/learn/presentation/practice/practice_group.dart @@ -1,5 +1,4 @@ -import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; +import 'package:brew_path/core/widgets/disclosure.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'; @@ -9,14 +8,9 @@ import 'package:flutter/material.dart'; /// One collapsible group of the practice shelf — *Lessons* or *Games* — with /// its count beside the name and its rows under it once opened. /// -/// **Closed on arrival.** The design opens neither group by default: the shelf -/// is a list of things the learner *could* do, and two long lists under the -/// day's one lesson would bury the ask. The count is what tells them the group -/// is worth opening. -/// -/// The header is the whole tappable row, and the caret turns over the design's -/// `240ms` as the rows appear — or at once when the platform asks for reduced -/// motion. +/// **Closed on arrival.** The design opens neither group by default: two long +/// lists under the day's one lesson would bury the ask, and the count is what +/// tells a learner the group is worth opening. class PracticeGroup extends StatefulWidget { /// Creates a [PracticeGroup]. const PracticeGroup({ @@ -40,19 +34,17 @@ class PracticeGroup extends StatefulWidget { /// Whether this is the shelf's last group, which drops the rule under it. final bool isLast; - /// The design's `transition: transform 240ms` on the caret. - static const Duration turnDuration = Duration(milliseconds: 240); - @override State createState() => _PracticeGroupState(); } class _PracticeGroupState extends State { - /// The caret's drawn size — the design's `width="18"`. - static const double _caretSize = 18; - - /// Half a turn: the caret points down closed and up open. - static const double _openTurns = 0.5; + /// The design's `padding: 16px 0` at the page gutter; the shelf sits a row's + /// bleed inside it, which the sides make up. + static const EdgeInsets _headerPadding = EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.xs, + ); bool _open = false; @@ -61,71 +53,32 @@ class _PracticeGroupState extends State { @override Widget build(BuildContext context) { final mood = context.mood; - final reduceMotion = MediaQuery.disableAnimationsOf(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - button: true, - expanded: _open, - label: '${widget.label}, ${widget.count}', - excludeSemantics: true, - child: InkWell( - onTap: _toggle, - child: Padding( - // The design's `padding: 16px 0` at the page gutter; the shelf - // sits a row's bleed inside it, which the sides make up. - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.md, - horizontal: AppSpacing.xs, - ), - child: Row( - children: [ - Text( - widget.label, - style: AppText.body(mood: mood, face: AppFace.control), - ), - SizedBox(width: OffTokens.practiceInlineGap.value), - Text( - '${widget.count}', - style: AppText.micro( - mood: mood, - tracking: AppTracking.hint, - ), - ), - const Spacer(), - AnimatedRotation( - turns: _open ? _openTurns : 0, - duration: reduceMotion - ? Duration.zero - : PracticeGroup.turnDuration, - curve: Curves.easeInOut, - child: IconMark( - AppIcon.caret, - size: _caretSize, - color: mood.inkMute, - ), - ), - ], - ), - ), - ), - ), - if (_open) - Padding( - padding: EdgeInsets.only(bottom: OffTokens.practiceGroupFoot.value), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.children, - ), + return Disclosure( + isOpen: _open, + onToggle: _toggle, + semanticsLabel: '${widget.label}, ${widget.count}', + divider: !widget.isLast, + headerPadding: _headerPadding, + panelPadding: EdgeInsets.only(bottom: OffTokens.practiceGroupFoot.value), + header: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.label, + style: AppText.body(mood: mood, face: AppFace.control), ), - if (!widget.isLast) - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: Divider(height: 1, color: mood.rule), + SizedBox(width: OffTokens.practiceInlineGap.value), + Text( + '${widget.count}', + style: AppText.micro(mood: mood, tracking: AppTracking.hint), ), - ], + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.children, + ), ); } } diff --git a/lib/features/path/domain/path_density.dart b/lib/features/path/domain/path_density.dart index b1b7b7fa..5c04e28b 100644 --- a/lib/features/path/domain/path_density.dart +++ b/lib/features/path/domain/path_density.dart @@ -8,36 +8,35 @@ import 'package:brew_path/features/learn/domain/learn_providers.dart'; /// The three densities Path draws a module at. /// -/// The course is one screen now, so five modules and thirty-two lessons have -/// to share it. The design's answer is not a scroll but a density: only the -/// module a learner is actually in lists its lessons, and the ones behind and -/// ahead of them shrink to a line. +/// The course is one screen, so five modules and thirty-two lessons share it. +/// Every module the learner can reach carries a caret and answers a tap; only +/// the one they are in lists its lessons before being asked. enum PathModuleDensity { - /// Reachable and unfinished — the module the learner is in. Always expanded, - /// and it cannot be collapsed: hiding the work in front of someone is what - /// the whole screen exists to stop. + /// Reachable and unfinished — the module the learner is in. Opens itself, + /// because the work in front of someone is what the screen exists to show, + /// and folds away on a tap like any other. active, - /// Every lesson done. Collapsed to a row that opens on tap, so finished work - /// is reviewable and replayable without holding the screen. + /// Every lesson done. Shut until asked, so finished work is reviewable and + /// replayable without holding the screen. complete, - /// Not yet reached. A compact static row — there is nothing to list, and - /// nothing to open. + /// Not yet reached. A compact static row: nothing to list, so no caret and + /// nothing a tap could open onto. locked; - /// Whether a tap opens and shuts this module. Only [complete] answers yes: - /// [active] must stay open and [locked] has nothing to show. - bool get canCollapse => this == PathModuleDensity.complete; + /// Whether the header carries a caret and answers a tap. Everything the + /// learner can reach; only [locked] has nothing behind it. + bool get canCollapse => this != PathModuleDensity.locked; + + /// Whether the module lists its lessons before anyone asks. Only [active] — + /// one module open on arrival, which is the one being worked through. + bool get opensUnasked => this == PathModuleDensity.active; /// Whether the module is out of reach. Asked instead of comparing against /// the enum value at a call site, so every question about a density is /// answered by the density itself. bool get isLocked => this == PathModuleDensity.locked; - - /// Whether the module lists its lessons without being asked. True only for - /// [active] — the one module whose lessons are the learner's next move. - bool get showsLessonsWhenCollapsed => this == PathModuleDensity.active; } /// Which density [item] draws at. @@ -45,7 +44,7 @@ enum PathModuleDensity { /// Locked is checked first and wins outright. A locked module's lesson tallies /// can read as complete — a content update that adds a lesson to its /// prerequisite re-locks it without touching its own progress — and drawing -/// that as a finished module would offer a tap that opens nothing. +/// that as a finished module would offer a caret over nothing. PathModuleDensity pathModuleDensity(ModuleWithProgress item) { if (item.isLocked) return PathModuleDensity.locked; if (item.isComplete) return PathModuleDensity.complete; @@ -75,10 +74,9 @@ class PathCourseSummary { /// The tally for [modules], counting **lessons, not modules**, and only the /// ones the learner can reach. /// -/// A locked module contributes to neither half. The design counts against +/// A locked module contributes to neither half: the design counts against /// `MODULES.filter(m => !m.locked)`, so the denominator is what is open to the -/// learner now — a total that included locked modules would be a target they -/// cannot close and would make the count fall as the course grows. +/// learner now rather than a target that grows with the course. PathCourseSummary pathCourseSummary(List modules) { var done = 0; var unlocked = 0; diff --git a/lib/features/path/domain/path_module_view.dart b/lib/features/path/domain/path_module_view.dart index 7133bc3e..50c84d68 100644 --- a/lib/features/path/domain/path_module_view.dart +++ b/lib/features/path/domain/path_module_view.dart @@ -91,14 +91,10 @@ class PathModule { /// Arranges [modules] into what Path draws. /// -/// [lessonsById] is the lessons bank; a module lesson id with no entry is -/// dropped rather than rendered as a blank row. **Currency is still decided -/// over the module's own id list**, not over the rows that survived that drop -/// — otherwise one missing bank entry would promote a later lesson to -/// "current" and point the learner past the one they actually owe. -/// -/// [hasCourse] is the learner's entitlement. Pass `false` while it is still -/// unresolved, which is what `courseEntitlement` asks of every caller. +/// A lesson id missing from [lessonsById] is dropped rather than drawn blank, +/// but **currency is still decided over the module's own id list**: one +/// missing entry must not promote a later lesson to "current". Pass +/// [hasCourse] false while the entitlement is unresolved, as every caller does. List buildPathModules({ required List modules, required Map lessonsById, diff --git a/lib/features/path/presentation/path_lesson_row.dart b/lib/features/path/presentation/path_lesson_row.dart index 8eb3648b..651dfaa9 100644 --- a/lib/features/path/presentation/path_lesson_row.dart +++ b/lib/features/path/presentation/path_lesson_row.dart @@ -19,15 +19,9 @@ import 'package:flutter/material.dart'; /// A lesson on the path: a bean on the spine, its title, and what the row has /// to say about it. /// -/// **The row is not a card.** The design draws `.lesson-row` as a flat row on a -/// hairline, threaded by a 1px spine that the bean discs punch stops out of — -/// that continuous line is what makes a list of lessons read as a *path*. -/// Cards would break it into separate objects, which is what this looked like -/// until [#435](https://github.com/maximsan/brewpath/issues/435). -/// -/// It carries the title and one meta word, and deliberately not the lesson's -/// minutes or points: those belonged to the module screen, where a lesson was -/// being chosen. Here the course is the subject and the row is a step in it. +/// **The row is not a card.** The design draws `.lesson-row` flat on a +/// hairline, threaded by a 1px spine the bean discs punch stops out of — that +/// line is what makes a list of lessons read as a *path*. See #435. class PathLessonRow extends StatelessWidget { /// Creates a [PathLessonRow]. const PathLessonRow({ @@ -178,15 +172,12 @@ class _Title extends StatelessWidget { } } -/// The right-hand slot: the lock on a row the free tier does not carry, a -/// chevron on the current lesson, the mastery word on one that needs practice, -/// and nothing at all otherwise. -/// -/// Nothing is the common case, and it is deliberate — a finished lesson that -/// went well says so by the fill of its bean, not by a second label. +/// The right-hand slot: a lock, the chevron on the current lesson, the mastery +/// word on one that needs practice, and nothing at all otherwise. /// -/// One lock per row, and this is where it goes. The spine beside it carries -/// no lock of its own, so there is nothing here to double up. +/// Nothing is the common case and it is deliberate — a finished lesson that +/// went well says so by the fill of its bean. One lock per row, and this is +/// where it goes. class _Meta extends StatelessWidget { const _Meta({required this.entry}); @@ -205,13 +196,11 @@ class _Meta extends StatelessWidget { // Before every other arm: locked is locked, whatever the learner scored // before or wherever the course is pointing. if (entry.isPurchaseLocked) { - return Semantics( - label: LockedRowCopy.partOfFoundations, - child: IconMark( - AppIcon.lock, - size: _lockSize, - color: mood.accent, - ), + return IconMark( + AppIcon.lock, + size: _lockSize, + color: mood.accent, + semanticLabel: LockedRowCopy.partOfFoundations, ); } @@ -240,14 +229,11 @@ class _Meta extends StatelessWidget { } /// The lesson node: a coffee bean on the page canvas, filled to the lesson's -/// best-score ratio. -/// -/// The bean *is* the gauge, so mastery reads as "how full" instead of a word in -/// the margin. Which tone and how full is decided by [lessonNodeGauge]; this -/// widget only turns that decision into mood colours. +/// best-score ratio, so mastery reads as "how full" rather than as a word. /// -/// Its disc is painted in the page colour on purpose: that is what masks the -/// spine behind it into a stop. +/// Which tone and how full is [lessonNodeGauge]'s decision. The disc is +/// painted in the page colour on purpose — that is what masks the spine behind +/// it into a stop. class _LessonNode extends StatelessWidget { const _LessonNode({required this.entry}); diff --git a/lib/features/path/presentation/path_module_section.dart b/lib/features/path/presentation/path_module_section.dart index 8c3cc7bc..fb4fed5a 100644 --- a/lib/features/path/presentation/path_module_section.dart +++ b/lib/features/path/presentation/path_module_section.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/icons/app_icon.dart'; +import 'package:brew_path/core/icons/disclosure_mark.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; +import 'package:brew_path/core/widgets/disclosure.dart'; import 'package:brew_path/core/widgets/module_glyph.dart'; import 'package:brew_path/features/challenges/presentation/path_challenge_node.dart'; import 'package:brew_path/features/monetization/domain/locked_row_copy.dart'; @@ -11,7 +13,6 @@ import 'package:brew_path/features/monetization/presentation/plus_gate_sheet.dar import 'package:brew_path/features/path/domain/path_density.dart'; import 'package:brew_path/features/path/domain/path_module_view.dart'; import 'package:brew_path/features/path/presentation/path_lesson_row.dart'; -import 'package:brew_path/shared/theme/app_motion.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'; @@ -32,81 +33,42 @@ class PathModuleSection extends StatelessWidget { super.key, }); + /// The design's gap between one module and the next. + static const double _sectionGap = 20; + + /// The lock's `size={13}`, which the design sets apart from the caret. + static const double _lockSize = 13; + /// The module and its lessons. final PathModule module; - /// Whether a collapsible module is currently open. Ignored at the densities - /// that cannot collapse. + /// Whether the module is open right now. A locked one never is: it has no + /// lessons to show and no caret to ask with. final bool isExpanded; - /// Opens or shuts a collapsible module. + /// Opens or shuts the module. final VoidCallback onToggle; /// The module before this one, named by a locked row as what opens it. final String? previousTitle; - /// The design's gap between one module and the next. - static const double _sectionGap = 20; - - /// The design's `320ms cubic-bezier(.4,0,.2,1)` — one duration, because the - /// chevron turns *as* the list grows and two constants could drift apart. - static const Duration expandDuration = AppMotion.expand; - - /// Whether the lessons are showing right now. - bool get _isOpen => - module.density.showsLessonsWhenCollapsed || - (module.density.canCollapse && isExpanded); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: _sectionGap), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _Heading( - module: module, - isOpen: _isOpen, - onToggle: onToggle, - previousTitle: previousTitle, - ), - _Lessons(module: module, isOpen: _isOpen), - ], - ), - ); - } -} - -/// Glyph, title and trailing mark — the line every module has at every density. -class _Heading extends StatelessWidget { - const _Heading({ - required this.module, - required this.isOpen, - required this.onToggle, - required this.previousTitle, - }); - - final PathModule module; - final bool isOpen; - final VoidCallback onToggle; - final String? previousTitle; - - /// The design indents the sub-line to the title's left edge: the 32-px glyph - /// column plus the gap beside it. - static const double _titleInset = 44; - static const double _markSize = 13; - @override Widget build(BuildContext context) { final mood = context.mood; final locked = module.density == PathModuleDensity.locked; + final canCollapse = module.density.canCollapse; - final heading = Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( + return Padding( + padding: const EdgeInsets.only(bottom: _sectionGap), + child: Disclosure( + isOpen: isExpanded && canCollapse, + collapsible: canCollapse, + onToggle: _headerTap(context), + semanticsLabel: _semanticsLabel(), + glyphSize: DisclosureMark.sectionCaretSize, + headerPadding: EdgeInsets.zero, + panelPadding: const EdgeInsets.only(top: AppSpacing.xs), + header: Row( children: [ ModuleGlyph(iconName: module.iconName, locked: locked), const SizedBox(width: AppSpacing.sm), @@ -119,169 +81,134 @@ class _Heading extends StatelessWidget { ), ), ), - _TrailingMark(module: module, isOpen: isOpen, size: _markSize), ], ), - if (_subLine(module, previousTitle) case final line?) ...[ - const SizedBox(height: AppSpacing.xs), - Padding( - padding: const EdgeInsets.only(left: _titleInset), - // Uppercase is the type rule, not part of what the line says, so - // the reader is given it as written — the same split - // `SmallcapsLabel` makes at the label step. - child: Semantics( - label: line, - excludeSemantics: true, - child: Text( - line.toUpperCase(), - style: AppText.micro(mood: mood), - ), - ), - ), - ], - ], + trailing: locked ? _LockMark(module: module, size: _lockSize) : null, + below: _SubLine(module: module, previousTitle: previousTitle), + child: _Lessons(module: module), + ), ); + } - // The one locked row that does something on tap: it is where someone who - // has not bought the course meets the wall, so it offers the way past. + /// What a tap on the heading does, or null where it does nothing. + /// + /// The one locked row that answers a tap is the purchase: it is where + /// someone who has not bought the course meets the wall, so it offers the + /// way past instead of the lessons. + VoidCallback? _headerTap(BuildContext context) { if (module.isPurchaseLocked) { - return Semantics( - button: true, - label: LockedRowCopy.purchaseLockedSemantics(module.title), - excludeSemantics: true, - child: InkWell( - onTap: () => unawaited( - showPlusGate(context, LockedModule(title: module.title)), - ), - child: heading, - ), - ); + return () => + unawaited(showPlusGate(context, LockedModule(title: module.title))); } - - if (!module.density.canCollapse) { - // Nothing to toggle: the row is a label, and wrapping it in a disabled - // button would announce an action that does not exist. - return heading; - } - - // Only a finished module gets here, and completion is the one thing this - // row does not say out loud: the design signals it by *removing* the - // lesson-count line, which leaves a screen reader nothing to read. - return Semantics( - button: true, - expanded: isOpen, - label: AppLabels.moduleCompleteSemantics(module.title), - excludeSemantics: true, - child: InkWell(onTap: onToggle, child: heading), - ); + return module.density.canCollapse ? onToggle : null; } - /// The mono line under a module's title. Only a locked module has one: an - /// active module lists its lessons instead, and a finished one says nothing. + /// What a screen reader hears in place of the heading's own parts, or null + /// where they read well enough on their own. /// - /// When a module is locked both ways, the purchase wins. Someone who has not - /// bought the course will never finish the module before it either, so - /// naming that module is advice they cannot take. ADR-0016. - static String? _subLine(PathModule module, String? previousTitle) { - if (!module.density.isLocked) return null; + /// Completion is the one thing a finished module does not say out loud: the + /// design signals it by *removing* the lesson-count line. + String? _semanticsLabel() { if (module.isPurchaseLocked) { - return LockedRowCopy.purchasedModule(module.totalCount); + return LockedRowCopy.purchaseLockedSemantics(module.title); } - return previousTitle == null - ? LockedRowCopy.moduleSize(module.totalCount) - : LockedRowCopy.finishToUnlock(previousTitle); + return module.density == PathModuleDensity.complete + ? AppLabels.moduleCompleteSemantics(module.title) + : null; } } -/// The lock, or the chevron that turns as a finished module opens. -class _TrailingMark extends StatelessWidget { - const _TrailingMark({ - required this.module, - required this.isOpen, - required this.size, - }); - - /// Half a turn: the chevron points down shut and up open. - static const double _openTurns = 0.5; +/// The lock on a module that is out of reach, in the ink its reason earns. +class _LockMark extends StatelessWidget { + const _LockMark({required this.module, required this.size}); final PathModule module; - final bool isOpen; final double size; @override Widget build(BuildContext context) { final mood = context.mood; - if (module.density.isLocked) { - // Accent for the purchase, ink-mute for progression. Accent means - // there is something to do, and buying is the one they can do now. - return Semantics( - label: module.isPurchaseLocked ? LockedRowCopy.partOfFoundations : null, - child: IconMark( - AppIcon.lock, - size: size, - color: module.isPurchaseLocked ? mood.accent : mood.inkMute, - ), - ); - } - if (!module.density.canCollapse) return const SizedBox.shrink(); + // Accent for the purchase, ink-mute for progression. Accent means there is + // something to do, and buying is the one they can do now. + return IconMark( + AppIcon.lock, + size: size, + color: module.isPurchaseLocked ? mood.accent : mood.inkMute, + semanticLabel: module.isPurchaseLocked + ? LockedRowCopy.partOfFoundations + : null, + ); + } +} + +/// The mono line under a module's title. Only a locked module has one: an +/// active module lists its lessons instead, and a finished one says nothing. +class _SubLine extends StatelessWidget { + const _SubLine({required this.module, required this.previousTitle}); - final chevron = IconMark(AppIcon.chevron, size: size, color: mood.inkMute); + final PathModule module; + final String? previousTitle; + + @override + Widget build(BuildContext context) { + final line = _text(); + if (line == null) return const SizedBox.shrink(); - return MediaQuery.disableAnimationsOf(context) - ? RotatedBox(quarterTurns: isOpen ? 2 : 0, child: chevron) - : AnimatedRotation( - turns: isOpen ? _openTurns : 0, - duration: PathModuleSection.expandDuration, - child: chevron, - ); + return Padding( + padding: const EdgeInsets.only( + top: AppSpacing.xs, + left: ModuleGlyph.titleInset, + ), + // Uppercase is the type rule, not part of what the line says, so the + // reader is given it as written — the same split `SmallcapsLabel` makes. + child: Semantics( + label: line, + excludeSemantics: true, + child: Text( + line.toUpperCase(), + style: AppText.micro(mood: context.mood), + ), + ), + ); + } + + /// When a module is locked both ways the purchase wins: someone who has not + /// bought the course will never finish the module before it, so naming that + /// module is advice they cannot take. ADR-0016. + String? _text() { + if (!module.density.isLocked) return null; + if (module.isPurchaseLocked) { + return LockedRowCopy.purchasedModule(module.totalCount); + } + return previousTitle == null + ? LockedRowCopy.moduleSize(module.totalCount) + : LockedRowCopy.finishToUnlock(previousTitle!); } } -/// The lesson list, and the way it grows and shrinks. +/// The lesson list a module opens onto. class _Lessons extends StatelessWidget { - const _Lessons({required this.module, required this.isOpen}); + const _Lessons({required this.module}); final PathModule module; - final bool isOpen; @override Widget build(BuildContext context) { - final lessons = isOpen - ? Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < module.lessons.length; i++) - PathLessonRow( - entry: module.lessons[i], - isLast: i == module.lessons.length - 1, - ), - // The module's Coffee Challenge — Path is the only place a - // challenge appears outside Today. Inside the collapsible - // region, as the design nests it: a finished module that is - // shut is not still offering its brew. A locked module never - // opens, so it never shows one. - if (!module.density.isLocked) - PathChallengeNode(moduleId: module.id), - ], - ), - ) - : const SizedBox.shrink(); - - // ⚠️ Reduced motion drops the animator rather than giving it a zero - // duration: `AnimatedSize` re-dirties itself inside its own - // `performLayout` when asked to finish instantly, which the framework - // asserts on. Same reasoning as `AppHeader`'s collapse. - return MediaQuery.disableAnimationsOf(context) - ? lessons - : AnimatedSize( - duration: PathModuleSection.expandDuration, - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - child: lessons, - ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < module.lessons.length; i++) + PathLessonRow( + entry: module.lessons[i], + isLast: i == module.lessons.length - 1, + ), + // The module's Coffee Challenge — Path is the only place a challenge + // appears outside Today. Inside the panel, as the design nests it: a + // finished module that is shut is not still offering its brew. + if (!module.density.isLocked) PathChallengeNode(moduleId: module.id), + ], + ); } } diff --git a/lib/features/path/presentation/path_screen.dart b/lib/features/path/presentation/path_screen.dart index 4993c704..924c9da6 100644 --- a/lib/features/path/presentation/path_screen.dart +++ b/lib/features/path/presentation/path_screen.dart @@ -28,18 +28,21 @@ class PathScreen extends ConsumerStatefulWidget { } class _PathScreenState extends ConsumerState { - /// Which finished modules the learner has opened. + /// The modules the learner has opened or shut by hand, against the density's + /// own default. /// /// View state, not progress: it is a reading position, it means nothing on /// the next launch, and storing it would put a UI preference in the progress - /// database. Only completed modules can be in here — the other two densities - /// are fixed open or shut and have nothing to remember. - final _expanded = {}; + /// database. + final _toggled = {}; - void _toggle(String moduleId) { - setState(() { - if (!_expanded.remove(moduleId)) _expanded.add(moduleId); - }); + /// Whether [module] is open: what the learner last said, or failing that + /// what its density does unasked. + bool _isOpen(PathModule module) => + _toggled[module.id] ?? module.density.opensUnasked; + + void _toggle(PathModule module) { + setState(() => _toggled[module.id] = !_isOpen(module)); } @override @@ -72,12 +75,13 @@ class _PathScreenState extends ConsumerState { for (var i = 0; i < list.length; i++) PathModuleSection( module: list[i], - isExpanded: _expanded.contains(list[i].id), - onToggle: () => _toggle(list[i].id), + isExpanded: _isOpen(list[i]), + onToggle: () => _toggle(list[i]), previousTitle: i == 0 ? null : list[i - 1].item.module.title, ), // Last on Path: the course's own appendix, at the end of the thing - // it summarises. + // it summarises, at the design's `marginTop: 12`. + const SizedBox(height: AppSpacing.sm), const ReferenceSection(), ], ), diff --git a/lib/features/path/presentation/reference_section.dart b/lib/features/path/presentation/reference_section.dart index fa1fecf3..3826155a 100644 --- a/lib/features/path/presentation/reference_section.dart +++ b/lib/features/path/presentation/reference_section.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/caret_mark.dart'; +import 'package:brew_path/core/icons/disclosure_mark.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; +import 'package:brew_path/core/widgets/disclosure.dart'; +import 'package:brew_path/core/widgets/module_glyph.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; import 'package:brew_path/core/widgets/visual_guide_art.dart'; import 'package:brew_path/features/monetization/domain/locked_row_copy.dart'; @@ -12,10 +14,10 @@ import 'package:brew_path/features/path/domain/visual_guide_providers.dart'; import 'package:brew_path/features/path/domain/visual_guide_shelf.dart'; import 'package:brew_path/features/path/presentation/visual_guide_sheet.dart'; import 'package:brew_path/shared/models/content/visual_guide.dart'; -import 'package:brew_path/shared/theme/app_motion.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'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -41,10 +43,6 @@ String _lockedSubtitle({required bool byPurchase, required String? nextTitle}) { const double _glyphSize = 24; const double _lockSize = 16; -/// How long the section takes to open when motion is allowed. The design -/// animates the expansion; this is that, in Flutter's terms. -const Duration _expandDuration = AppMotion.expand; - /// The last thing on Path: the illustrated references a learner has earned. /// /// A section rather than a boxed card, because Path is editorial. **Locked @@ -83,126 +81,64 @@ class _ReferenceSectionState extends ConsumerState { nextTitle: ref.watch(nextGuideUnlockProvider).asData?.value, ); final isOpen = _isOpen && !shelf.isLocked; + final mood = context.mood; + final ink = shelf.isLocked ? mood.inkMute : mood.ink; return Semantics( container: true, label: shelf.isLocked ? '$_title, locked. $subtitle' : '$_title, ${shelf.earned.length} guides', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _Heading( - isLocked: shelf.isLocked, - isOpen: isOpen, - subtitle: shelf.isLocked ? subtitle : _openSubtitle, - // A locked section will not open onto nothing. If the lock is - // the purchase, it offers the way past instead. - onTap: !shelf.isLocked - ? () => setState(() => _isOpen = !_isOpen) - : byPurchase - ? () => unawaited( - showPlusGate(context, const LockedGuides()), - ) - : null, - ), - _Expansion( - isOpen: isOpen, - child: _Guides(shelf: shelf), - ), - ], - ), - ); - } -} - -/// The opening and closing itself. -/// -/// ⚠️ **Reduced motion drops the animator rather than zeroing it.** -/// `AnimatedSize` handed `Duration.zero` re-dirties itself inside its own -/// `performLayout`, which the framework asserts on — a sweep test forbids it. -class _Expansion extends StatelessWidget { - const _Expansion({required this.isOpen, required this.child}); - - final bool isOpen; - final Widget child; - - @override - Widget build(BuildContext context) { - final shown = isOpen ? child : const SizedBox(width: double.infinity); - if (MediaQuery.disableAnimationsOf(context)) return shown; - - return AnimatedSize( - duration: _expandDuration, - curve: Curves.easeOut, - alignment: Alignment.topCenter, - child: shown, - ); - } -} - -class _Heading extends StatelessWidget { - const _Heading({ - required this.isLocked, - required this.isOpen, - required this.subtitle, - required this.onTap, - }); - - final bool isLocked; - final bool isOpen; - - /// The line under the title, already chosen for this learner. - final String subtitle; - - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final mood = context.mood; - final ink = isLocked ? mood.inkMute : mood.ink; - - return Semantics( - button: onTap != null, - // Null while there is no expanded state to be in — locked, or locked - // behind a purchase, where the tap opens an offer rather than the shelf. - expanded: onTap == null || isLocked ? null : isOpen, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconMark(AppIcon.module, size: _glyphSize, color: ink), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - _title, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(color: ink), - ), - ), - if (isLocked) - IconMark(AppIcon.lock, size: _lockSize, color: ink) - else - CaretMark(open: isOpen, color: mood.inkMute), - ], + child: Disclosure( + isOpen: isOpen, + collapsible: !shelf.isLocked, + // A locked section will not open onto nothing. If the lock is the + // purchase, it offers the way past instead. + onToggle: _headerTap(locked: shelf.isLocked, byPurchase: byPurchase), + glyphSize: DisclosureMark.sectionCaretSize, + headerPadding: EdgeInsets.zero, + panelPadding: EdgeInsets.only(top: OffTokens.referenceShelfHead.value), + header: Row( + children: [ + // The same 32-px column a module glyph sits in, so Reference's + // title and caption line up with every module above it. + SizedBox( + width: ModuleGlyph.columnWidth, + child: Center( + child: IconMark(AppIcon.module, size: _glyphSize, color: ink), ), - const SizedBox(height: AppSpacing.xxs), - Padding( - padding: const EdgeInsets.only(left: AppSpacing.xl), - child: SmallcapsLabel(subtitle), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + _title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: ink), ), - ], + ), + ], + ), + trailing: shelf.isLocked + ? IconMark(AppIcon.lock, size: _lockSize, color: ink) + : null, + below: Padding( + padding: const EdgeInsets.only( + top: AppSpacing.xs, + left: ModuleGlyph.titleInset, ), + child: SmallcapsLabel(shelf.isLocked ? subtitle : _openSubtitle), ), + child: _Guides(shelf: shelf), ), ); } + + VoidCallback? _headerTap({required bool locked, required bool byPurchase}) { + if (!locked) return () => setState(() => _isOpen = !_isOpen); + if (!byPurchase) return null; + return () => unawaited(showPlusGate(context, const LockedGuides())); + } } class _Guides extends StatelessWidget { diff --git a/lib/features/profile/presentation/settings/help_faq_row.dart b/lib/features/profile/presentation/settings/help_faq_row.dart index 6edcb0b3..0df4920f 100644 --- a/lib/features/profile/presentation/settings/help_faq_row.dart +++ b/lib/features/profile/presentation/settings/help_faq_row.dart @@ -1,7 +1,7 @@ import 'package:brew_path/core/icons/disclosure_mark.dart'; +import 'package:brew_path/core/widgets/disclosure.dart'; import 'package:brew_path/features/profile/domain/help_faq.dart'; import 'package:brew_path/features/profile/presentation/settings/settings_copy.dart'; -import 'package:brew_path/shared/theme/app_motion.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'; @@ -9,8 +9,8 @@ import 'package:flutter/material.dart'; /// One question, with its answer opening inline beneath it. /// -/// The mark is the design's own plus turning into a cross, which the owner -/// ruled in for the FAQ. +/// The mark is the design's plus turning into a cross rather than the caret +/// every other section takes: an answer is prose, not a count of things. class HelpFaqRow extends StatelessWidget { /// Creates a row for [entry], open or closed, toggled by [onToggle]. const HelpFaqRow({ @@ -31,77 +31,25 @@ class HelpFaqRow extends StatelessWidget { @override Widget build(BuildContext context) { - final mood = context.mood; - - return Semantics( - button: true, - expanded: isOpen, - child: InkWell( - onTap: onToggle, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - child: Row( - children: [ - Expanded( - child: Text( - entry.question, - style: AppText.body(mood: mood), - ), - ), - const SizedBox(width: AppSpacing.sm), - DisclosureMark(open: isOpen, color: mood.inkMute), - ], - ), - ), - _Answer(isOpen: isOpen, answer: entry.answer), - Divider(height: 0, thickness: 1, color: mood.rule), - ], - ), - ), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter), + child: Disclosure( + isOpen: isOpen, + onToggle: onToggle, + label: entry.question, + glyph: DisclosureGlyph.plus, + divider: true, + // The design's `margin: '0 0 16px'` under the answer's paragraph. + panelPadding: const EdgeInsets.only(bottom: AppSpacing.md), + child: _Answer(answer: entry.answer), ), ); } } -/// The answer, revealed without an animator when motion is reduced. -/// -/// ⚠️ Reduced motion **drops** `AnimatedSize` rather than zeroing it: handed -/// `Duration.zero` it re-dirties itself inside its own `performLayout`, which -/// the framework asserts on — the defect a sweep test now forbids. -class _Answer extends StatelessWidget { - const _Answer({required this.isOpen, required this.answer}); - - final bool isOpen; - final String? answer; - - @override - Widget build(BuildContext context) { - final shown = isOpen - ? Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: _Body(answer: answer), - ) - : const SizedBox(width: double.infinity); - - if (MediaQuery.disableAnimationsOf(context)) return shown; - - return AnimatedSize( - duration: AppMotion.expand, - curve: Curves.easeOut, - alignment: Alignment.topCenter, - child: shown, - ); - } -} - /// The answer, or the wait for the counts it is built from. -class _Body extends StatelessWidget { - const _Body({required this.answer}); +class _Answer extends StatelessWidget { + const _Answer({required this.answer}); final String? answer; diff --git a/lib/shared/theme/app_motion.dart b/lib/shared/theme/app_motion.dart index 87018b24..e7b06cb9 100644 --- a/lib/shared/theme/app_motion.dart +++ b/lib/shared/theme/app_motion.dart @@ -1,12 +1,21 @@ -/// The design's durations, so a timing is stated once. +import 'package:flutter/animation.dart'; + +/// The design's durations and easings, so a timing is stated once. /// /// Mood-independent, like `AppSpacing` — a `static const` on a class with no /// `of(context)`, so a painter can read one with no `BuildContext`. abstract final class AppMotion { - /// The design's `240ms cubic-bezier` opening, rounded to the app's step — - /// what every accordion, snap and frame move already used separately. + /// The design's `320ms cubic-bezier` move, which the Tour's frame travels + /// over and a match line snaps home over. static const Duration expand = Duration(milliseconds: 320); - /// The design's `transition: transform 200ms ease` on a disclosure mark. - static const Duration markTurn = Duration(milliseconds: 200); + /// The design's `240ms` disclosure: the panel's growth and the glyph's turn, + /// one duration because they move as one thing. + static const Duration disclosure = Duration(milliseconds: 240); + + /// The panel's own `cubic-bezier(.2,.8,.2,1)` — away fast, settling slowly. + static const Curve disclosurePanel = Cubic(0.2, 0.8, 0.2, 1); + + /// The glyph's `cubic-bezier(.4,0,.2,1)`, which leaves later than the panel. + static const Curve disclosureGlyph = Cubic(0.4, 0, 0.2, 1); } diff --git a/lib/shared/theme/off_token.dart b/lib/shared/theme/off_token.dart index d103994d..dd577721 100644 --- a/lib/shared/theme/off_token.dart +++ b/lib/shared/theme/off_token.dart @@ -114,6 +114,12 @@ abstract final class OffTokens { reason: 'the practice shelf pairs a label with its count at `gap: 10`', ); + /// The room above the first guide on Reference's open shelf. + static const OffToken referenceShelfHead = OffToken( + 6, + reason: 'the guide list opens at `marginTop: 6`', + ); + /// The room under an open practice group's last row. static const OffToken practiceGroupFoot = OffToken( 6, @@ -518,6 +524,7 @@ abstract final class OffTokens { todayLeadGap, todayCtaGap, practiceInlineGap, + referenceShelfHead, practiceGroupFoot, cardsFooterPadding, cardsFooterLineGap, diff --git a/test/support/practice_shelf.dart b/test/support/practice_shelf.dart index 3cd5810f..8ead7567 100644 --- a/test/support/practice_shelf.dart +++ b/test/support/practice_shelf.dart @@ -1,4 +1,5 @@ import 'package:brew_path/features/learn/presentation/practice/practice_group.dart'; +import 'package:brew_path/shared/theme/app_motion.dart'; import 'package:flutter_test/flutter_test.dart'; /// Opens the practice group named [label] on the Learn tab. @@ -13,5 +14,5 @@ Future openPracticeGroup(WidgetTester tester, String label) async { ), ); await tester.pump(); - await tester.pump(PracticeGroup.turnDuration); + await tester.pump(AppMotion.disclosure); } diff --git a/test/unit/features/path/path_density_test.dart b/test/unit/features/path/path_density_test.dart index d6de7c59..c9118de5 100644 --- a/test/unit/features/path/path_density_test.dart +++ b/test/unit/features/path/path_density_test.dart @@ -56,16 +56,16 @@ void main() { } }); - test('only complete collapses; the other two are fixed open or shut', () { + test('every reachable density collapses; a locked one has no caret', () { expect(PathModuleDensity.complete.canCollapse, isTrue); - expect(PathModuleDensity.active.canCollapse, isFalse); + expect(PathModuleDensity.active.canCollapse, isTrue); expect(PathModuleDensity.locked.canCollapse, isFalse); }); - test('an active module is open and a locked one lists nothing', () { - expect(PathModuleDensity.active.showsLessonsWhenCollapsed, isTrue); - expect(PathModuleDensity.locked.showsLessonsWhenCollapsed, isFalse); - expect(PathModuleDensity.complete.showsLessonsWhenCollapsed, isFalse); + test('only the active module lists its lessons unasked', () { + expect(PathModuleDensity.active.opensUnasked, isTrue); + expect(PathModuleDensity.complete.opensUnasked, isFalse); + expect(PathModuleDensity.locked.opensUnasked, isFalse); }); }); diff --git a/test/widget/core/widgets/disclosure_test.dart b/test/widget/core/widgets/disclosure_test.dart new file mode 100644 index 00000000..4ad86b27 --- /dev/null +++ b/test/widget/core/widgets/disclosure_test.dart @@ -0,0 +1,189 @@ +import 'package:brew_path/app/app_theme.dart'; +import 'package:brew_path/core/icons/disclosure_mark.dart'; +import 'package:brew_path/core/widgets/disclosure.dart'; +import 'package:brew_path/shared/theme/app_motion.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Harness extends StatefulWidget { + const _Harness({ + this.glyph = DisclosureGlyph.caret, + this.collapsible = true, + this.startOpen = false, + this.semanticsLabel, + this.disableAnimations = false, + }); + + final DisclosureGlyph glyph; + final bool collapsible; + final bool startOpen; + final String? semanticsLabel; + final bool disableAnimations; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + late bool _open = widget.startOpen; + + @override + Widget build(BuildContext context) => MaterialApp( + theme: AppTheme.darkRoast, + home: MediaQuery( + data: MediaQueryData(disableAnimations: widget.disableAnimations), + child: Scaffold( + body: Disclosure( + isOpen: _open, + collapsible: widget.collapsible, + glyph: widget.glyph, + label: 'Question', + semanticsLabel: widget.semanticsLabel, + onToggle: widget.collapsible + ? () => setState(() => _open = !_open) + : null, + child: const Text('Answer'), + ), + ), + ), + ); +} + +Future _toggle(WidgetTester tester) async { + await tester.tap(find.text('Question')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a shut panel builds nothing at all', (tester) async { + await tester.pumpWidget(const _Harness()); + + expect(find.text('Question'), findsOneWidget); + expect(find.text('Answer'), findsNothing); + }); + + testWidgets('opens on a tap and shuts on another', (tester) async { + await tester.pumpWidget(const _Harness()); + + await _toggle(tester); + expect(find.text('Answer'), findsOneWidget); + + await _toggle(tester); + expect(find.text('Answer'), findsNothing); + }); + + testWidgets('the panel grows over the design 240ms', (tester) async { + await tester.pumpWidget(const _Harness()); + + await tester.tap(find.text('Question')); + await tester.pump(); + final opening = tester.getSize(find.text('Answer')).height; + + await tester.pump(const Duration(milliseconds: 120)); + final midway = tester.getSize(find.byType(ClipRect)).height; + expect(midway, greaterThan(0)); + expect(midway, lessThan(opening)); + + await tester.pump(AppMotion.disclosure); + expect(tester.getSize(find.byType(ClipRect)).height, opening); + }); + + testWidgets('reduced motion cuts the move rather than dropping it', ( + tester, + ) async { + await tester.pumpWidget(const _Harness(disableAnimations: true)); + + await tester.tap(find.text('Question')); + await tester.pump(); + + expect( + tester.getSize(find.byType(ClipRect)).height, + tester.getSize(find.text('Answer')).height, + ); + }); + + testWidgets('the caret turns half a turn as it opens', (tester) async { + await tester.pumpWidget(const _Harness(disableAnimations: true)); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0, + ); + + await _toggle(tester); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0.5, + ); + }); + + testWidgets('the plus turns an eighth as it opens', (tester) async { + await tester.pumpWidget( + const _Harness(glyph: DisclosureGlyph.plus, disableAnimations: true), + ); + + await _toggle(tester); + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + 0.125, + ); + }); + + testWidgets('an open panel fills the width it is given', (tester) async { + await tester.pumpWidget(const _Harness(disableAnimations: true)); + await _toggle(tester); + + expect( + tester.getTopLeft(find.text('Answer')).dx, + tester.getTopLeft(find.byType(Disclosure)).dx, + ); + }); + + testWidgets('the glyph is centred on the header, not pinned to its top', ( + tester, + ) async { + await tester.pumpWidget(const _Harness(disableAnimations: true)); + + // `CrossAxisAlignment.baseline` puts a mark reporting no baseline — every + // mark — at the top of the row, 6px above where the design draws it. + expect( + tester.getRect(find.byType(DisclosureMark)).center.dy, + closeTo(tester.getRect(find.text('Question')).center.dy, 1), + ); + }); + + testWidgets('a fixed header is a heading with no glyph', (tester) async { + await tester.pumpWidget( + const _Harness(collapsible: false, startOpen: true), + ); + + expect(find.byType(DisclosureMark), findsNothing); + expect(find.byType(InkWell), findsNothing); + expect(find.text('Answer'), findsOneWidget); + }); + + testWidgets('is announced as a button that expands', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(const _Harness(semanticsLabel: 'Question, 3')); + + final header = find.bySemanticsLabel('Question, 3'); + expect( + tester.getSemantics(header), + isSemantics(isButton: true, hasExpandedState: true, isExpanded: false), + ); + + await _toggle(tester); + expect(tester.getSemantics(header), isSemantics(isExpanded: true)); + handle.dispose(); + }); + + testWidgets('a shut panel is out of the semantics tree', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(const _Harness()); + + expect(find.bySemanticsLabel('Answer'), findsNothing); + + await _toggle(tester); + expect(find.bySemanticsLabel('Answer'), findsOneWidget); + handle.dispose(); + }); +} diff --git a/test/widget/features/challenges/saved_challenges_list_test.dart b/test/widget/features/challenges/saved_challenges_list_test.dart index 626f7791..4a15f263 100644 --- a/test/widget/features/challenges/saved_challenges_list_test.dart +++ b/test/widget/features/challenges/saved_challenges_list_test.dart @@ -32,17 +32,25 @@ void main() { // A header over an empty list tells the learner they are missing // something rather than that there is nothing to miss. - expect(find.text('SAVED CHALLENGES'), findsNothing); + expect(find.textContaining('SAVED CHALLENGES'), findsNothing); expect(find.byType(SizedBox), findsWidgets); }); + Future open(WidgetTester tester) async { + await tester.tap(find.textContaining('SAVED CHALLENGES')); + await tester.pumpAndSettle(); + } + testWidgets('lists what is parked', (tester) async { await pump(tester, [ testChallenge(), testChallenge(id: 'bc-m2', title: 'Blind process test'), ]); - expect(find.text('SAVED CHALLENGES'), findsOneWidget); + expect(find.text('SAVED CHALLENGES · 2'), findsOneWidget); + expect(find.text('Two cups, two ratios'), findsNothing); + + await open(tester); expect(find.text('Two cups, two ratios'), findsOneWidget); expect(find.text('Blind process test'), findsOneWidget); expect(find.text('Next brews · 5 min'), findsNWidgets(2)); @@ -50,6 +58,7 @@ void main() { testWidgets('starting one puts it in play', (tester) async { await pump(tester, [testChallenge()]); + await open(tester); await tester.tap(find.text('Start')); await tester.pump(); @@ -64,6 +73,7 @@ void main() { testWidgets('removing one is reachable by its own label', (tester) async { await pump(tester, [testChallenge()]); + await open(tester); expect( find.byTooltip('Remove Two cups, two ratios from saved'), diff --git a/test/widget/features/learn/practice_group_test.dart b/test/widget/features/learn/practice_group_test.dart index b3d5b441..82cf3de3 100644 --- a/test/widget/features/learn/practice_group_test.dart +++ b/test/widget/features/learn/practice_group_test.dart @@ -20,8 +20,7 @@ Future _pump(WidgetTester tester, {bool isLast = false}) => Future _toggle(WidgetTester tester) async { await tester.tap(find.text('Games')); - await tester.pump(); - await tester.pump(PracticeGroup.turnDuration); + await tester.pumpAndSettle(); } void main() { diff --git a/test/widget/features/path/path_screen_test.dart b/test/widget/features/path/path_screen_test.dart index 376c4e4b..816b04c6 100644 --- a/test/widget/features/path/path_screen_test.dart +++ b/test/widget/features/path/path_screen_test.dart @@ -1,4 +1,5 @@ import 'package:brew_path/core/constants/app_labels.dart'; +import 'package:brew_path/core/icons/disclosure_mark.dart'; import 'package:brew_path/features/challenges/presentation/path_challenge_node.dart'; import 'package:brew_path/features/learn/domain/learn_providers.dart'; import 'package:brew_path/features/path/domain/path_density.dart'; @@ -135,15 +136,30 @@ void main() { expect(find.text('Lesson 3.1'), findsNothing); }); - testWidgets('the active module cannot be collapsed away', (tester) async { + testWidgets('the active module opens itself and still folds away', ( + tester, + ) async { await _pumpPath(tester); + expect(find.text('Lesson 2.1'), findsOneWidget); await tester.tap(find.text('Module 2')); await tester.pumpAndSettle(); + expect(find.text('Lesson 2.1'), findsNothing); + await tester.tap(find.text('Module 2')); + await tester.pumpAndSettle(); expect(find.text('Lesson 2.1'), findsOneWidget); }); + testWidgets('every module the learner can reach carries a caret', ( + tester, + ) async { + await _pumpPath(tester); + + // Modules 1 and 2 are reachable; module 3 is not and has nothing to open. + expect(find.byType(DisclosureMark), findsNWidgets(2)); + }); + testWidgets('the header counts lessons and draws no progress bar', ( tester, ) async { diff --git a/test/widget/features/profile/help_support_screen_test.dart b/test/widget/features/profile/help_support_screen_test.dart index 2cc03cc7..9c380d82 100644 --- a/test/widget/features/profile/help_support_screen_test.dart +++ b/test/widget/features/profile/help_support_screen_test.dart @@ -145,7 +145,12 @@ void main() { testWidgets('every row announces itself as an expander', (tester) async { await pump(tester); - final semantics = tester.getSemantics(find.byType(HelpFaqRow).first); + final semantics = tester.getSemantics( + find.descendant( + of: find.byType(HelpFaqRow).first, + matching: find.byType(InkWell), + ), + ); expect(semantics.flagsCollection.isButton, isTrue); expect(