guided_tutor 0.2.0
guided_tutor: ^0.2.0 copied to clipboard
A dependency-free guided-tour overlay for Flutter: a scrim spotlights one widget at a time with a caption explaining it.
import 'package:flutter/material.dart';
import 'package:guided_tutor/guided_tutor.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'guided_tutor example',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
/// Everything the tour on this screen can point at. One flat enum, so a
/// target that gets renamed or removed is a compile error in both the widget
/// that owns it and the step list that points at it.
enum HomeAnchor { header, optionalCard, farListItem, fab }
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
// Created once as a field, not in build(), so the keys keep their identity
// across rebuilds.
final _anchors = TourAnchors<HomeAnchor>(HomeAnchor.values);
bool _showTour = false;
// Toggled from the switch below. With this off, HomeAnchor.optionalCard is
// never mounted — and the tour step that points at it is skipped
// automatically rather than the app needing to special-case it.
bool _showOptionalCard = true;
List<GuidedTourStep> _steps() => [
const GuidedTourStep(
title: 'Welcome',
body:
"This step has no target — it's about the screen as a "
'whole, so the whole screen just dims.',
),
GuidedTourStep(
targetKey: _anchors[HomeAnchor.header],
title: 'The header',
body: 'A plain widget, spotlighted with a rounded-rectangle hole.',
),
GuidedTourStep(
targetKey: _anchors[HomeAnchor.optionalCard],
title: 'An optional card',
body:
"Turn its switch off and this step vanishes on its own — "
"there's nothing here for the tour to skip past manually.",
),
GuidedTourStep(
targetKey: _anchors[HomeAnchor.farListItem],
title: 'Scrolled into view',
body:
'This item starts off-screen. The tour scrolled the list to '
'bring it into view before measuring it — try it on a short '
'window to see the scroll happen.',
alignment: 0.3,
),
GuidedTourStep(
targetKey: _anchors[HomeAnchor.fab],
title: 'A circular spotlight',
body:
'Round targets get a round hole, sized to their longest '
'side, instead of a rectangle with empty corners lit up.',
shape: GuidedTourShape.circle,
),
const GuidedTourStep(
title: "That's it",
body:
'Skip is always on screen, on every step — a tour nobody '
'can leave is a trap, not a tutorial.',
),
];
@override
Widget build(BuildContext context) {
final scaffold = Scaffold(
appBar: AppBar(
title: TourAnchorScope.wrap(
context,
HomeAnchor.header,
const Text('guided_tutor example'),
),
actions: [
// The lite path: no Stack, no showTour state, no labels — just the
// same steps handed to GuidedTour.show(). Compare with the FAB
// below, which drives the exact same tour the long way.
IconButton(
tooltip: 'Quick tour (lite)',
icon: const Icon(Icons.flash_on),
onPressed: () => GuidedTour.show(context: context, steps: _steps()),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Scroll down, then tap "Show tour" to see the guided tours walk '
'through this screen, including scrolling to a target that '
"starts off-screen.",
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Show optional card'),
subtitle: const Text(
'Off = its tour step is skipped, not shown empty',
),
value: _showOptionalCard,
onChanged: (v) => setState(() => _showOptionalCard = v),
),
if (_showOptionalCard)
TourAnchorScope.wrap(
context,
HomeAnchor.optionalCard,
Card(
child: ListTile(
leading: const Icon(Icons.star),
title: const Text('Optional card'),
subtitle: const Text(
'Only in the tree when the switch above is on',
),
),
),
),
const SizedBox(height: 16),
for (var i = 0; i < 12; i++)
ListTile(
key: i == 10
? TourAnchorScope.keyFor(context, HomeAnchor.farListItem)
: null,
leading: CircleAvatar(child: Text('${i + 1}')),
title: Text('List item ${i + 1}'),
),
],
),
// The full-control path: hosted in the Stack below, with its own
// showTour flag and custom labels. Same tour as the AppBar's lite
// button above, wired up the long way — use this when you need to
// override the style, localise the chrome, or control exactly where
// in the tree the overlay lives.
floatingActionButton: TourAnchorScope.wrap(
context,
HomeAnchor.fab,
FloatingActionButton(
tooltip: 'Full-control tour',
onPressed: () => setState(() => _showTour = true),
child: const Icon(Icons.school),
),
),
);
return TourAnchorScope<HomeAnchor>(
anchors: _anchors,
child: Stack(
children: [
scaffold,
if (_showTour)
GuidedTour(
steps: _steps(),
labels: GuidedTourLabels(
next: 'Next',
back: 'Back',
skip: 'Skip',
done: 'Done',
counter: (position, total) => '$position of $total',
),
onClose: ({required completed}) =>
setState(() => _showTour = false),
),
],
),
);
}
}