Finished updating the tour

This commit is contained in:
Courtney Arnold 2026-08-15 20:07:16 -05:00
parent 606106e5ec
commit 2fcc6dcab7
7 changed files with 288 additions and 83 deletions

View file

@ -1,17 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// The full-screen "spotlight" shown by one step of the first-launch
/// guided tour: a dimmed barrier with a cut-out hole around whatever
/// [targetKey] currently points to (or, if that key isn't laid out yet or
/// doesn't resolve, just a plain dimmed barrier with the card centered),
/// plus a card explaining that element with Next/Skip controls.
/// guided tour: a dimmed barrier with a cut-out hole around each of
/// [targetKeys]' current on-screen positions (or, for any key that isn't
/// laid out yet / doesn't resolve, no hole for it — falling back to a
/// plain dimmed barrier with the card centered if none resolve), plus a
/// card explaining the step with Next/Skip controls. Most steps highlight
/// a single element, but [targetKeys] can hold more than one — e.g. the
/// first step highlights both the "+" button and the Receipts tab it
/// lives on, together.
///
/// This widget is purely presentational — it doesn't know about tabs,
/// routes, or persistence. `MainShell` is responsible for sequencing
/// steps, navigating to the right screen/route before each one, and
/// recording that the tour's been seen once it ends.
class OnboardingTourOverlay extends StatelessWidget {
final GlobalKey targetKey;
final List<GlobalKey> targetKeys;
final String title;
final String description;
final int stepNumber;
@ -21,7 +26,7 @@ class OnboardingTourOverlay extends StatelessWidget {
const OnboardingTourOverlay({
super.key,
required this.targetKey,
required this.targetKeys,
required this.title,
required this.description,
required this.stepNumber,
@ -30,44 +35,69 @@ class OnboardingTourOverlay extends StatelessWidget {
required this.onSkip,
});
Rect? _targetRect() {
final renderObject = targetKey.currentContext?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
List<Rect> _targetRects() {
final rects = <Rect>[];
for (final key in targetKeys) {
final renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderBox && renderObject.hasSize) {
rects.add((renderObject.localToGlobal(Offset.zero) & renderObject.size).inflate(6));
}
}
return rects;
}
@override
Widget build(BuildContext context) {
final rect = _targetRect()?.inflate(6);
final rects = _targetRects();
final media = MediaQuery.of(context);
final isLastStep = stepNumber >= totalSteps;
final colorScheme = Theme.of(context).colorScheme;
// Prefer placing the card below the highlighted element; only flip
// above it if there's not enough room below on this screen.
final cardBelow = rect == null || (rect.bottom + 260) < media.size.height;
final cardTop = cardBelow ? (rect?.bottom ?? media.size.height * 0.38) + 16 : null;
final cardBottom = !cardBelow ? media.size.height - rect.top + 16 : null;
final screenMid = media.size.height / 2;
final topGroup = rects.where((r) => r.center.dy < screenMid);
final bottomGroup = rects.where((r) => r.center.dy >= screenMid);
double? cardTop;
double? cardBottom;
if (topGroup.isNotEmpty && bottomGroup.isNotEmpty) {
// Highlighted elements exist both above and below the screen's
// midpoint (e.g. an AppBar action + a bottom-nav destination) — the
// union of every rect would span almost the full screen height in
// this case, which the plain below/above logic below handles badly.
// Anchor the card just under the topmost group instead, into the
// open band between the two.
cardTop = topGroup.map((r) => r.bottom).reduce((a, b) => a > b ? a : b) + 16;
} else {
final unionRect = rects.isEmpty ? null : rects.reduce((a, b) => a.expandToInclude(b));
// Prefer placing the card below the highlighted element(s); only
// flip above if there's not enough room below on this screen.
final cardBelow = unionRect == null || (unionRect.bottom + 260) < media.size.height;
if (cardBelow) {
cardTop = (unionRect?.bottom ?? media.size.height * 0.38) + 16;
} else {
cardBottom = media.size.height - unionRect.top + 16;
}
}
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// Swallows every tap on the barrier (and the spotlight hole
// itself) so the highlighted element can't be accidentally
// Swallows every tap on the barrier (and the spotlight holes
// themselves) so a highlighted element can't be accidentally
// triggered mid-tour — Next/Skip on the card are the only way
// to advance.
onTap: () {},
child: CustomPaint(
painter: _SpotlightPainter(
targetRect: rect,
targetRects: rects,
color: Colors.black.withValues(alpha: 0.75),
),
),
),
),
if (rect != null)
for (final rect in rects)
Positioned(
left: rect.left,
top: rect.top,
@ -127,25 +157,23 @@ class OnboardingTourOverlay extends StatelessWidget {
}
class _SpotlightPainter extends CustomPainter {
final Rect? targetRect;
final List<Rect> targetRects;
final Color color;
const _SpotlightPainter({required this.targetRect, required this.color});
const _SpotlightPainter({required this.targetRects, required this.color});
@override
void paint(Canvas canvas, Size size) {
final barrierPaint = Paint()..color = color;
final fullPath = Path()..addRect(Offset.zero & size);
final rect = targetRect;
if (rect == null) {
canvas.drawPath(fullPath, barrierPaint);
return;
var path = Path()..addRect(Offset.zero & size);
for (final rect in targetRects) {
final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
path = Path.combine(PathOperation.difference, path, holePath);
}
final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
canvas.drawPath(Path.combine(PathOperation.difference, fullPath, holePath), barrierPaint);
canvas.drawPath(path, barrierPaint);
}
@override
bool shouldRepaint(covariant _SpotlightPainter oldDelegate) =>
oldDelegate.targetRect != targetRect || oldDelegate.color != color;
!listEquals(oldDelegate.targetRects, targetRects) || oldDelegate.color != color;
}