guided_tour 1.0.3
guided_tour: ^1.0.3 copied to clipboard
Declarative guided tours and coachmarks where the tour is data: screens carry a TourKey, tour definitions import no UI. No registration, no dependencies.
guided_tour #
Declarative guided tours, coachmarks and product onboarding for Flutter.
A tour is data, not markup. A screen joins a tour by carrying a
const TourKey('some.id') — that is the entire contract. Tour definitions live
in their own files, reference targets by string id, and never import a widget.
Zero third-party dependencies. No registration. No GlobalKeys. No routing
dependency.
// The only change any screen ever makes:
FloatingActionButton(key: TourKey('home.basket'), onPressed: ...)
// The tour, somewhere else entirely:
const tour = Tour(id: 'onboarding', steps: [
NavigateStep('/'),
HighlightStep('home.search', title: 'Know what you want?', body: '…'),
HighlightStep('home.basket', title: 'Your basket follows you', body: '…'),
]);
controller.start(tour);

Above: the step doesn't advance on a tap — it advances when the basket stops being empty. The tour waits on the app.
Install #
dependencies:
guided_tour: ^1.0.3
Requires Flutter 3.38.1 or newer.
Set up #
1. Adapt your router. The package never depends on one. This is the whole GoRouter adapter:
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:guided_tour/guided_tour.dart';
class GoRouterTourNavigator implements TourNavigator {
const GoRouterTourNavigator(this.router);
final GoRouter router;
@override
String get location => router.state.uri.toString();
@override
Listenable get onLocationChanged => router.routerDelegate;
@override
void go(String location) => router.go(location);
}
For Navigator 1.0, back it with a NavigatorObserver and a ValueNotifier.
2. Create a controller and wrap the app once.
final controller = TourController(
navigator: GoRouterTourNavigator(router),
onEvent: (event) => analytics.report(event),
);
MaterialApp.router(
routerConfig: router,
builder: (context, child) => TourHost(
controller: controller,
config: myTooltipConfig,
child: child!,
),
);
TourHost sits above the Navigator, so the spotlight is structurally above
every route, dialog, sheet and snackbar in the app. When no step is showing it
renders nothing and is hit-test transparent.
3. Mark your targets. key: TourKey('…'). That's it — no wrapper widget, no
initState registration, no controller threaded down the tree.
How steps advance #
This is the part that matters. Every HighlightStep declares how it ends:
advanceOn |
Completes when | The user can touch the target |
|---|---|---|
AdvanceOn.next() |
they tap Next | no |
AdvanceOn.tap() |
they tap the spotlit widget | yes |
AdvanceOn.route('/basket') |
the location matches (:param, /**) |
yes |
AdvanceOn.signal('saved') |
the app calls controller.milestone('saved') |
yes |
AdvanceOn.predicate(test) |
test() returns true (polled at 100 ms) |
yes |
AdvanceOn.branch({…}) |
the first matching milestone wins → jump | yes |
Interactive steps are real: the tap goes to the actual widget, which runs
its actual onPressed. Nothing is simulated.
// "Add anything you fancy" — the tour cannot know what the user will pick or
// how long they'll take, so it watches the app instead of guessing.
HighlightStep(
'home.aisles',
title: 'Add anything you fancy',
body: 'Have a proper look around — we\'ll wait until something\'s in.',
advanceOn: AdvanceOn.predicate(() => store.basket.isNotEmpty),
)
Every variant takes a goto (a step name, or TourFlow.next / TourFlow.end),
so any step can branch.
Async outcomes #
controller.milestone('payment.ok') goes in ordinary feature code, wherever
the API call resolves:
Future<Order> checkout(SavedCard card) async {
try {
final order = await api.placeOrder(card: card);
controller.milestone('payment.ok'); // no-op when no tour is running
return order;
} on PaymentDeclined {
controller.milestone('payment.declined');
rethrow;
}
}
No if (tourRunning) guard, no injected callback. Milestones are buffered
with a one-step lookback window, so a response that arrives before the next
step has finished mounting still counts.
Then fork on it:
HighlightStep('basket.checkout', name: 'pay',
title: 'Paying', body: '…',
advanceOn: AdvanceOn.branch({
'payment.ok': 'tracking',
'payment.declined': 'retry',
}),
timeout: Duration(minutes: 3),
)
What it handles that hand-rolled overlays don't #
- Targets that move. Rects are re-measured every frame — scrolling lists, opening keyboards, reflowing content.
- Targets that get covered. A dialog or sheet on top of the target hides the overlay entirely and the step stays armed underneath. The user works the sheet; the tour picks up where it was.
- Targets that aren't there.
skipIfAbsent(default) quietly drops a step whose target never mounts — role-gated buttons, empty lists, promos that aren't running. - Targets below the fold.
Scrollable.ensureVisibleruns before the spotlight lands. - Routes still animating. Measurement waits for the transition to settle, so the spotlight never lands mid-slide.
- Duplicate ids. Legal. The resolver picks the on-stage, topmost-route
match — no
GlobalKeycrash when a screen is mounted twice. - Keyboards. The tooltip stays out of
viewInsetsand slides with it.
Events — the app owns every message #
The package ships no product strings (beyond four overridable button
labels) and displays no errors of its own. Everything surfaces through
onEvent:
| Event | |
|---|---|
TourStarted(tourId) |
|
TourStepShown(tourId, stepIndex, step) |
|
TourCompleted(tourId) |
|
TourAborted(tourId, reason) |
see below |
TourAbortReason |
Meaning |
|---|---|
noTarget |
Tour.resolveRoute returned null — nothing to tour |
targetMissing |
a target never mounted, and skipIfAbsent: false |
timeout |
a bounded wait expired |
userLeft |
the target went away mid-step and never came back |
dismissed |
Skip, scrim dismiss, or a new tour started over this one |
invalidDefinition |
unknown goto, duplicate step names (asserts in debug) |
loopGuard |
the visit-count backstop tripped |
void onTourEvent(TourEvent event) {
if (event case TourAborted(reason: TourAbortReason.noTarget)) {
showSnackBar('Nothing on its way right now.'); // your words, your UI
}
}
Styling #
TooltipConfig covers the scrim, the hole shape (RectHole, CircleHole,
PathHole) and the default card. When you want your own card, hand it a
builder:
TooltipConfig(
scrimColor: const Color(0xD912261C),
holeShape: const RectHole(radius: 14),
tooltipBuilder: (context, view) => MyCoachmarkCard(
title: view.step.title,
body: view.step.body,
progress: view.stepCount, // null when the tour branches
onNext: view.showNext ? view.onNext : null,
onSkip: view.onSkip,
),
)
The builder styles the card; the package positions it. Flipping above the target when there's no room below, clamping to the screen edges and staying clear of the keyboard stays the package's job however the card looks.
TourStepView.stepCount is nullable on purpose: "step 3 of 7" is a lie for a
tour that branches, so the count is published only when the tour is provably
linear, and null otherwise.
"Have they seen it?" #
Deliberately not in the package — that's a product decision with product storage. The recipe is three lines:
if (!prefs.getBool('seen.onboarding')) controller.start(onboardingTour);
// in onEvent:
if (event is TourCompleted) prefs.setBool('seen.${event.tourId}', true);
Tour likewise carries no name, icon or category. Discovery metadata changes
far more often than steps do and needs translating; keep it in your own
catalogue, next to the screen that lists your tours.
Why not showcaseview / tutorial_coach_mark? #
Both are good at what they do. The differences that decide it:
| guided_tour | typical alternative | |
|---|---|---|
| Marking a target | key: TourKey('id') |
wrap the widget in a Showcase, or hold a GlobalKey |
| Where tours are defined | a data file that imports no UI | inline in the screen, next to the widget |
| Multi-screen tours | NavigateStep + AdvanceOn.route through your router |
usually out of scope |
| Waiting on app state | AdvanceOn.predicate / signal / branch |
tap-to-continue |
| Async success/failure forks | AdvanceOn.branch |
— |
| Target covered by a dialog | overlay suspends, step survives | overlay sits on top of the dialog |
| Same id mounted twice | fine — topmost on-stage wins | GlobalKey collision |
| Dependencies | none | varies |
The short version: if your tour is "point at four things on one screen", anything works. If it is "walk someone through placing an order, across four screens, waiting on their basket and then on the payment API", that is what this is for.
Example #
example/ is Pantry, a working grocery-delivery app with four
real tours: a passive welcome walkthrough, an interactive order flow that waits
on live basket state and forks on a declined card, a contextual "where's my
order?" tour that resolves its own route, and a deep-link-plus-scroll settings
tour. It ships the GoRouter adapter above, verbatim.
cd example && flutter run
![]() |
![]() |
| Welcome to Pantry Passive walkthrough — Back / Next / Skip, no interaction required. |
Place your first order Tap-through on the real search field, AdvanceOn.predicate on live basket state, and a branch on a genuinely declined card. |
![]() |
![]() |
Where's my order?Tour.resolveRoute picks the starting screen, or aborts with noTarget when nothing is on its way. |
Never get the wrong substitute Deep link, Scrollable.ensureVisible three sections down, and skipIfAbsent for the Pantry Plus section non-subscribers don't have. |
License #
BSD-3-Clause. Copyright (c) 2026, Velzosoft.



