guided_tour 1.0.3 copy "guided_tour: ^1.0.3" to clipboard
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);

A tour step waiting on live app state

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.ensureVisible runs 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 GlobalKey crash when a screen is mounted twice.
  • Keyboards. The tooltip stays out of viewInsets and 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
Tour A — Welcome to Pantry Tour B — Place your first order
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.
Tour C — Where's my order? Tour D — Never get the wrong substitute
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.

1
likes
160
points
109
downloads

Documentation

API reference

Publisher

verified publishervelzosoft.com

Weekly Downloads

Declarative guided tours and coachmarks where the tour is data: screens carry a TourKey, tour definitions import no UI. No registration, no dependencies.

Repository (GitHub)
View/report issues

Topics

#onboarding #tutorial #coachmark #walkthrough

License

BSD-3-Clause (license)

Dependencies

flutter

More

Packages that depend on guided_tour