Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions lib/features/tour/domain/tour_geometry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double> _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];
}
71 changes: 52 additions & 19 deletions lib/features/tour/presentation/today_tour.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -177,7 +179,7 @@ class _TodayTourState extends State<TodayTour> {
),
),
),
_cardSlot(constraints.maxHeight),
_cardSlot(),
],
),
);
Expand All @@ -196,24 +198,18 @@ class _TodayTourState extends State<TodayTour> {
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.
Expand All @@ -224,6 +220,43 @@ class _TodayTourState extends State<TodayTour> {
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;
}
113 changes: 113 additions & 0 deletions test/unit/features/tour/tour_geometry_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
}
Loading