showcasescreen 1.0.1
showcasescreen: ^1.0.1 copied to clipboard
A Flutter package to Showcase/Highlight widgets step by step.
example/lib/main.dart
import 'dart:developer';
import 'package:example/now_playing_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:showcasescreen/showcasescreen.dart';
/// Top-level (not per-State) so it survives the `MusicHomePage` widget being
/// rebuilt, and lasts for the app's process lifetime: the tour auto-plays
/// once per run, then only restarts when the user taps "Restart tour".
/// This is intentionally in-memory rather than persisted to disk - it needs
/// no extra dependency and works identically on every platform (including
/// web, where `dart:io`-based storage isn't available).
bool _hasSeenTour = false;
void main() => runApp(const MyApp());
// Light, airy palette inspired by modern music-streaming apps.
const _bg = Color(0xFFF8FAFC);
const _surface = Color(0xFFFFFFFF);
const _primary = Color(0xFF4338CA);
const _accent = Color(0xFF22C55E);
const _foreground = Color(0xFF0F0F23);
const _muted = Color(0xFF64748B);
// A solid black scrim gives a crisp spotlight against the light theme -
// the barrier stays black regardless of app theme; only the tooltip/pill
// colors below need to flip for light vs dark.
const _tourOverlayColor = Colors.black;
const _tourOverlayOpacity = 0.75;
// One consistent action-button language for the whole tour: green solid =
// primary/forward action, opaque surface pill = every secondary action
// (back/skip/close). These buttons can render over either the tooltip
// card or the dark barrier scrim depending on tooltip position, so the
// pill background must be a solid, opaque color - not a translucent tint
// - otherwise the text color's contrast depends on whatever happens to
// be underneath, and can disappear against a dark backdrop.
const _secondaryActionBg = _surface;
const _secondaryActionBorder = Border.fromBorderSide(
BorderSide(color: Colors.black12),
);
const _secondaryActionText = TextStyle(
color: _foreground,
fontWeight: FontWeight.w600,
);
const _primaryActionText = TextStyle(
color: Colors.black,
fontWeight: FontWeight.w700,
);
/// Showcase key for the menu button in the header.
final GlobalKey _menuKey = GlobalKey();
/// Showcase key for the "restart tour" floating action button.
final GlobalKey _fabKey = GlobalKey();
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SoundWave',
theme: ThemeData.light(useMaterial3: true).copyWith(
scaffoldBackgroundColor: _bg,
colorScheme: ColorScheme.fromSeed(
seedColor: _primary,
brightness: Brightness.light,
primary: _primary,
secondary: _accent,
surface: _surface,
),
),
debugShowCheckedModeBanner: false,
home: const MusicHomePage(),
);
}
}
class MusicHomePage extends StatefulWidget {
const MusicHomePage({super.key});
@override
State<MusicHomePage> createState() => _MusicHomePageState();
}
class _MusicHomePageState extends State<MusicHomePage> {
final GlobalKey _profileKey = GlobalKey();
final GlobalKey _trackTileKey = GlobalKey();
final GlobalKey _albumArtKey = GlobalKey();
// Tracks whether the tour is actually mid-flight, so returning from the
// Now Playing screen only resumes it when the user tapped through as
// part of a running tour - not on every casual track tap.
bool _tourActive = false;
final scrollController = ScrollController();
List<Track> tracks = [];
@override
void initState() {
super.initState();
// Register the showcase view for this screen. This is the alternative
// to wrapping the app in a `ShowCaseWidget` - all configuration lives
// here instead. Without registering a ShowcaseScreen, showcase
// functionality will not work.
ShowcaseScreen.register(
hideFloatingActionWidgetForShowcase: [_fabKey],
globalFloatingActionWidget: (showcaseContext) => FloatingActionWidget(
left: 16,
bottom: 16,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: () => ShowcaseScreen.get().dismiss(),
style: ElevatedButton.styleFrom(backgroundColor: _accent),
child: const Text(
'Skip',
style: TextStyle(color: Colors.black, fontSize: 15),
),
),
),
),
onStart: (index, key) {
log('onStart: $index, $key');
},
onComplete: (index, key) {
log('onComplete: $index, $key');
if (index == 4) {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
_tourActive = false;
_markTourSeen();
}
},
blurValue: 1,
autoPlayDelay: const Duration(seconds: 3),
globalTooltipActionConfig: const TooltipActionConfig(
position: TooltipActionPosition.inside,
alignment: MainAxisAlignment.spaceBetween,
actionGap: 20,
),
globalTooltipActions: [
// The first showcase step doesn't need a "previous" action.
TooltipActionButton(
type: TooltipDefaultActionType.previous,
backgroundColor: _secondaryActionBg,
border: _secondaryActionBorder,
textStyle: _secondaryActionText,
hideActionWidgetForShowcase: [_menuKey],
),
// The last showcase step doesn't need a "next" action.
TooltipActionButton(
type: TooltipDefaultActionType.next,
backgroundColor: _accent,
textStyle: _primaryActionText,
hideActionWidgetForShowcase: [_fabKey],
),
],
onDismiss: (key) {
debugPrint('Dismissed at $key');
// Skipping counts as having seen the tour too - don't force it on
// the user again next time, only the restart button should.
_tourActive = false;
_markTourSeen();
},
);
// Auto-play the tour on first launch only; once it's been seen (either
// finished or skipped), it won't start again on its own.
_maybeAutoStartTour();
tracks = [
Track(
title: 'Midnight Drive',
artist: 'Lumen Fields',
duration: '3:42',
gradient: const [Color(0xFF4338CA), Color(0xFF9333EA)],
icon: Icons.graphic_eq_rounded,
isLiked: true,
),
Track(
title: 'Golden Hour',
artist: 'Aurora Bay',
duration: '4:05',
gradient: const [Color(0xFFEA580C), Color(0xFFF59E0B)],
icon: Icons.wb_sunny_rounded,
isLiked: false,
),
Track(
title: 'Neon Skyline',
artist: 'The Night Owls',
duration: '3:15',
gradient: const [Color(0xFF0EA5E9), Color(0xFF22D3EE)],
icon: Icons.nightlife_rounded,
isLiked: false,
),
Track(
title: 'Slow Static',
artist: 'Coral Waves',
duration: '2:58',
gradient: const [Color(0xFFDB2777), Color(0xFFF472B6)],
icon: Icons.waves_rounded,
isLiked: true,
),
Track(
title: 'Paper Moon',
artist: 'Isla & the Tides',
duration: '3:33',
gradient: const [Color(0xFF16A34A), Color(0xFF4ADE80)],
icon: Icons.nights_stay_rounded,
isLiked: false,
),
Track(
title: 'Electric Bloom',
artist: 'Vesper Lane',
duration: '4:20',
gradient: const [Color(0xFF7C3AED), Color(0xFFC084FC)],
icon: Icons.bolt_rounded,
isLiked: false,
),
];
}
@override
void dispose() {
scrollController.dispose();
// Unregister the showcase view when this screen is disposed.
ShowcaseScreen.get().unregister();
super.dispose();
}
void _maybeAutoStartTour() {
if (_hasSeenTour) return;
_tourActive = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => ShowcaseScreen.get().startShowCase(
[_menuKey, _profileKey, _trackTileKey, _albumArtKey, _fabKey],
),
);
}
void _markTourSeen() {
_hasSeenTour = true;
}
void _openNowPlaying(Track track) {
Navigator.push<void>(
context,
MaterialPageRoute<void>(
builder: (_) => NowPlayingScreen(track: track),
),
).then((_) {
// The player screen's scope is no longer needed once we're back,
// since `get()` always resolves to the most recently registered scope.
ShowcaseScreen.getNamed('nowPlayingScope').unregister();
// Only resume the tour if it was actually mid-flight when the user
// tapped through to this screen - a casual tap on any track (now
// that they're all tappable) must not restart a finished/skipped
// tour on its own.
if (_tourActive) {
ShowcaseScreen.get().startShowCase([_albumArtKey, _fabKey]);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
bottom: false,
child: Column(
children: <Widget>[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: <Widget>[
Showcase(
key: _menuKey,
title: 'Menu',
description: 'Tap here to open the navigation menu',
onBarrierClick: () {
debugPrint('Barrier clicked');
ShowcaseScreen.get().hideFloatingActionWidgetForKeys(
[_menuKey, _fabKey],
);
},
tooltipBackgroundColor: _surface,
textColor: _foreground,
overlayColor: _tourOverlayColor,
overlayOpacity: _tourOverlayOpacity,
targetBorderRadius: BorderRadius.circular(12),
tooltipActionConfig: const TooltipActionConfig(
alignment: MainAxisAlignment.end,
position: TooltipActionPosition.outside,
gapBetweenContentAndAction: 10,
),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: GestureDetector(
onTap: () => debugPrint('Menu button clicked'),
child: const Icon(Icons.menu, color: _foreground),
),
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Good evening',
style: TextStyle(fontSize: 13, color: _muted),
),
Text(
'Sahil Italiya',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: _foreground,
letterSpacing: 0.3,
),
),
],
),
),
Showcase(
targetPadding: const EdgeInsets.all(4),
key: _profileKey,
title: 'Your profile',
description:
'Tap here to view your profile, liked songs and '
'listening stats.',
tooltipBackgroundColor: _primary,
textColor: Colors.white,
floatingActionWidget: FloatingActionWidget(
left: 16,
bottom: 16,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
),
onPressed: ShowcaseScreen.get().dismiss,
child: const Text(
'Close Showcase',
style: TextStyle(color: Colors.black, fontSize: 15),
),
),
),
),
targetShapeBorder: const CircleBorder(),
overlayColor: _tourOverlayColor,
overlayOpacity: _tourOverlayOpacity,
tooltipActionConfig: const TooltipActionConfig(
alignment: MainAxisAlignment.spaceBetween,
gapBetweenContentAndAction: 10,
position: TooltipActionPosition.outside,
),
tooltipActions: const [
TooltipActionButton(
type: TooltipDefaultActionType.previous,
backgroundColor: _secondaryActionBg,
border: _secondaryActionBorder,
textStyle: _secondaryActionText,
),
TooltipActionButton(
type: TooltipDefaultActionType.next,
backgroundColor: _accent,
textStyle: _primaryActionText,
),
],
child: Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Color(0xFF9333EA), Color(0xFFDB2777)],
),
),
alignment: Alignment.center,
child: const Text(
'S',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
],
),
),
const SizedBox(height: 24),
SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: tracks.length,
separatorBuilder: (context, index) => const SizedBox(width: 12),
itemBuilder: (context, index) {
// Reverse order so the shelf doesn't just mirror the list
// below it.
final track = tracks[tracks.length - 1 - index];
return _RecentChip(track: track);
},
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text(
'Made For You',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: _foreground,
),
),
Text(
'${tracks.length} songs',
style: const TextStyle(fontSize: 13, color: _muted),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
controller: scrollController,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 96),
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
if (index == 0) {
return _showcasedTrackTile(track);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _openNowPlaying(track),
child: TrackTile(
track: track,
onToggleLike: () => setState(
() => track.isLiked = !track.isLiked,
),
),
),
);
},
),
),
],
),
),
floatingActionButton: Showcase(
key: _fabKey,
title: 'Restart tour',
description: 'Tap here to restart the showcase tour anytime',
targetBorderRadius: const BorderRadius.all(Radius.circular(32)),
showArrow: false,
tooltipBackgroundColor: _surface,
textColor: _foreground,
overlayColor: _tourOverlayColor,
overlayOpacity: _tourOverlayOpacity,
tooltipActionConfig: const TooltipActionConfig(
position: TooltipActionPosition.insideRight,
gapBetweenContentAndAction: 8,
),
tooltipActions: [
TooltipActionButton.custom(
button: GestureDetector(
onTap: () => ShowcaseScreen.get().dismiss(),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _secondaryActionBg,
border: _secondaryActionBorder,
),
child: const Icon(Icons.close, color: _foreground, size: 16),
),
),
),
],
child: FloatingActionButton(
backgroundColor: _accent,
tooltip: 'Restart tour',
onPressed: () {
setState(() {
// Reset the list to the top to guarantee the showcased track
// tile is currently rendered before restarting the tour -
// this always plays regardless of whether the user has
// already seen it before.
scrollController.jumpTo(0);
_tourActive = true;
ShowcaseScreen.get().startShowCase(
[_menuKey, _profileKey, _trackTileKey, _albumArtKey, _fabKey],
);
});
},
child: const Icon(Icons.replay_rounded, color: Colors.black),
),
),
);
}
Widget _showcasedTrackTile(Track track) {
return GestureDetector(
onTap: () => _openNowPlaying(track),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Showcase(
key: _trackTileKey,
description: 'Tap a track to open the full player',
disposeOnTap: true,
onTargetClick: () => _openNowPlaying(track),
tooltipBackgroundColor: _surface,
textColor: _foreground,
overlayColor: _tourOverlayColor,
overlayOpacity: _tourOverlayOpacity,
targetBorderRadius: BorderRadius.circular(16),
tooltipActionConfig: const TooltipActionConfig(
alignment: MainAxisAlignment.spaceBetween,
actionGap: 16,
position: TooltipActionPosition.outside,
gapBetweenContentAndAction: 16,
),
tooltipActions: [
TooltipActionButton(
type: TooltipDefaultActionType.previous,
name: 'Back',
onTap: () => ShowcaseScreen.get().previous(),
backgroundColor: _secondaryActionBg,
border: _secondaryActionBorder,
textStyle: _secondaryActionText,
),
TooltipActionButton(
type: TooltipDefaultActionType.skip,
name: 'Close',
backgroundColor: _secondaryActionBg,
border: _secondaryActionBorder,
textStyle: _secondaryActionText,
tailIcon: const ActionButtonIcon(
icon: Icon(Icons.close, color: _foreground, size: 15),
),
),
],
child: TrackTile(
track: track,
albumArtShowcaseKey: _albumArtKey,
onToggleLike: () => setState(() => track.isLiked = !track.isLiked),
),
),
),
);
}
}
class Track {
Track({
required this.title,
required this.artist,
required this.duration,
required this.gradient,
required this.icon,
required this.isLiked,
});
final String title;
final String artist;
final String duration;
final List<Color> gradient;
final IconData icon;
bool isLiked;
}
class _RecentChip extends StatelessWidget {
const _RecentChip({required this.track});
final Track track;
@override
Widget build(BuildContext context) {
return Container(
width: 220,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: Row(
children: <Widget>[
_AlbumArt(gradient: track.gradient, size: 56, icon: track.icon),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: _foreground,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
const Icon(
Icons.play_circle_fill_rounded,
color: _accent,
size: 18,
),
],
),
),
],
),
);
}
}
class _AlbumArt extends StatelessWidget {
const _AlbumArt({
required this.gradient,
required this.size,
this.icon = Icons.graphic_eq_rounded,
});
final List<Color> gradient;
final double size;
final IconData icon;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size * 0.28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradient,
),
),
child: Icon(icon, color: Colors.white, size: size * 0.5),
);
}
}
class TrackTile extends StatelessWidget {
const TrackTile({
super.key,
required this.track,
this.albumArtShowcaseKey,
this.onToggleLike,
});
final Track track;
final GlobalKey? albumArtShowcaseKey;
final VoidCallback? onToggleLike;
@override
Widget build(BuildContext context) {
final art = _AlbumArt(gradient: track.gradient, size: 52, icon: track.icon);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: track.gradient.last.withValues(alpha: 0.18),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
if (albumArtShowcaseKey != null)
Showcase.withWidget(
key: albumArtShowcaseKey!,
overlayColor: _tourOverlayColor,
overlayOpacity: _tourOverlayOpacity,
tooltipActionConfig: const TooltipActionConfig(
alignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
actionGap: 16,
),
tooltipActions: const [
TooltipActionButton(
type: TooltipDefaultActionType.previous,
name: 'Back',
backgroundColor: _secondaryActionBg,
border: _secondaryActionBorder,
textStyle: _secondaryActionText,
),
TooltipActionButton(
type: TooltipDefaultActionType.skip,
name: 'Close',
backgroundColor: _secondaryActionBg,
border: _secondaryActionBorder,
textStyle: _secondaryActionText,
),
],
targetBorderRadius: BorderRadius.circular(52 * 0.28),
container: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: Colors.black12),
boxShadow: const [
BoxShadow(
color: Color(0x1F000000),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
width: 170,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_AlbumArt(
gradient: track.gradient, size: 44, icon: track.icon),
const SizedBox(height: 10),
const Text(
'Now playing preview',
style: TextStyle(color: _foreground),
),
],
),
),
child: art,
)
else
art,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
track.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
color: _foreground,
),
),
const SizedBox(height: 2),
Text(
track.artist,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, color: _muted),
),
],
),
),
Text(
track.duration,
style: const TextStyle(fontSize: 12, color: _muted),
),
GestureDetector(
onTap: onToggleLike,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
track.isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 18,
color: track.isLiked ? _accent : _muted,
),
),
),
],
),
);
}
}