diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27e0a922..080fb310 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -110,6 +110,13 @@ You can always edit this file by hand instead — the helpers just save effort. ### Fixed +- **The Tour's first card no longer opens under the clock.** Its card takes the + side of the target the design names whenever it fits there, and the other + side when it does not — so the tall Today card, which the design's rule sends + it above and which leaves only the status bar's strip there, now gets a card + below it instead. On a phone the first stop had been drawn behind the status + bar with the time struck through its title; the design is drawn against a + mock that has no status bar, so nothing in the rule could see it. - **The lesson ending puts what you earned under the tree it fed.** The points and the *lessons to the next stage* countdown were the wrong way round, so the payout sat a line further from the tree than the design places it. diff --git a/lib/features/tour/domain/tour_geometry.dart b/lib/features/tour/domain/tour_geometry.dart index 5cdb71f8..f270ed04 100644 --- a/lib/features/tour/domain/tour_geometry.dart +++ b/lib/features/tour/domain/tour_geometry.dart @@ -49,3 +49,50 @@ double tourBottomOverflow({ required Rect target, required double viewportHeight, }) => target.bottom - viewportHeight + OffTokens.tourScrollCardClearance.value; + +/// The top edge of a card [cardHeight] tall, on a layer [areaHeight] tall. +/// +/// The design picks a side; where that side cannot hold the card clear of +/// [safeArea] and the other can, the other one takes it. The mock the rule is +/// drawn against has no status bar, so above a tall target near the top of the +/// feed the card ran under the clock on a real phone. +double tourCardTop({ + required Rect? target, + required double areaHeight, + required double cardHeight, + required EdgeInsets safeArea, +}) { + final gap = OffTokens.tourCardInset.value; + final highest = safeArea.top + gap; + final lowest = areaHeight - safeArea.bottom - gap - cardHeight; + // A card with less room than it has height shows its top — the counter, the + // title and the start of the body — rather than its buttons alone. + if (lowest < highest) return highest; + + final sides = _tourCardSides( + target: target, + areaHeight: areaHeight, + cardHeight: cardHeight, + ); + for (final side in sides) { + if (side >= highest && side <= lowest) return side; + } + return sides.first.clamp(highest, lowest); +} + +/// Where the card could go, the design's own side first. +List _tourCardSides({ + required Rect? target, + required double areaHeight, + required double cardHeight, +}) { + final gap = OffTokens.tourCardInset.value; + if (target == null) { + return [areaHeight - OffTokens.tourCardRestingBottom.value - cardHeight]; + } + final below = target.bottom + gap; + final above = target.top - gap - cardHeight; + return tourCardSitsBelow(target: target, areaHeight: areaHeight) + ? [below, above] + : [above, below]; +} diff --git a/lib/features/tour/presentation/today_tour.dart b/lib/features/tour/presentation/today_tour.dart index b3528a0d..0ac552e8 100644 --- a/lib/features/tour/presentation/today_tour.dart +++ b/lib/features/tour/presentation/today_tour.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:brew_path/core/widgets/fade_up.dart'; import 'package:brew_path/features/tour/domain/tour_copy.dart'; import 'package:brew_path/features/tour/domain/tour_geometry.dart'; @@ -177,7 +179,7 @@ class _TodayTourState extends State { ), ), ), - _cardSlot(constraints.maxHeight), + _cardSlot(), ], ), ); @@ -196,24 +198,18 @@ class _TodayTourState extends State { if (previous != null) _arriveAt(_step); } - /// Where the card sits: under the target where there is room for it, over it - /// where there is not, and at rest near the foot until anything is measured. - Widget _cardSlot(double areaHeight) { - final target = _target; - final gap = OffTokens.tourCardInset.value; - final below = - target == null || - tourCardSitsBelow(target: target, areaHeight: areaHeight); - - return Positioned( - left: gap, - right: gap, - top: target != null && below ? target.bottom + gap : null, - bottom: switch (target) { - null => OffTokens.tourCardRestingBottom.value, - final Rect measured when !below => areaHeight - measured.top + gap, - _ => null, - }, + /// Where the card sits: the design's side of the target where it fits there, + /// the other side where it does not, and at rest near the foot until + /// anything is measured. + /// + /// Laid out rather than `Positioned`, because which sides fit is only + /// answerable once the card's own height is known — see [tourCardTop]. + Widget _cardSlot() => Positioned.fill( + child: CustomSingleChildLayout( + delegate: _TourCardSlot( + target: _target, + safeArea: MediaQuery.viewPaddingOf(context), + ), child: FadeUp( // Keyed by the stop, so each card fades up as it arrives rather than // the words changing inside one that is already there. @@ -224,6 +220,43 @@ class _TodayTourState extends State { onAdvance: _advance, ), ), + ), + ); +} + +/// Puts the card at the design's side of the target, kept inside the safe area. +class _TourCardSlot extends SingleChildLayoutDelegate { + const _TourCardSlot({required this.target, required this.safeArea}); + + /// The frame's target, or null before the first measurement. + final Rect? target; + + /// What the card stays clear of — the status bar, and the home indicator. + final EdgeInsets safeArea; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final inset = OffTokens.tourCardInset.value; + final width = math.max(0, constraints.maxWidth - inset * 2).toDouble(); + return BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: constraints.maxHeight, ); } + + @override + Offset getPositionForChild(Size size, Size childSize) => Offset( + OffTokens.tourCardInset.value, + tourCardTop( + target: target, + areaHeight: size.height, + cardHeight: childSize.height, + safeArea: safeArea, + ), + ); + + @override + bool shouldRelayout(_TourCardSlot oldDelegate) => + oldDelegate.target != target || oldDelegate.safeArea != safeArea; } diff --git a/test/unit/features/tour/tour_geometry_test.dart b/test/unit/features/tour/tour_geometry_test.dart index 80064898..a067df68 100644 --- a/test/unit/features/tour/tour_geometry_test.dart +++ b/test/unit/features/tour/tour_geometry_test.dart @@ -132,4 +132,117 @@ void main() { ); }); }); + + group('where the card sits', () { + const areaHeight = 852.0; + const cardHeight = 150.0; + const statusBar = EdgeInsets.only(top: 59, bottom: 34); + final gap = OffTokens.tourCardInset.value; + + test('under a target with room beneath it', () { + const target = Rect.fromLTWH(0, 100, 300, 120); + + expect( + tourCardTop( + target: target, + areaHeight: areaHeight, + cardHeight: cardHeight, + safeArea: statusBar, + ), + target.bottom + gap, + ); + }); + + test('over a target with none', () { + const target = Rect.fromLTWH(0, 300, 300, 400); + + expect( + tourCardTop( + target: target, + areaHeight: areaHeight, + cardHeight: cardHeight, + safeArea: statusBar, + ), + target.top - gap - cardHeight, + ); + }); + + test('takes the other side when its own has no room', () { + // The stop-1 case: a tall Today card near the top of the feed. The + // design's rule says above, where only the status bar's strip is left. + const target = Rect.fromLTWH(0, 172, 393, 392); + + final top = tourCardTop( + target: target, + areaHeight: areaHeight, + cardHeight: cardHeight, + safeArea: statusBar, + ); + + expect( + tourCardSitsBelow(target: target, areaHeight: areaHeight), + isFalse, + ); + expect(top, target.bottom + gap); + expect(top, greaterThan(statusBar.top)); + }); + + test('clamps when neither side has room', () { + // A target filling the screen: above is off the top, below off the foot. + const target = Rect.fromLTWH(0, 80, 393, 700); + + final top = tourCardTop( + target: target, + areaHeight: areaHeight, + cardHeight: cardHeight, + safeArea: statusBar, + ); + + expect(top, greaterThanOrEqualTo(statusBar.top + gap)); + expect( + top + cardHeight, + lessThanOrEqualTo(areaHeight - statusBar.bottom - gap), + ); + }); + + test('never under the home indicator', () { + const target = Rect.fromLTWH(0, 700, 393, 100); + + final top = tourCardTop( + target: target, + areaHeight: areaHeight, + cardHeight: cardHeight, + safeArea: statusBar, + ); + + expect( + top + cardHeight, + lessThanOrEqualTo(areaHeight - statusBar.bottom - gap), + ); + }); + + test('at rest near the foot until a target is measured', () { + expect( + tourCardTop( + target: null, + areaHeight: areaHeight, + cardHeight: cardHeight, + safeArea: statusBar, + ), + areaHeight - OffTokens.tourCardRestingBottom.value - cardHeight, + ); + }); + + test('shows its top when the room is shorter than the card', () { + expect( + tourCardTop( + target: const Rect.fromLTWH(0, 100, 300, 120), + areaHeight: 200, + cardHeight: cardHeight, + safeArea: statusBar, + ), + statusBar.top + gap, + ); + }); + }); }