showcasescreen 1.0.1 copy "showcasescreen: ^1.0.1" to clipboard
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,
              ),
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
150
points
10
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter package to Showcase/Highlight widgets step by step.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on showcasescreen