Tour
This commit is contained in:
parent
956231048e
commit
606106e5ec
10 changed files with 632 additions and 1 deletions
151
lib/widgets/onboarding_tour_overlay.dart
Normal file
151
lib/widgets/onboarding_tour_overlay.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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.
|
||||
///
|
||||
/// 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 String title;
|
||||
final String description;
|
||||
final int stepNumber;
|
||||
final int totalSteps;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onSkip;
|
||||
|
||||
const OnboardingTourOverlay({
|
||||
super.key,
|
||||
required this.targetKey,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.stepNumber,
|
||||
required this.totalSteps,
|
||||
required this.onNext,
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rect = _targetRect()?.inflate(6);
|
||||
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;
|
||||
|
||||
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
|
||||
// triggered mid-tour — Next/Skip on the card are the only way
|
||||
// to advance.
|
||||
onTap: () {},
|
||||
child: CustomPaint(
|
||||
painter: _SpotlightPainter(
|
||||
targetRect: rect,
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (rect != null)
|
||||
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 Rect? targetRect;
|
||||
final Color color;
|
||||
|
||||
const _SpotlightPainter({required this.targetRect, 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;
|
||||
}
|
||||
final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
|
||||
canvas.drawPath(Path.combine(PathOperation.difference, fullPath, holePath), barrierPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpotlightPainter oldDelegate) =>
|
||||
oldDelegate.targetRect != targetRect || oldDelegate.color != color;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue