introduceme 1.0.1
introduceme: ^1.0.1 copied to clipboard
Introduce Me — a flexible, lightweight Flutter showcase and onboarding package with a smart tour engine and lean rendering core.
import 'package:flutter/material.dart';
import 'package:introduceme/introduceme.dart';
import 'branding.dart';
import 'example_app_state.dart';
import 'example_storage.dart';
import 'screens/home_screen.dart';
import 'screens/profile_screen.dart';
import 'screens/settings_screen.dart';
void main() {
runApp(const IntroduceMeExampleApp());
}
/// Root navigator key so [onDismiss] can show a SnackBar without a local context.
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class IntroduceMeExampleApp extends StatelessWidget {
const IntroduceMeExampleApp({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: exampleThemeMode,
builder: (context, themeMode, _) {
return ValueListenableBuilder<ShowcaseLook?>(
valueListenable: exampleLook,
builder: (context, selectedLook, child) {
return MaterialApp(
title: 'Introduce Me Example',
debugShowCheckedModeBanner: false,
themeMode: themeMode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: AnonimeactBranding.primaryCyan,
brightness: Brightness.light,
),
useMaterial3: true,
extensions: [
IntroduceMeTheme(
look: selectedLook,
barrierColor: const Color(0xB3000000),
tooltipBackgroundColor: const Color(0xFF1E1E2E),
enableGlassEffect: false,
blurValue: 0,
),
],
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: AnonimeactBranding.primaryCyan,
brightness: Brightness.dark,
),
useMaterial3: true,
extensions: [
IntroduceMeTheme(
look: selectedLook,
barrierColor: const Color(0xCC000000),
tooltipBackgroundColor: const Color(0xFF2A2A3E),
enableGlassEffect: true,
glassBlurSigma: 16,
),
],
),
// Scope on `home` with a nested Navigator so pushed routes (e.g.
// Settings) stay under the same overlay host subtree.
home: IntroduceMeScopeWidget(
scopeName: 'main',
storage: exampleTourStorage,
routeSettleDelay: const Duration(milliseconds: 500),
onDismiss: (tourId, reason) {
debugPrint('👋 Dismissed $tourId: $reason');
final messenger = ScaffoldMessenger.maybeOf(
navigatorKey.currentContext ?? context,
);
messenger?.showSnackBar(
SnackBar(
content: Text('Tour dismissed: ${reason.name}'),
duration: const Duration(seconds: 2),
),
);
},
scrollLoadingWidget: Builder(
builder: (context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(16),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text('Loading…', style: theme.textTheme.bodyMedium),
],
),
),
);
},
),
floatingActionWidget: Builder(
builder: (context) {
return Material(
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(24),
elevation: 4,
child: TextButton.icon(
onPressed: () => IntroduceMeScope.of(context).skip(),
icon: const Icon(Icons.skip_next),
label: const Text('Skip tour'),
),
);
},
),
analytics: TourAnalytics(
onStepShown: (tourId, stepId, _) {
debugPrint('📍 Step shown: $tourId/$stepId');
if (stepId == 'manual-scroll-item') {
MainShell.scrollToManualScrollItem?.call();
}
},
onStepSkipped: (tourId, stepId) {
debugPrint('⏭️ Step skipped: $tourId/$stepId');
},
onStepCompleted: (tourId, stepId, duration) {
debugPrint(
'✓ Step done: $tourId/$stepId (${duration.inMilliseconds}ms)',
);
},
onTourAbandoned: (tourId, done, total) {
debugPrint('⚠️ Abandoned: $tourId ($done/$total)');
},
onTourFinished: (tourId, duration) {
debugPrint(
'✅ Tour finished: $tourId in ${duration.inMilliseconds}ms',
);
},
),
onFinish: () => debugPrint('🎉 Onboarding complete!'),
// Branches `branching-demo` (see Settings > "Branching demo")
// by `exampleUserIsMember`; every other tour just runs
// normally, in order.
onDetermineNextStep: (currentStep, currentIndex, steps) {
if (currentStep?.config.tourId != 'branching-demo') {
return null;
}
switch (currentStep?.config.id) {
case 'branch-start':
final target = exampleUserIsMember.value
? 'branch-member'
: 'branch-guest';
final idx = steps.indexWhere(
(s) => s.config.id == target,
);
return idx >= 0 ? idx : null;
case 'branch-guest':
// Guests skip straight past the Member step.
final idx = steps.indexWhere(
(s) => s.config.id == 'branch-final',
);
return idx >= 0 ? idx : null;
default:
return null; // e.g. branch-member -> branch-final normally
}
},
child: Builder(
builder: (context) {
final tourObserver = IntroduceMeScope.of(
context,
).navigatorObserver;
return Navigator(
key: navigatorKey,
observers: [tourObserver],
initialRoute: '/',
onGenerateRoute: (settings) {
switch (settings.name) {
case '/settings':
return MaterialPageRoute<void>(
settings: settings,
builder: (_) => const SettingsScreen(),
);
case '/':
default:
return MaterialPageRoute<void>(
settings: settings,
builder: (_) => const MainShell(),
);
}
},
);
},
),
),
);
},
);
},
);
}
}
class MainShell extends StatefulWidget {
const MainShell({super.key});
/// Called from analytics when the manual-scroll step becomes active.
static VoidCallback? scrollToManualScrollItem;
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
static const _profileStepIds = {'avatar', 'custom-tooltip', 'nav-profile'};
static const _shellHomeStepIds = {'nav-home', 'fab-add', 'settings-action'};
static const _homeStepIds = {
'arrow-demo',
'featured-item',
'manual-scroll-item',
'multi-a',
'side-tooltip',
'styled-tooltip',
'styled-actions',
'look-override',
'semantics-off',
'blur-overlay',
'dismiss-demo',
'gestures-demo',
'animations-demo',
};
int _tabIndex = 0;
TourController? _tourController;
final ScrollController _homeScrollController = ScrollController();
@override
void initState() {
super.initState();
MainShell.scrollToManualScrollItem = _scrollToManualItem;
prepareOnboardingReplay = _prepareForOnboardingReplay;
WidgetsBinding.instance.addPostFrameCallback((_) {
final scope = IntroduceMeScope.of(context);
_tourController = scope.controller;
_tourController!.addListener(_syncTabWithTour);
scope.start('onboarding');
});
}
void _scrollToManualItem() {
if (!_homeScrollController.hasClients) return;
_homeScrollController.animateTo(
_homeScrollController.position.maxScrollExtent * 0.55,
duration: const Duration(milliseconds: 450),
curve: Curves.easeInOut,
);
}
void _prepareForOnboardingReplay() {
if (!mounted) return;
if (_tabIndex != 0) {
setState(() => _tabIndex = 0);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_homeScrollController.hasClients) {
_homeScrollController.jumpTo(0);
}
});
}
@override
void dispose() {
MainShell.scrollToManualScrollItem = null;
prepareOnboardingReplay = null;
_tourController?.removeListener(_syncTabWithTour);
_homeScrollController.dispose();
super.dispose();
}
void _syncTabWithTour() {
if (!mounted) return;
final stepId = _tourController?.activeConfig?.id;
if (stepId == null) return;
if (_profileStepIds.contains(stepId) && _tabIndex != 1) {
setState(() => _tabIndex = 1);
} else if ((_homeStepIds.contains(stepId) ||
_shellHomeStepIds.contains(stepId)) &&
_tabIndex != 0) {
setState(() => _tabIndex = 0);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: AnonimeactBranding.logoSmallImage(height: 24),
centerTitle: false,
actions: [
Showcase.auto(
id: 'settings-action',
tourId: 'onboarding',
order: 4,
title: 'Settings',
description:
'Opens Settings — next step is on another route (cross-screen).',
showArrow: true,
tooltipPlacement: TooltipPlacement.below,
onTargetClick: () {
Navigator.pushNamed(context, '/settings');
},
child: IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.pushNamed(context, '/settings'),
),
),
],
),
body: IndexedStack(
index: _tabIndex,
children: [
HomeScreen(scrollController: _homeScrollController),
const ProfileScreen(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (i) => setState(() => _tabIndex = i),
destinations: [
NavigationDestination(
icon: Showcase.auto(
id: 'nav-home',
tourId: 'onboarding',
order: 1,
title: 'Home',
description: 'Auto-discovery on bottom navigation.',
showArrow: true,
child: const Icon(Icons.home_outlined),
),
label: 'Home',
),
NavigationDestination(
icon: Showcase.auto(
id: 'nav-profile',
tourId: 'onboarding',
order: 3,
title: 'Profile',
description: 'Tab sync switches IndexedStack during the tour.',
showArrow: true,
child: const Icon(Icons.person_outline),
),
label: 'Profile',
),
],
),
floatingActionButton: Showcase.auto(
id: 'fab-add',
tourId: 'onboarding',
order: 2,
title: 'Quick Add',
description:
'Glass tooltip (enableGlassEffect) + TooltipPlacement.above.',
targetShapeBorder: const CircleBorder(),
enableGlassEffect: true,
showArrow: true,
tooltipPlacement: TooltipPlacement.above,
targetTooltipGap: 8,
overlayPadding: const EdgeInsets.all(2),
child: FloatingActionButton(
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('FAB tapped!')));
},
child: const Icon(Icons.add),
),
),
);
}
}