overmark 0.1.0
overmark: ^0.1.0 copied to clipboard
Spotlight coach marks for Flutter onboarding tours. Dims the screen, highlights any widget through a cut-out, and explains it in a fully customizable card. Zero dependencies.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:overmark/overmark.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() => runApp(const OvermarkExampleApp());
/// Persists the "already seen" flag across launches.
///
/// Overmark ships without dependencies, so this adapter lives in your app.
class PrefsOvermarkStorage implements OvermarkStorage {
String _key(String tourId) => 'overmark_$tourId';
@override
Future<bool> hasShown(String tourId) async =>
(await SharedPreferences.getInstance()).getBool(_key(tourId)) ?? false;
@override
Future<void> markShown(String tourId) async =>
(await SharedPreferences.getInstance()).setBool(_key(tourId), true);
Future<void> reset(String tourId) async =>
(await SharedPreferences.getInstance()).remove(_key(tourId));
}
class OvermarkExampleApp extends StatefulWidget {
const OvermarkExampleApp({super.key});
@override
State<OvermarkExampleApp> createState() => _OvermarkExampleAppState();
}
class _OvermarkExampleAppState extends State<OvermarkExampleApp> {
ThemeMode _mode = ThemeMode.light;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Overmark',
debugShowCheckedModeBanner: false,
themeMode: _mode,
theme: ThemeData(colorSchemeSeed: const Color(0xFF173F34)),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF173F34),
brightness: Brightness.dark,
),
home: HomePage(
onToggleBrightness: () => setState(() {
_mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
}),
),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({required this.onToggleBrightness, super.key});
final VoidCallback onToggleBrightness;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
static const String _tourId = 'home_tour_v1';
final PrefsOvermarkStorage _storage = PrefsOvermarkStorage();
final GlobalKey _searchKey = GlobalKey();
final GlobalKey _shelfKey = GlobalKey();
final GlobalKey _libraryKey = GlobalKey();
final GlobalKey _profileKey = GlobalKey();
int _tab = 0;
String _lastOutcome = '—';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _showFirstRunTour());
}
List<OvermarkStep> get _steps => <OvermarkStep>[
OvermarkStep(
anchorKey: _searchKey,
title: 'Find your next book',
message: 'Search by title, author, or topic — or browse the shelves.',
),
OvermarkStep(
anchorKey: _shelfKey,
title: 'Tap any cover',
message:
'Every book is a short visual summary. Tap one to preview it.',
),
OvermarkStep(
anchorKey: _libraryKey,
title: 'Your library',
message: 'Saved books and reading progress live here.',
shape: OvermarkShape.circle,
),
OvermarkStep(
anchorKey: _profileKey,
title: 'Your progress',
message: 'Streaks and stats, all in one place.',
shape: OvermarkShape.circle,
),
];
Future<void> _showFirstRunTour() async {
final OvermarkOutcome outcome = await Overmark.showOnce(
context,
tourId: _tourId,
storage: _storage,
steps: _steps,
);
_record(outcome);
}
Future<void> _replayDefault() async {
_record(await Overmark.show(context, steps: _steps));
}
Future<void> _replayCustom() async {
_record(
await Overmark.show(
context,
labels: const OvermarkLabels(
next: 'Lanjut',
skip: 'Lewati',
done: 'Mengerti',
),
theme: OvermarkTheme(
scrimColor: const Color(0xFF1B0E2B).withAlpha(220),
spotlightBorderColor: const Color(0xFFFFC857),
spotlightBorderWidth: 3,
spotlightRadius: 28,
spotlightPadding: const EdgeInsets.all(12),
cardColor: const Color(0xFF2A1B3D),
cardRadius: 28,
titleStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Color(0xFFFFC857),
),
messageStyle: const TextStyle(
fontSize: 14,
height: 1.5,
color: Color(0xFFE7DFF5),
),
buttonColor: const Color(0xFFFFC857),
buttonLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: Color(0xFF2A1B3D),
),
indicatorActiveColor: const Color(0xFFFFC857),
indicatorColor: const Color(0xFF54407A),
),
config: const OvermarkConfig(advanceOnBarrierTap: false),
steps: _steps,
),
);
}
Future<void> _resetFlag() async {
await _storage.reset(_tourId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Restart the app to see the tour again.')),
);
}
void _record(OvermarkOutcome outcome) {
if (!mounted) return;
setState(() => _lastOutcome = outcome.name);
}
@override
Widget build(BuildContext context) {
final ColorScheme colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Overmark'),
actions: <Widget>[
IconButton(
onPressed: widget.onToggleBrightness,
icon: const Icon(Icons.brightness_6_outlined),
tooltip: 'Toggle brightness',
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
children: <Widget>[
TextField(
key: _searchKey,
decoration: InputDecoration(
hintText: 'Search books, authors, topics',
prefixIcon: const Icon(Icons.search),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 28),
Text('Popular', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
SizedBox(
key: _shelfKey,
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 8,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: 12),
itemBuilder: (BuildContext context, int index) => Container(
width: 104,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text('#${index + 1}'),
),
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _replayDefault,
child: const Text('Replay the tour'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _replayCustom,
child: const Text('Replay with a custom theme'),
),
const SizedBox(height: 12),
TextButton(
onPressed: _resetFlag,
child: const Text('Reset the once-only flag'),
),
const SizedBox(height: 24),
Center(
child: Text(
'Last outcome: $_lastOutcome',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (int index) => setState(() => _tab = index),
destinations: <Widget>[
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.bookmark_border, key: _libraryKey),
selectedIcon: const Icon(Icons.bookmark),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.person_outline, key: _profileKey),
selectedIcon: const Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}