🎑 Flutter Feature Tour

Spotlight feature tours and onboarding for Flutter apps. Highlight any widget, explain it in a themed info card, and remember how far each user got.

Feature tour screenshot 1 Feature tour screenshot 2 Feature tour screenshot 3

✨ Features

  • πŸ”¦ Spotlight overlay – the screen is dimmed and the highlighted widget is cut out (circle, rectangle, rounded rectangle or any custom path), with configurable padding.
  • 🧭 Smart card placement – the info card goes below or above the target and always stays on screen, including notches and the keyboard.
  • 🎯 Live tracking – the spotlight follows targets that scroll, animate or resize; targets in scroll views are scrolled into view automatically.
  • πŸ‘† Interactive steps – let users tap the real highlighted widget, advance the tour on that tap, or embed your own widget in the card.
  • πŸ”€ Conditional steps – show a step only when showIf returns true; steps whose widget is not on screen are skipped.
  • πŸ—‚οΈ Multiple tours – independent tours (e.g. one per screen), each with its own saved progress.
  • πŸ’Ύ Persistence – progress is stored with shared_preferences by default, in memory, or in any storage you plug in.
  • 🌐 Localization – every string, including screen reader text, is configurable and can follow the app's locale; right-to-left layouts are supported.
  • 🧩 Works with any state management – the service is a ChangeNotifier, can be injected, and page-scoped providers can be re-provided to the tour.
  • 🎨 Theming – FeatureTourTheme or FeatureTourTheme.fromTheme(Theme.of(context)) for light and dark mode.
  • πŸ“Š Analytics hooks – one callback for every tour event, plus onComplete, onSkip and onStepChanged.
  • β™Ώ Accessibility – screen reader announcements, keyboard navigation (←/β†’, Enter, Esc), back-button support and reduced-motion support.

πŸ“₯ Installation

dependencies:
  flutter_feature_tour: ^0.2.0

Requires Flutter 3.27 or newer. Works on Android, iOS, web, macOS, Windows and Linux.

πŸš€ Usage

  1. Give each widget you want to highlight a GlobalKey:
final _searchKey = GlobalKey();
final _profileKey = GlobalKey();

IconButton(key: _searchKey, icon: const Icon(Icons.search), onPressed: () {});
  1. Describe the steps and start the tour:
import 'package:flutter_feature_tour/flutter_feature_tour.dart';

class _HomePageState extends State<HomePage> {
  final _onboarding = OnboardingService();

  @override
  void initState() {
    super.initState();
    _onboarding.setSteps([
      [
        FeatureHighlight(
          targetKey: _searchKey,
          title: 'Search',
          description: 'Quickly find what you need.',
          icon: Icons.search,
        ),
      ],
      [
        FeatureHighlight(
          targetKey: _profileKey,
          title: 'Profile',
          description: 'View and edit your profile.',
          icon: Icons.person,
          shape: HighlightShape.roundedRectangle,
        ),
      ],
    ]);

    // Safe to call from initState: the tour waits for the first frame.
    _onboarding.startOnboarding(context);
  }
}

The tour is shown once. After the user finishes or skips it, startOnboarding does nothing until you call restartOnboarding(context) or resetOnboarding().

If the screen is still loading when you call startOnboarding, the tour waits up to targetWaitTimeout (5 seconds by default) for the first widget to appear, checking showIf again meanwhile. If no widget appears, it does not start and is not marked as completed; if showIf still turns off every remaining step, it is marked as completed. It returns true when the tour is showing.

Keep your GlobalKeys stable. Create them once, in a State, a controller or a provider. A key created inside build (for example in a StatelessWidget, ConsumerWidget or BlocBuilder) changes on every rebuild, so the tour can't find the widget. In debug mode the tour prints a hint when it skips a step for this reason.

Each step is a list: every highlight in it gets a spotlight, and the info card describes the first one.

πŸ”¦ Highlights

FeatureHighlight(
  targetKey: _addKey,
  title: 'Add an item',
  description: 'Tap the button to create your first item.',
  icon: Icons.add,                          // optional
  shape: HighlightShape.roundedRectangle,   // circle, rectangle, roundedRectangle, customPath
  borderRadius: 16,                         // for roundedRectangle
  padding: const EdgeInsets.all(8),         // space around the widget
  allowTargetInteraction: true,             // the real button stays tappable
  advanceOnTargetTap: true,                 // tapping it moves the tour on
  showIf: () => !user.hasCreatedItem,       // conditional step
  content: const MyRichExplanation(),       // replaces the description text
  interactiveWidgetBuilder: (context, onComplete) => TextButton(
    onPressed: onComplete,
    child: const Text('Try it'),
  ),
)

For HighlightShape.customPath, pass a customPath in the target's local coordinates (Offset.zero is its top-left corner).

πŸŽ›οΈ Configuration

OnboardingService()
  ..showPreviousButton = true                               // "Back" button
  ..showSkipButton = true
  ..showCompletionCard = true                               // "You're all set" card
  ..barrierTapBehavior = TourBarrierTapBehavior.none        // next (default), none, skip
  ..autoScroll = true                                       // scroll targets into view
  ..skipMissingTargets = true                               // skip steps whose widget isn't built
  ..persistProgress = true                                  // save progress across launches
  ..onComplete = () => debugPrint('finished')
  ..onSkip = () => debugPrint('skipped')
  ..onStepChanged = (index) => debugPrint('step $index');

Control a running tour from code with next(), previous(), skip() and dismiss() (hides it without marking it completed), and check isActive.

The tour follows the page it was started from. While a dialog, bottom sheet or menu is open above that page, the tour is hidden and comes back when it closes. When another page covers it, or the page is popped or removed, the tour is dismissed (not completed) and resumes on the next startOnboarding. Set dismissOnRouteChange = false to keep the tour above every route instead.

Services made with OnboardingService.create should be disposed when you no longer need them; disposing one also removes its tour from the screen.

Multiple tours

final settingsTour = OnboardingService.forTour('settings');

Each tour has its own steps, settings and saved progress.

🧩 State management

The package works with any state management solution: it only needs a GlobalKey per target and a BuildContext inside your app.

  • Listening to the tour. OnboardingService is a ChangeNotifier that notifies on start, every step change and end (isActive, currentStepIndex, isShowingCompletion). Use it with ListenableBuilder, Provider's ChangeNotifierProvider.value, Riverpod's ChangeNotifierProvider, or listen to it from a BLoC or GetX controller. Don't dispose the shared instances returned by OnboardingService() and forTour().

  • Dependency injection. OnboardingService.create(tourId: ..., repository: ...) returns an instance that isn't shared, for get_it, Riverpod or tests.

  • Page-scoped providers. The tour is drawn above all routes, so providers that only wrap one page aren't visible to interactiveWidgetBuilder or content. Re-provide them with overlayBuilder:

    service.overlayBuilder = (context, child) => BlocProvider.value(
          value: context.read<CartBloc>(),
          child: child,
        );
    

    Providers above MaterialApp (or a root ProviderScope) work as-is, and the page's themes are carried over automatically.

  • No widget context? If you can only reach a context above the Navigator (for example in MaterialApp.builder, or GetX's Get.key), set service.navigatorKey to your app's navigator key.

πŸ’Ύ Custom storage

Progress is saved through a TourProgressRepository. The default stores it in shared_preferences under flutter_feature_tour.<tourId>.*. Calling SharedPreferences.clear() (common on logout) also resets the tours.

// Keep progress in memory only (the tour shows on every launch):
service.persistProgress = false;

// Or use any key/value store by implementing TourStorageDataProvider:
service.progressRepository = TourProgressRepositoryImpl(HiveDataProvider());

// Or save per user on your backend by implementing TourProgressRepository.

🎨 Customization

_onboarding.setTheme(
  FeatureTourTheme(
    overlayColor: Colors.black87,
    highlightColor: Colors.amber,
    cardColor: Colors.grey[900]!,
    textColor: Colors.white,
    primaryColor: Colors.amber,
    onPrimaryColor: Colors.black,
    titleStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
    bodyStyle: const TextStyle(fontSize: 16),
    cornerRadius: 12,
    highlightBorderWidth: 3,
    highlightPadding: const EdgeInsets.all(6),
    cardMaxWidth: 340,
    animationDuration: const Duration(milliseconds: 250),
  ),
);

Or follow your app's theme, in light and dark mode:

_onboarding.setTheme(FeatureTourTheme.fromTheme(Theme.of(context)));

🌐 Localization

The package works with any localization setup (flutter_localizations, intl, easy_localization, …) and doesn't require MaterialLocalizations. Use builders so the text follows the app's locale, even when it changes while the tour is open:

service.labelsBuilder = (context) {
  final l10n = AppLocalizations.of(context)!;
  return FeatureTourLabels(
    next: l10n.tourNext,
    previous: l10n.tourBack,
    skip: l10n.tourSkip,
    finish: l10n.tourFinish,
    completionTitle: l10n.tourDoneTitle,
    completionDescription: l10n.tourDoneBody,
    stepCounter: (current, total) => l10n.tourStep(current, total),
    overlaySemanticLabel: l10n.tourSemanticLabel,
    overlaySemanticHint: l10n.tourSemanticHint,
  );
};

FeatureHighlight(
  targetKey: _searchKey,
  titleBuilder: (context) => AppLocalizations.of(context)!.searchTitle,
  descriptionBuilder: (context) => AppLocalizations.of(context)!.searchBody,
);

Fixed strings work too: setLabels(const FeatureTourLabels(next: 'Weiter')) and FeatureHighlight(title: ..., description: ...).

Screen readers announce the same text as the visible buttons. Set nextSemanticLabel, previousSemanticLabel, skipSemanticLabel or finishSemanticLabel for different wording.

Right-to-left languages are supported: the layout mirrors, and the arrow keys follow the reading direction (← is "next" in RTL).

πŸ“Š Analytics

_onboarding.setAnalyticsCallback((event, properties) {
  analytics.logEvent(name: event, parameters: properties);
});

Every event includes tour_id and total_steps. Events:

Event When
onboarding_started The tour is shown by startOnboarding
onboarding_restarted restartOnboarding was called
step_viewed A step is shown (step_index)
step_completed The user moves past a step (step_index)
step_previous The user goes back (step_index)
all_set_card_shown The completion card is shown
onboarding_completed The user finishes the tour
onboarding_skipped The user skips the tour (step_index)
onboarding_dismissed dismiss() was called (step_index)

β™Ώ Accessibility

  • The overlay is announced to screen readers and hides the app underneath from them.
  • Keyboard: β†’ / Page Down next, ← / Page Up back (mirrored in right-to-left layouts), Enter / Space activate, Esc skip; focus stays inside the tour.
  • The system back button skips the tour instead of leaving the page.
  • Animations are turned off when the platform asks for reduced motion.

πŸ§ͺ Testing

startOnboarding waits for a frame, so in widget tests call it without await and pump. To avoid touching SharedPreferences, inject an in-memory repository with OnboardingService.create(repository: TourProgressRepositoryImpl(InMemoryDataProvider())).

testWidgets('shows the tour', (tester) async {
  SharedPreferences.setMockInitialValues({});
  OnboardingService.resetInstances();

  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  expect(find.byType(FeatureHighlightOverlay), findsOneWidget);
  await tester.tap(find.text('Next'));
  await tester.pumpAndSettle();
});

πŸ”„ Migrating from 0.1.x

  • Skipping now ends the tour right away instead of showing the "all set" card, and reports onboarding_skipped.
  • Progress is stored under namespaced keys (flutter_feature_tour.<tourId>.*). Progress saved by 0.1.x is still read for the default tour, so users who finished the tour don't see it again.
  • FeatureHighlight.icon is optional, and HighlightShape has a new roundedRectangle value; add it to exhaustive switch statements.
  • Circle highlights now cover the whole widget (diameter = longest side) instead of only fitting inside it.
  • startOnboarding and restartOnboarding now return Future<bool> (whether the tour is showing).
  • initialize() is no longer needed; it is kept as a no-op.
  • The package no longer depends on iconsax, web, flutter_web_plugins or plugin_platform_interface.

🀝 Contributing

Contributions are welcome! Please open an issue or a pull request.

πŸ“„ License

This project is licensed under the MIT License – see the LICENSE file for details.

Libraries

flutter_feature_tour
Spotlight feature tours and onboarding for Flutter apps.