179 lines
6.6 KiB
Dart
179 lines
6.6 KiB
Dart
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 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 List<GlobalKey> targetKeys;
|
|
final String title;
|
|
final String description;
|
|
final int stepNumber;
|
|
final int totalSteps;
|
|
final VoidCallback onNext;
|
|
final VoidCallback onSkip;
|
|
|
|
const OnboardingTourOverlay({
|
|
super.key,
|
|
required this.targetKeys,
|
|
required this.title,
|
|
required this.description,
|
|
required this.stepNumber,
|
|
required this.totalSteps,
|
|
required this.onNext,
|
|
required this.onSkip,
|
|
});
|
|
|
|
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 rects = _targetRects();
|
|
final media = MediaQuery.of(context);
|
|
final isLastStep = stepNumber >= totalSteps;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
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 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(
|
|
targetRects: rects,
|
|
color: Colors.black.withValues(alpha: 0.75),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
for (final rect in rects)
|
|
Positioned(
|
|
left: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
child: IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: colorScheme.primary, width: 2.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 20,
|
|
right: 20,
|
|
top: cardTop,
|
|
bottom: cardBottom,
|
|
child: SafeArea(
|
|
child: Card(
|
|
elevation: 8,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Step $stepNumber of $totalSteps',
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: colorScheme.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(height: 8),
|
|
Text(description, style: Theme.of(context).textTheme.bodyMedium),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
TextButton(onPressed: onSkip, child: const Text('Skip Tour')),
|
|
FilledButton(onPressed: onNext, child: Text(isLastStep ? 'Got It' : 'Next')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SpotlightPainter extends CustomPainter {
|
|
final List<Rect> targetRects;
|
|
final Color color;
|
|
|
|
const _SpotlightPainter({required this.targetRects, required this.color});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final barrierPaint = Paint()..color = color;
|
|
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);
|
|
}
|
|
canvas.drawPath(path, barrierPaint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _SpotlightPainter oldDelegate) =>
|
|
!listEquals(oldDelegate.targetRects, targetRects) || oldDelegate.color != color;
|
|
}
|