Skip to content

Instantly share code, notes, and snippets.

@jtmuller5
Last active August 4, 2025 19:07
Show Gist options
  • Save jtmuller5/1d6474de2d208b632fea14900c7e6e94 to your computer and use it in GitHub Desktop.
Save jtmuller5/1d6474de2d208b632fea14900c7e6e94 to your computer and use it in GitHub Desktop.
Flutter class for displaying sequential onboarding tips
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import 'package:app/shared_preferences.dart';
import 'package:logging/logging.dart';
/// Represents a single onboarding tip
class OnboardingTip {
final String id;
final String title;
final String message;
final String buttonText;
final GlobalKey targetKey;
final VoidCallback? onDismiss;
const OnboardingTip({
required this.id,
required this.title,
required this.message,
required this.targetKey,
this.buttonText = 'Got it!',
this.onDismiss,
});
}
@singleton
class OnboardingService {
static const String _tipShownPrefix = 'onboarding_tip_shown_';
/// Check if a specific tip has been shown before
bool hasTipBeenShown(String tipId) {
log(
'Checking if tip $tipId has been shown',
level: Level.SEVERE.value,
);
return sharedPrefs.getBool('$_tipShownPrefix$tipId') ?? false;
}
/// Mark a specific tip as shown
Future<void> markTipAsShown(String tipId) async {
await sharedPrefs.setBool('$_tipShownPrefix$tipId', true);
}
/// Show a single tip if it hasn't been shown before
Future<void> showTipIfNeeded(BuildContext context, OnboardingTip tip) async {
if (!hasTipBeenShown(tip.id)) {
// Wait a bit for the UI to settle
await Future.delayed(const Duration(milliseconds: 500));
if (context.mounted && tip.targetKey.currentContext != null) {
_showTip(context, tip);
await markTipAsShown(tip.id);
}
}
}
/// Show a sequence of tips one after another with smooth spotlight transitions
Future<void> showTipSequence(
BuildContext context, List<OnboardingTip> tips) async {
// Filter tips that haven't been shown and have valid contexts
final validTips = <OnboardingTip>[];
for (final tip in tips) {
if (!hasTipBeenShown(tip.id)) {
// Wait a bit for the UI to settle
await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted && tip.targetKey.currentContext != null) {
validTips.add(tip);
} else {
log(
'Tip ${tip.id} target key context is null, skipping',
level: Level.SEVERE.value,
);
}
} else {
log(
'Tip ${tip.id} has already been shown, skipping',
level: Level.SEVERE.value,
);
}
}
if (validTips.isEmpty) return;
// Show the sequence with animated transitions
await _showAnimatedTipSequence(context, validTips);
}
/// Show an animated sequence of tips with smooth spotlight transitions
Future<void> _showAnimatedTipSequence(
BuildContext context, List<OnboardingTip> validTips) async {
if (validTips.isEmpty) return;
final completer = Completer<void>();
showDialog(
context: context,
barrierColor: Colors.transparent,
useSafeArea: false,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AnimatedOnboardingSequence(
tips: validTips,
onComplete: () async {
Navigator.of(dialogContext).pop();
completer.complete();
},
onTipShown: (tipId) async {
await markTipAsShown(tipId);
},
);
},
);
return completer.future;
}
/// Show a single tip overlay
void _showTip(BuildContext context, OnboardingTip tip) {
showDialog(
context: context,
barrierColor: Colors.transparent,
barrierDismissible: false, // Prevent accidental dismissal
anchorPoint: Offset(0, 0),
useSafeArea: false,
builder: (BuildContext context) {
return OnboardingTipOverlay(
tip: tip,
onDismiss: () {
Navigator.of(context).pop();
tip.onDismiss?.call();
},
);
},
);
}
/// Force show a tip (for testing purposes)
void forceShowTip(BuildContext context, OnboardingTip tip) {
_showTip(context, tip);
}
/// Reset a specific tip (for testing purposes)
Future<void> resetTip(String tipId) async {
await sharedPrefs.remove('$_tipShownPrefix$tipId');
}
/// Reset all tips (for testing purposes)
Future<void> resetAllTips() async {
final keys = sharedPrefs.getKeys();
for (final key in keys) {
if (key.startsWith(_tipShownPrefix)) {
await sharedPrefs.remove(key);
}
}
}
}
class AnimatedOnboardingSequence extends StatefulWidget {
final List<OnboardingTip> tips;
final VoidCallback onComplete;
final Function(String) onTipShown;
const AnimatedOnboardingSequence({
super.key,
required this.tips,
required this.onComplete,
required this.onTipShown,
});
@override
State<AnimatedOnboardingSequence> createState() =>
_AnimatedOnboardingSequenceState();
}
class _AnimatedOnboardingSequenceState extends State<AnimatedOnboardingSequence>
with TickerProviderStateMixin {
late AnimationController _spotlightController;
late AnimationController _tipController;
Animation<Rect?>? _spotlightAnimation;
late Animation<double> _tipOpacity;
int _currentTipIndex = 0;
Rect? _currentSpotlightRect;
@override
void initState() {
super.initState();
_spotlightController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_tipController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_tipOpacity = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _tipController,
curve: Curves.easeInOut,
));
_initializeFirstTip();
}
@override
void dispose() {
_spotlightController.dispose();
_tipController.dispose();
super.dispose();
}
void _initializeFirstTip() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.tips.isNotEmpty) {
final firstTip = widget.tips[0];
final rect = _getRectFromTip(firstTip);
if (rect != null) {
setState(() {
_currentSpotlightRect = rect;
});
_tipController.forward();
}
}
});
}
Rect? _getRectFromTip(OnboardingTip tip) {
final context = tip.targetKey.currentContext;
if (context == null) return null;
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final position = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
return Rect.fromLTWH(
position.dx,
position.dy,
size.width,
size.height,
);
}
Future<void> _nextTip() async {
if (_currentTipIndex >= widget.tips.length - 1) {
// Last tip, complete the sequence
await widget.onTipShown(widget.tips[_currentTipIndex].id);
widget.onComplete();
return;
}
// Mark current tip as shown
await widget.onTipShown(widget.tips[_currentTipIndex].id);
// Prepare for next tip
final nextIndex = _currentTipIndex + 1;
final nextTip = widget.tips[nextIndex];
final nextRect = _getRectFromTip(nextTip);
if (nextRect == null) {
// Skip to next tip if context is null
setState(() {
_currentTipIndex = nextIndex;
});
_nextTip();
return;
}
// Fade out current tip
await _tipController.reverse();
// Animate spotlight to next position
if (_currentSpotlightRect != null) {
_spotlightAnimation = RectTween(
begin: _currentSpotlightRect,
end: nextRect,
).animate(CurvedAnimation(
parent: _spotlightController,
curve: Curves.easeInOut,
));
_spotlightController.reset();
await _spotlightController.forward();
}
// Update state and fade in new tip
setState(() {
_currentTipIndex = nextIndex;
_currentSpotlightRect = nextRect;
});
await _tipController.forward();
}
@override
Widget build(BuildContext context) {
if (widget.tips.isEmpty || _currentTipIndex >= widget.tips.length) {
return const SizedBox.shrink();
}
final currentTip = widget.tips[_currentTipIndex];
final isLastTip = _currentTipIndex == widget.tips.length - 1;
return Material(
color: Colors.transparent,
child: Stack(
children: [
// Animated spotlight
if (_currentSpotlightRect != null)
AnimatedBuilder(
animation: _spotlightController,
builder: (context, child) {
final targetRect =
_spotlightAnimation?.value ?? _currentSpotlightRect!;
return Positioned.fill(
child: CustomPaint(
painter: SpotlightPainter(
targetRect: targetRect,
overlayColor: Colors.black.withOpacity(0.4),
),
),
);
},
),
// Animated tip overlay
if (_currentSpotlightRect != null)
AnimatedBuilder(
animation: _tipOpacity,
builder: (context, child) {
return Opacity(
opacity: _tipOpacity.value,
child: OnboardingTipOverlay(
tip: OnboardingTip(
id: currentTip.id,
title: currentTip.title,
message: currentTip.message,
targetKey: currentTip.targetKey,
buttonText: isLastTip ? currentTip.buttonText : 'Next',
onDismiss: currentTip.onDismiss,
),
onDismiss: _nextTip,
customTargetRect:
_spotlightAnimation?.value ?? _currentSpotlightRect!,
),
);
},
),
],
),
);
}
}
class OnboardingTipOverlay extends StatelessWidget {
final OnboardingTip tip;
final VoidCallback? onDismiss;
final Rect? customTargetRect;
const OnboardingTipOverlay({
super.key,
required this.tip,
this.onDismiss,
this.customTargetRect,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
// Highlighted widget and tip box with spotlight effect
if (customTargetRect != null || tip.targetKey.currentContext != null)
_buildSpotlightEffect(context),
],
),
);
}
Widget _buildSpotlightEffect(BuildContext context) {
// Use custom target rect if provided, otherwise calculate from tip's target key
Offset targetPosition;
Size targetSize;
if (customTargetRect != null) {
targetPosition = customTargetRect!.topLeft;
targetSize = customTargetRect!.size;
} else if (tip.targetKey.currentContext != null) {
final RenderBox targetBox =
tip.targetKey.currentContext!.findRenderObject() as RenderBox;
targetPosition = targetBox.localToGlobal(Offset.zero);
targetSize = targetBox.size;
} else {
// Fallback if no target is available
return const SizedBox.shrink();
}
final screenSize = MediaQuery.of(context).size;
return Stack(
children: [
// Create spotlight cutout for target widget
_buildSpotlightCutout(context, targetPosition, targetSize),
// Tip box with arrow
_buildTipWithArrow(context, targetPosition, targetSize, screenSize),
],
);
}
Widget _buildSpotlightCutout(
BuildContext context, Offset targetPosition, Size targetSize) {
// Apply the same visual adjustments that were applied to tip positioning
// to ensure the spotlight aligns perfectly with the tip box positioning
final adjustedTargetPosition = Offset(
targetPosition.dx,
targetPosition.dy,
);
return Positioned.fill(
child: CustomPaint(
painter: SpotlightPainter(
targetRect: Rect.fromLTWH(
adjustedTargetPosition.dx,
adjustedTargetPosition.dy,
targetSize.width,
targetSize.height,
),
overlayColor: Colors.black.withOpacity(0.7),
),
),
);
}
Widget _buildTipWithArrow(BuildContext context, Offset targetPosition,
Size targetSize, Size screenSize) {
// Calculate optimal tip size based on content
final textStyle = Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w500);
// Calculate size for title + message + button
final titlePainter = tip.title.isNotEmpty
? TextPainter(
text: TextSpan(
text: tip.title,
style: textStyle?.copyWith(fontWeight: FontWeight.w600),
),
textDirection: TextDirection.ltr,
maxLines: 1,
)
: null;
titlePainter?.layout(maxWidth: 240);
final messagePainter = TextPainter(
text: TextSpan(text: tip.message, style: textStyle),
textDirection: TextDirection.ltr,
maxLines: 3,
);
messagePainter.layout(maxWidth: 240);
const double padding = 32; // 16px on each side
const double buttonHeight = 36;
const double spacing = 8; // Between elements
final double titleHeight = titlePainter?.height ?? 0;
final double titleSpacing = tip.title.isNotEmpty ? spacing : 0;
final double maxTextWidth = [
titlePainter?.width ?? 0,
messagePainter.width,
].reduce((a, b) => a > b ? a : b);
final double tipWidth = (maxTextWidth + padding).clamp(200.0, 300.0);
final double tipHeight = titleHeight +
titleSpacing +
messagePainter.height +
spacing +
buttonHeight +
padding;
const double arrowSize = 12;
const double margin = 16;
// Calculate tip position - try below first, then above if no space
final double spaceBelow =
screenSize.height - (targetPosition.dy + targetSize.height);
final double spaceAbove = targetPosition.dy;
bool showBelow = spaceBelow >= tipHeight + arrowSize + margin;
if (!showBelow && spaceAbove < tipHeight + arrowSize + margin) {
// If neither position has enough space, choose the one with more space
showBelow = spaceBelow > spaceAbove;
}
// Calculate horizontal position (center the tip with the target widget)
double tipX = targetPosition.dx + (targetSize.width / 2) - (tipWidth / 2);
// Ensure tip stays within screen bounds
if (tipX < margin) {
tipX = margin;
} else if (tipX + tipWidth > screenSize.width - margin) {
tipX = screenSize.width - tipWidth - margin;
}
// Calculate vertical position
double tipY;
if (showBelow) {
// Position tip box accounting for internal arrow margin and padding
// Add extra space between the target widget and tip box for better visual separation
tipY = targetPosition.dy + targetSize.height + 16; // Added 16px spacing
} else {
// Position tip box accounting for container padding and visual spacing
// Add extra space above the target widget for better visual separation
tipY = targetPosition.dy - tipHeight - 16; // Added 16px spacing
}
// Ensure tip doesn't go off screen vertically
if (showBelow) {
// When showing below, ensure the bottom of the tip box doesn't exceed screen bounds
if (tipY + tipHeight + arrowSize > screenSize.height - margin) {
tipY = screenSize.height - tipHeight - arrowSize - margin;
}
} else {
// When showing above, ensure the top doesn't go above screen bounds
if (tipY < margin) {
tipY = margin;
}
}
// Calculate arrow position relative to tip box
final double targetCenterX = targetPosition.dx + (targetSize.width / 2);
double arrowX = targetCenterX - tipX;
// Ensure arrow stays within tip box bounds (with some padding)
const double arrowPadding = 20;
arrowX = arrowX.clamp(arrowPadding, tipWidth - arrowPadding);
return Positioned(
left: tipX,
top: tipY,
child: TipBox(
width: tipWidth,
height: tipHeight,
arrowPosition: arrowX,
pointingUp: showBelow,
arrowSize: arrowSize,
tip: tip,
onDismiss: onDismiss ?? () => Navigator.of(context).pop(),
),
);
}
}
class TipBox extends StatelessWidget {
final double width;
final double height;
final double arrowPosition;
final bool pointingUp;
final double arrowSize;
final OnboardingTip tip;
final VoidCallback onDismiss;
const TipBox({
super.key,
required this.width,
required this.height,
required this.arrowPosition,
required this.pointingUp,
required this.arrowSize,
required this.tip,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(width, height + arrowSize),
painter: TipBoxPainter(
arrowPosition: arrowPosition,
pointingUp: pointingUp,
arrowSize: arrowSize,
backgroundColor: Theme.of(context).cardColor,
borderColor: Theme.of(context).dividerColor,
),
child: Container(
width: width,
height: height,
margin: EdgeInsets.only(
top: pointingUp ? arrowSize : 0,
bottom: pointingUp ? 0 : arrowSize,
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (tip.title.isNotEmpty) ...[
Text(
tip.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
],
Text(
tip.message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 8),
SizedBox(
height: 36, // Fixed height for button
child: TextButton(
onPressed: onDismiss,
style: TextButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
minimumSize: const Size(80, 36),
),
child: Text(tip.buttonText),
),
),
],
),
),
);
}
}
class TipBoxPainter extends CustomPainter {
final double arrowPosition;
final bool pointingUp;
final double arrowSize;
final Color backgroundColor;
final Color borderColor;
TipBoxPainter({
required this.arrowPosition,
required this.pointingUp,
required this.arrowSize,
required this.backgroundColor,
required this.borderColor,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 1;
final shadowPaint = Paint()
..color = Colors.black.withOpacity(0.1)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
const double radius = 8;
final double boxHeight = size.height - arrowSize;
// Calculate box position
final double boxTop = pointingUp ? arrowSize : 0;
final double boxBottom = pointingUp ? size.height : boxHeight;
// Create the main box path
final Path boxPath = Path();
final RRect boxRect = RRect.fromLTRBR(
0, boxTop, size.width, boxBottom, const Radius.circular(radius));
boxPath.addRRect(boxRect);
// Create arrow path
final Path arrowPath = Path();
// Ensure arrow position is within bounds
final double clampedArrowPosition =
arrowPosition.clamp(arrowSize + 5, size.width - arrowSize - 5);
final double arrowLeft = clampedArrowPosition - arrowSize;
final double arrowRight = clampedArrowPosition + arrowSize;
if (pointingUp) {
// Arrow pointing up
arrowPath.moveTo(arrowLeft, arrowSize);
arrowPath.lineTo(clampedArrowPosition, 0);
arrowPath.lineTo(arrowRight, arrowSize);
} else {
// Arrow pointing down
arrowPath.moveTo(arrowLeft, boxHeight);
arrowPath.lineTo(clampedArrowPosition, size.height);
arrowPath.lineTo(arrowRight, boxHeight);
}
// Combine box and arrow
final Path fullPath = Path.combine(PathOperation.union, boxPath, arrowPath);
// Draw shadow
canvas.drawPath(fullPath, shadowPaint);
// Draw filled shape
canvas.drawPath(fullPath, paint);
// Draw border
canvas.drawPath(fullPath, borderPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class SpotlightPainter extends CustomPainter {
final Rect targetRect;
final Color overlayColor;
SpotlightPainter({
required this.targetRect,
required this.overlayColor,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = overlayColor
..style = PaintingStyle.fill;
// Create the main overlay path covering the entire available area
final Path overlayPath = Path()
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
// Create the cutout path for the target widget with rounded corners
// Add more generous padding around target for better visual effect
final Path cutoutPath = Path()
..addRRect(RRect.fromRectAndRadius(
targetRect.inflate(4),
const Radius.circular(12), // Increased radius for smoother appearance
));
// Subtract the cutout from the overlay to create the spotlight effect
final Path spotlightPath = Path.combine(
PathOperation.difference,
overlayPath,
cutoutPath,
);
// Draw the overlay with cutout
canvas.drawPath(spotlightPath, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is SpotlightPainter &&
(oldDelegate.targetRect != targetRect ||
oldDelegate.overlayColor != overlayColor);
}
}
// OnboardingService Usage Examples
import 'package:flutter/material.dart';
import 'package:services/onboarding_service.dart';
/// Complete example showing how to implement onboarding tips in your Flutter app
class OnboardingExamplePage extends StatefulWidget {
@override
_OnboardingExamplePageState createState() => _OnboardingExamplePageState();
}
class _OnboardingExamplePageState extends State<OnboardingExamplePage> {
// 1. Create GlobalKeys for widgets you want to target
final GlobalKey _searchFieldKey = GlobalKey();
final GlobalKey _menuButtonKey = GlobalKey();
final GlobalKey _settingsButtonKey = GlobalKey();
final GlobalKey _floatingActionButtonKey = GlobalKey();
// 2. Define your onboarding tips
late final List<OnboardingTip> _onboardingTips;
@override
void initState() {
super.initState();
// 3. Initialize your tips with unique IDs, titles, messages, and target keys
_onboardingTips = [
OnboardingTip(
id: 'onboarding_search_tip',
title: 'Search Feature',
message: 'Use this search bar to find anything you need',
targetKey: _searchFieldKey,
buttonText: 'Next',
),
OnboardingTip(
id: 'onboarding_menu_tip',
title: 'Navigation Menu',
message: 'Access all app sections from this menu',
targetKey: _menuButtonKey,
buttonText: 'Next',
),
OnboardingTip(
id: 'onboarding_settings_tip',
title: 'Settings',
message: 'Customize your app experience here',
targetKey: _settingsButtonKey,
buttonText: 'Next',
),
OnboardingTip(
id: 'onboarding_add_content_tip',
title: 'Add Content',
message: 'Use this button to add new content to the app',
targetKey: _floatingActionButtonKey,
buttonText: 'Got it!',
onDismiss: () {
// Optional: Custom callback when tip is dismissed
print('User completed onboarding tour!');
},
),
];
// 4. Show onboarding tips after the widget is built
WidgetsBinding.instance.addPostFrameCallback((_) {
// Option A: Show a sequence of tips (recommended for tours)
onboardingService.showTipSequence(context, _onboardingTips);
// Option B: Show individual tips (use for specific features)
// onboardingService.showTipIfNeeded(context, _onboardingTips.first);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Onboarding Example'),
leading: IconButton(
key: _menuButtonKey, // Assign the key here
icon: Icon(Icons.menu),
onPressed: () {
// Menu action
},
),
actions: [
IconButton(
key: _settingsButtonKey, // Assign the key here
icon: Icon(Icons.settings),
onPressed: () {
// Settings action
},
),
],
),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
// Search field with assigned key
TextField(
key: _searchFieldKey, // Assign the key here
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: Icon(Icons.search),
),
),
SizedBox(height: 20),
// Other content...
Expanded(
child: Center(
child: Text('Your app content goes here'),
),
),
],
),
),
floatingActionButton: FloatingActionButton(
key: _floatingActionButtonKey, // Assign the key here
onPressed: () {
// Add content action
},
child: Icon(Icons.add),
),
);
}
}
/// Individual Usage Examples
class OnboardingUsageExamples {
/// Example 1: Show a single tip for a specific feature
static void showSingleTip(BuildContext context, GlobalKey targetKey) {
final tip = OnboardingTip(
id: 'feature_tip_unique_id',
title: 'New Feature!',
message: 'This is how you use this awesome new feature',
targetKey: targetKey,
buttonText: 'Try it!',
onDismiss: () {
// Track that user saw the tip
print('User dismissed feature tip');
},
);
// This will only show if the tip hasn't been shown before
onboardingService.showTipIfNeeded(context, tip);
}
/// Example 2: Show contextual tips based on user actions
static void showContextualTip(BuildContext context, String userAction, GlobalKey targetKey) {
late OnboardingTip tip;
switch (userAction) {
case 'first_search':
tip = OnboardingTip(
id: 'first_search_tip',
title: 'Great job!',
message: 'You can refine your search using filters',
targetKey: targetKey,
);
break;
case 'empty_results':
tip = OnboardingTip(
id: 'empty_results_tip',
title: 'No results found',
message: 'Try using different keywords or check your spelling',
targetKey: targetKey,
);
break;
default:
return;
}
onboardingService.showTipIfNeeded(context, tip);
}
/// Example 3: Force show a tip (useful for help buttons)
static void showHelpTip(BuildContext context, GlobalKey targetKey) {
final tip = OnboardingTip(
id: 'help_tip',
title: 'Help',
message: 'This feature allows you to...',
targetKey: targetKey,
);
// This will show the tip even if it was shown before
onboardingService.forceShowTip(context, tip);
}
/// Example 4: Check if a tip has been shown (useful for analytics)
static bool hasUserSeenFeature(String featureId) {
return onboardingService.hasTipBeenShown('onboarding_${featureId}_tip');
}
/// Example 5: Reset tips for testing or user preference
static Future<void> resetOnboarding() async {
// Reset all tips
await onboardingService.resetAllTips();
// Or reset specific tip
// await onboardingService.resetTip('specific_tip_id');
}
}
/// Advanced Usage: Custom onboarding flow based on user type
class AdvancedOnboardingExample extends StatefulWidget {
final String userType; // 'new', 'returning', 'premium'
const AdvancedOnboardingExample({Key? key, required this.userType}) : super(key: key);
@override
_AdvancedOnboardingExampleState createState() => _AdvancedOnboardingExampleState();
}
class _AdvancedOnboardingExampleState extends State<AdvancedOnboardingExample> {
final GlobalKey _basicFeatureKey = GlobalKey();
final GlobalKey _advancedFeatureKey = GlobalKey();
final GlobalKey _premiumFeatureKey = GlobalKey();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_showUserSpecificOnboarding();
});
}
void _showUserSpecificOnboarding() {
List<OnboardingTip> tips = [];
// Always show basic features
tips.add(OnboardingTip(
id: 'basic_feature_tip',
title: 'Basic Feature',
message: 'This is available to all users',
targetKey: _basicFeatureKey,
buttonText: 'Next',
));
// Show advanced features for returning users
if (widget.userType == 'returning' || widget.userType == 'premium') {
tips.add(OnboardingTip(
id: 'advanced_feature_tip',
title: 'Advanced Feature',
message: 'Since you\'re familiar with the app, here\'s an advanced feature',
targetKey: _advancedFeatureKey,
buttonText: 'Next',
));
}
// Show premium features only for premium users
if (widget.userType == 'premium') {
tips.add(OnboardingTip(
id: 'premium_feature_tip',
title: 'Premium Feature',
message: 'This exclusive feature is available with your premium subscription',
targetKey: _premiumFeatureKey,
buttonText: 'Awesome!',
));
}
if (tips.isNotEmpty) {
onboardingService.showTipSequence(context, tips);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
ElevatedButton(
key: _basicFeatureKey,
onPressed: () {},
child: Text('Basic Feature'),
),
if (widget.userType == 'returning' || widget.userType == 'premium')
ElevatedButton(
key: _advancedFeatureKey,
onPressed: () {},
child: Text('Advanced Feature'),
),
if (widget.userType == 'premium')
ElevatedButton(
key: _premiumFeatureKey,
onPressed: () {},
child: Text('Premium Feature'),
),
],
),
);
}
}
/// Debug Widget for Testing (only in debug mode)
class OnboardingDebugTools extends StatelessWidget {
final List<OnboardingTip> tips;
const OnboardingDebugTools({Key? key, required this.tips}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
top: 100,
right: 16,
child: Column(
children: [
// Reset all tips button
FloatingActionButton.small(
heroTag: "reset_tips",
backgroundColor: Colors.red,
onPressed: () async {
await onboardingService.resetAllTips();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('All tips reset!')),
);
},
child: Icon(Icons.refresh),
tooltip: 'Reset all tips',
),
SizedBox(height: 8),
// Show first tip button
FloatingActionButton.small(
heroTag: "show_tip",
backgroundColor: Colors.blue,
onPressed: () {
if (tips.isNotEmpty) {
onboardingService.forceShowTip(context, tips.first);
}
},
child: Icon(Icons.help),
tooltip: 'Show first tip',
),
SizedBox(height: 8),
// Show tip sequence button
FloatingActionButton.small(
heroTag: "show_sequence",
backgroundColor: Colors.green,
onPressed: () {
onboardingService.showTipSequence(context, tips);
},
child: Icon(Icons.play_arrow),
tooltip: 'Show tip sequence',
),
],
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment