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 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 _targetRects() { final rects = []; 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 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; }