v_story_viewer 2.0.2 copy "v_story_viewer: ^2.0.2" to clipboard
v_story_viewer: ^2.0.2 copied to clipboard

A high-performance Flutter story viewer like WhatsApp/Instagram. Supports image, video, text, voice stories with 3D cube transitions.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:v_story_viewer/v_story_viewer.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'V Story Viewer Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      darkTheme: ThemeData.dark(useMaterial3: true),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;
  late List<VStoryGroup> _basicStories;
  late List<VStoryGroup> _advancedStories;
  late List<VStoryGroup> _customStories;
  // Track seen state
  final Set<String> _seenStoryIds = {};
  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
    _basicStories = _createBasicStories();
    _advancedStories = _createAdvancedStories();
    _customStories = _createCustomStories();
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  // ═══════════════════════════════════════════════════════════════
  // BASIC STORIES - Image, Video, Text, Voice
  // ═══════════════════════════════════════════════════════════════
  List<VStoryGroup> _createBasicStories() {
    final now = DateTime.now();
    return [
      // User 1: Multiple Image Stories
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_images',
          name: 'Sarah Photos',
          imageUrl: 'https://i.pravatar.cc/150?u=sarah',
        ),
        stories: [
          VImageStory(
            url: 'https://picsum.photos/seed/nature1/1080/1920',
            createdAt: now.subtract(const Duration(hours: 2)),
            isSeen: false,
            duration: const Duration(seconds: 4),
            caption: 'This is a caption',
          ),
          VImageStory(
            url: 'https://picsum.photos/seed/nature2/1080/1920',
            createdAt: now.subtract(const Duration(hours: 1, minutes: 30)),
            isSeen: false,
          ),
          VImageStory(
            url: 'https://picsum.photos/seed/nature3/1080/1920',
            createdAt: now.subtract(const Duration(hours: 1)),
            isSeen: false,
            duration: const Duration(seconds: 7),
          ),
        ],
      ),
      // User 2: Video Stories
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_videos',
          name: 'Mike Videos',
          imageUrl: 'https://i.pravatar.cc/150?u=mike',
        ),
        stories: [
          VVideoStory(
            url:
                'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
            createdAt: now.subtract(const Duration(hours: 4)),
            isSeen: false,
          ),
          VVideoStory(
            url:
                'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
            createdAt: now.subtract(const Duration(hours: 3)),
            isSeen: false,
          ),
        ],
      ),
      // User 3: Text Stories with different styles
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_text',
          name: 'Emma Quotes',
          imageUrl: 'https://i.pravatar.cc/150?u=emma',
        ),
        stories: [
          VTextStory(
            text: 'Good morning everyone! Hope you have an amazing day ahead.',
            backgroundColor: const Color(0xFF6366F1),
            createdAt: now.subtract(const Duration(hours: 6)),
            isSeen: false,
          ),
          VTextStory(
            text:
                'The only way to do great work is to love what you do. - Steve Jobs',
            backgroundColor: const Color(0xFFEC4899),
            textStyle: const TextStyle(
              fontSize: 28,
              fontStyle: FontStyle.italic,
              fontWeight: FontWeight.w300,
            ),
            createdAt: now.subtract(const Duration(hours: 5)),
            isSeen: false,
          ),
          VTextStory(
            text: 'Flutter is awesome for building beautiful apps!',
            backgroundColor: const Color(0xFF10B981),
            createdAt: now.subtract(const Duration(hours: 4)),
            isSeen: false,
            duration: const Duration(seconds: 4),
          ),
        ],
      ),
      // User 4: Voice/Audio Stories
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_voice',
          name: 'DJ Alex',
          imageUrl: 'https://i.pravatar.cc/150?u=alex',
        ),
        stories: [
          VVoiceStory(
            url:
                'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
            backgroundColor: const Color(0xFF8B5CF6),
            createdAt: now.subtract(const Duration(hours: 8)),
            isSeen: false,
          ),
          VVoiceStory(
            url:
                'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
            backgroundColor: const Color(0xFFF59E0B),
            createdAt: now.subtract(const Duration(hours: 7)),
            isSeen: false,
          ),
        ],
      ),
      // User 5: Mixed content (partial seen)
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_mixed',
          name: 'Chris Mix',
          imageUrl: 'https://i.pravatar.cc/150?u=chris',
        ),
        stories: [
          VImageStory(
            url: 'https://picsum.photos/seed/mix1/1080/1920',
            createdAt: now.subtract(const Duration(hours: 10)),
            isSeen: true,
          ),
          VTextStory(
            text: 'Check out my latest video below!',
            backgroundColor: Colors.deepOrange,
            createdAt: now.subtract(const Duration(hours: 9)),
            isSeen: true,
          ),
          VVideoStory(
            url:
                'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
            createdAt: now.subtract(const Duration(hours: 8)),
            isSeen: false,
          ),
          VImageStory(
            url: 'https://picsum.photos/seed/mix2/1080/1920',
            createdAt: now.subtract(const Duration(hours: 7)),
            isSeen: false,
          ),
        ],
      ),
    ];
  }

  // ═══════════════════════════════════════════════════════════════
  // ADVANCED STORIES - Overlays, RichText, Custom Builders
  // ═══════════════════════════════════════════════════════════════
  List<VStoryGroup> _createAdvancedStories() {
    final now = DateTime.now();
    return [
      // User 1: Stories with Overlays (captions, watermarks)
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_overlay',
          name: 'Overlay Demo',
          imageUrl: 'https://i.pravatar.cc/150?u=overlay',
        ),
        stories: [
          // Image with caption overlay
          VImageStory(
            url: 'https://picsum.photos/seed/overlay1/1080/1920',
            createdAt: now.subtract(const Duration(hours: 2)),
            isSeen: false,
            overlayBuilder: (context) => Positioned(
              bottom: 120,
              left: 16,
              right: 16,
              child: Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: Colors.black54,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: const Text(
                  'Beautiful sunset at the beach!',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 18,
                    fontWeight: FontWeight.w500,
                  ),
                  textAlign: TextAlign.center,
                ),
              ),
            ),
          ),
          // Image with watermark overlay
          VImageStory(
            url: 'https://picsum.photos/seed/overlay2/1080/1920',
            createdAt: now.subtract(const Duration(hours: 1, minutes: 30)),
            isSeen: false,
            overlayBuilder: (context) => Positioned(
              top: 100,
              right: 16,
              child: Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                decoration: BoxDecoration(
                  color: Colors.white.withValues(alpha: 0.8),
                  borderRadius: BorderRadius.circular(20),
                ),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.verified, color: Colors.blue.shade600, size: 18),
                    const SizedBox(width: 4),
                    Text(
                      '@photographer',
                      style: TextStyle(
                        color: Colors.blue.shade600,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
          // Image with multiple overlay elements
          VImageStory(
            url: 'https://picsum.photos/seed/overlay3/1080/1920',
            createdAt: now.subtract(const Duration(hours: 1)),
            isSeen: false,
            overlayBuilder: (context) => Stack(
              children: [
                // Location tag
                Positioned(
                  top: 100,
                  left: 16,
                  child: Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                    decoration: BoxDecoration(
                      color: Colors.white,
                      borderRadius: BorderRadius.circular(20),
                    ),
                    child: const Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Icon(Icons.location_on, color: Colors.red, size: 16),
                        SizedBox(width: 4),
                        Text('New York City',
                            style: TextStyle(fontWeight: FontWeight.w600)),
                      ],
                    ),
                  ),
                ),
                // Hashtag
                Positioned(
                  bottom: 140,
                  left: 16,
                  child: Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                    decoration: BoxDecoration(
                      color: Colors.blue.withValues(alpha: 0.8),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    child: const Text(
                      '#travel #adventure',
                      style: TextStyle(
                          color: Colors.white, fontWeight: FontWeight.w500),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      // User 2: Rich Text Stories
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_richtext',
          name: 'Rich Text',
          imageUrl: 'https://i.pravatar.cc/150?u=richtext',
        ),
        stories: [
          // RichText with TextSpan
          VTextStory(
            text: 'Rich text story',
            backgroundColor: const Color(0xFF1F2937),
            createdAt: now.subtract(const Duration(hours: 3)),
            isSeen: false,
            richText: TextSpan(
              children: [
                const TextSpan(
                  text: 'Welcome to ',
                  style: TextStyle(fontSize: 28, color: Colors.white),
                ),
                TextSpan(
                  text: 'V Story Viewer',
                  style: TextStyle(
                    fontSize: 32,
                    fontWeight: FontWeight.bold,
                    foreground: Paint()
                      ..shader = const LinearGradient(
                        colors: [Color(0xFF6366F1), Color(0xFFEC4899)],
                      ).createShader(const Rect.fromLTWH(0, 0, 200, 70)),
                  ),
                ),
                const TextSpan(
                  text: '\n\nThe most powerful story viewer for Flutter!',
                  style: TextStyle(fontSize: 20, color: Colors.white70),
                ),
              ],
            ),
          ),
          // Custom text builder
          VTextStory(
            text: 'Custom builder story',
            backgroundColor: const Color(0xFF0F172A),
            createdAt: now.subtract(const Duration(hours: 2)),
            isSeen: false,
            textBuilder: (context, text) => Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.rocket_launch, size: 80, color: Colors.amber),
                const SizedBox(height: 24),
                ShaderMask(
                  shaderCallback: (bounds) => const LinearGradient(
                    colors: [Colors.purple, Colors.blue, Colors.cyan],
                  ).createShader(bounds),
                  child: const Text(
                    'LAUNCH DAY!',
                    style: TextStyle(
                      fontSize: 40,
                      fontWeight: FontWeight.bold,
                      color: Colors.white,
                    ),
                  ),
                ),
                const SizedBox(height: 16),
                const Text(
                  'Our new app is now live',
                  style: TextStyle(fontSize: 20, color: Colors.white70),
                ),
              ],
            ),
          ),
        ],
      ),
      // User 3: All seen stories (grey ring)
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_allseen',
          name: 'All Seen',
          imageUrl: 'https://i.pravatar.cc/150?u=allseen',
        ),
        stories: [
          VImageStory(
            url: 'https://picsum.photos/seed/seen1/1080/1920',
            createdAt: now.subtract(const Duration(hours: 12)),
            isSeen: true,
          ),
          VImageStory(
            url: 'https://picsum.photos/seed/seen2/1080/1920',
            createdAt: now.subtract(const Duration(hours: 11)),
            isSeen: true,
          ),
          VTextStory(
            text: 'All stories viewed!',
            backgroundColor: Colors.grey,
            createdAt: now.subtract(const Duration(hours: 10)),
            isSeen: true,
          ),
        ],
      ),
    ];
  }

  // ═══════════════════════════════════════════════════════════════
  // CUSTOM STORIES - VCustomStory with interactive content
  // ═══════════════════════════════════════════════════════════════
  List<VStoryGroup> _createCustomStories() {
    final now = DateTime.now();
    return [
      // User 1: Interactive Poll/Quiz
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_poll',
          name: 'Poll Demo',
          imageUrl: 'https://i.pravatar.cc/150?u=poll',
        ),
        stories: [
          VCustomStory(
            createdAt: now.subtract(const Duration(hours: 1)),
            isSeen: false,
            duration: const Duration(seconds: 15),
            contentBuilder: (context, isPaused, isMuted, onLoaded, onError) {
              // Call onLoaded immediately since content is ready
              WidgetsBinding.instance.addPostFrameCallback((_) {
                onLoaded(const Duration(seconds: 15));
              });
              return const _PollStoryContent();
            },
          ),
        ],
      ),
      // User 2: Countdown Timer
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_countdown',
          name: 'Countdown',
          imageUrl: 'https://i.pravatar.cc/150?u=countdown',
        ),
        stories: [
          VCustomStory(
            createdAt: now.subtract(const Duration(hours: 2)),
            isSeen: false,
            contentBuilder: (context, isPaused, isMuted, onLoaded, onError) {
              return _CountdownStoryContent(
                isPaused: isPaused,
                onLoaded: onLoaded,
              );
            },
          ),
        ],
      ),
      // User 3: Gradient Animation
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_gradient',
          name: 'Animated BG',
          imageUrl: 'https://i.pravatar.cc/150?u=gradient',
        ),
        stories: [
          VCustomStory(
            createdAt: now.subtract(const Duration(hours: 3)),
            isSeen: false,
            duration: const Duration(seconds: 8),
            contentBuilder: (context, isPaused, isMuted, onLoaded, onError) {
              WidgetsBinding.instance.addPostFrameCallback((_) {
                onLoaded(const Duration(seconds: 8));
              });
              return _AnimatedGradientContent(isPaused: isPaused);
            },
          ),
        ],
      ),
      // User 4: Product Showcase
      VStoryGroup(
        user: const VStoryUser(
          id: 'user_product',
          name: 'Shop Now',
          imageUrl: 'https://i.pravatar.cc/150?u=shop',
        ),
        stories: [
          VCustomStory(
            createdAt: now.subtract(const Duration(hours: 4)),
            isSeen: false,
            duration: const Duration(seconds: 10),
            contentBuilder: (context, isPaused, isMuted, onLoaded, onError) {
              WidgetsBinding.instance.addPostFrameCallback((_) {
                onLoaded(const Duration(seconds: 10));
              });
              return const _ProductShowcaseContent();
            },
            overlayBuilder: (context) => Positioned(
              bottom: 120,
              left: 16,
              right: 16,
              child: ElevatedButton.icon(
                onPressed: () {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('Shop Now tapped!')),
                  );
                },
                icon: const Icon(Icons.shopping_bag),
                label: const Text('Shop Now'),
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.white,
                  foregroundColor: Colors.black,
                  padding: const EdgeInsets.symmetric(vertical: 16),
                ),
              ),
            ),
          ),
        ],
      ),
    ];
  }

  // ═══════════════════════════════════════════════════════════════
  // STORY VIEWER OPENERS
  // ═══════════════════════════════════════════════════════════════
  void _openBasicViewer(VStoryGroup group, int index) {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => VStoryViewer(
          storyGroups: _basicStories,
          initialGroupIndex: index,
          config: const VStoryConfig(),
          onComplete: (group, item) => debugPrint('All stories complete'),
          onClose: (group, item) => debugPrint('Viewer closed'),
          onStoryViewed: (group, item) {
            debugPrint('Viewed: ${group.user.name}');
            setState(
                () => _seenStoryIds.add('${group.user.id}_${item.createdAt}'));
          },
          onReply: (group, item, text) {
            debugPrint('Reply to ${group.user.name}: $text');
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                  content: Text('Reply sent to ${group.user.name}: $text')),
            );
          },
          onUserTap: (group, item) async {
            await _showUserProfile(group.user);
            return true;
          },
          onMenuTap: (group, item) async {
            return await _showStoryMenu(group, item);
          },
          onSwipeUp: (group, item) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Swipe up detected!')),
            );
          },
          onError: (group, item, error) {
            debugPrint('Error: $error');
          },
        ),
      ),
    );
  }

  void _openAdvancedViewer(VStoryGroup group, int index) {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => VStoryViewer(
          storyGroups: _advancedStories,
          initialGroupIndex: index,
          config: VStoryConfig(
            // Custom colors
            progressColor: Colors.amber,
            progressBackgroundColor: Colors.amber.withValues(alpha: 0.3),
            // Custom loading builder
            loadingBuilder: (context) => const Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  CircularProgressIndicator(
                    valueColor: AlwaysStoppedAnimation(Colors.amber),
                    strokeWidth: 3,
                  ),
                  SizedBox(height: 16),
                  Text(
                    'Loading...',
                    style: TextStyle(color: Colors.white70),
                  ),
                ],
              ),
            ),
            // Custom error builder
            errorBuilder: (context, error, retry) => Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.error_outline, size: 64, color: Colors.red),
                  const SizedBox(height: 16),
                  const Text(
                    'Oops! Something went wrong',
                    style: TextStyle(color: Colors.white, fontSize: 18),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    error.toString(),
                    style: const TextStyle(color: Colors.white54, fontSize: 12),
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 16),
                  ElevatedButton.icon(
                    onPressed: retry,
                    icon: const Icon(Icons.refresh),
                    label: const Text('Try Again'),
                  ),
                ],
              ),
            ),
            // i18n texts
            texts: const VStoryTexts(
              replyHint: 'Write a reply...',
              errorLoadingMedia: 'Could not load content',
              tapToRetry: 'Tap here to retry',
            ),
          ),
          onStoryViewed: (group, item) => debugPrint('Advanced viewed'),
          onReply: (group, item, text) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Reply: $text')),
            );
          },
          onMenuTap: (group, item) async => await _showStoryMenu(group, item),
        ),
      ),
    );
  }

  void _openCustomViewer(VStoryGroup group, int index) {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => VStoryViewer(
          storyGroups: _customStories,
          initialGroupIndex: index,
          config: const VStoryConfig(
            showReplyField: false, // Hide reply for custom stories
            showEmojiButton: false,
          ),
          onStoryViewed: (group, item) => debugPrint('Custom story viewed'),
          onSwipeUp: (group, item) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Swipe up on custom story!')),
            );
          },
        ),
      ),
    );
  }

  void _openMinimalViewer() {
    // Minimal config - hide most UI elements
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => VStoryViewer(
          storyGroups: _basicStories,
          initialGroupIndex: 0,
          config: const VStoryConfig(
            showHeader: false,
            showProgressBar: true,
            showReplyField: false,
            autoPauseOnBackground: false,
          ),
        ),
      ),
    );
  }

  void _openCustomHeaderViewer() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => VStoryViewer(
          storyGroups: _basicStories,
          initialGroupIndex: 0,
          config: VStoryConfig(
            headerBuilder: (context, user, item, onClose) => SafeArea(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Row(
                  children: [
                    CircleAvatar(
                      backgroundImage: NetworkImage(user.imageUrl),
                      radius: 24,
                    ),
                    const SizedBox(width: 12),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text(
                            user.name,
                            style: const TextStyle(
                              color: Colors.white,
                              fontWeight: FontWeight.bold,
                              fontSize: 16,
                            ),
                          ),
                          Text(
                            TimeFormatter.formatRelativeTime(item.createdAt),
                            style: const TextStyle(
                                color: Colors.white70, fontSize: 12),
                          ),
                        ],
                      ),
                    ),
                    IconButton(
                      icon: const Icon(Icons.close, color: Colors.white),
                      onPressed: onClose,
                    ),
                  ],
                ),
              ),
            ),
            footerBuilder: (context, group, item) => SafeArea(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Row(
                  children: [
                    Expanded(
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 16, vertical: 12),
                        decoration: BoxDecoration(
                          color: Colors.white.withValues(alpha: 0.2),
                          borderRadius: BorderRadius.circular(30),
                        ),
                        child: const Text(
                          'Custom footer!',
                          style: TextStyle(color: Colors.white),
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    IconButton(
                      icon: const Icon(Icons.favorite_border,
                          color: Colors.white),
                      onPressed: () {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(content: Text('Liked!')),
                        );
                      },
                    ),
                    IconButton(
                      icon: const Icon(Icons.share, color: Colors.white),
                      onPressed: () {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(content: Text('Share!')),
                        );
                      },
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  // ═══════════════════════════════════════════════════════════════
  // HELPER DIALOGS
  // ═══════════════════════════════════════════════════════════════
  Future<void> _showUserProfile(VStoryUser user) async {
    await showModalBottomSheet(
      context: context,
      builder: (ctx) => Container(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            CircleAvatar(
              backgroundImage: NetworkImage(user.imageUrl),
              radius: 50,
            ),
            const SizedBox(height: 16),
            Text(
              user.name,
              style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            Text('User ID: ${user.id}'),
            const SizedBox(height: 24),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                _buildProfileStat('Posts', '42'),
                _buildProfileStat('Followers', '1.2K'),
                _buildProfileStat('Following', '180'),
              ],
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () => Navigator.pop(ctx),
              child: const Text('View Full Profile'),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildProfileStat(String label, String value) {
    return Column(
      children: [
        Text(value,
            style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
        Text(label, style: const TextStyle(color: Colors.grey)),
      ],
    );
  }

  Future<bool> _showStoryMenu(VStoryGroup group, VStoryItem item) async {
    final action = await showModalBottomSheet<String>(
      context: context,
      builder: (ctx) => SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ListTile(
              leading: const Icon(Icons.flag_outlined),
              title: const Text('Report'),
              onTap: () => Navigator.pop(ctx, 'report'),
            ),
            ListTile(
              leading: const Icon(Icons.volume_off_outlined),
              title: Text('Mute ${group.user.name}'),
              onTap: () => Navigator.pop(ctx, 'mute'),
            ),
            ListTile(
              leading: const Icon(Icons.block_outlined),
              title: Text('Block ${group.user.name}'),
              onTap: () => Navigator.pop(ctx, 'block'),
            ),
            ListTile(
              leading: const Icon(Icons.share_outlined),
              title: const Text('Share'),
              onTap: () => Navigator.pop(ctx, 'share'),
            ),
            const Divider(),
            ListTile(
              leading: const Icon(Icons.delete_outline, color: Colors.red),
              title: const Text('Delete', style: TextStyle(color: Colors.red)),
              onTap: () => Navigator.pop(ctx, 'delete'),
            ),
          ],
        ),
      ),
    );
    if (action != null && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Action: $action for ${group.user.name}')),
      );
    }
    return true;
  }

  // ═══════════════════════════════════════════════════════════════
  // BUILD UI
  // ═══════════════════════════════════════════════════════════════
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('V Story Viewer Demo'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(text: 'Basic', icon: Icon(Icons.auto_stories)),
            Tab(text: 'Advanced', icon: Icon(Icons.auto_awesome)),
            Tab(text: 'Custom', icon: Icon(Icons.widgets)),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: [
          _buildBasicTab(),
          _buildAdvancedTab(),
          _buildCustomTab(),
        ],
      ),
    );
  }

  Widget _buildBasicTab() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Padding(
          padding: EdgeInsets.all(16),
          child: Text('Basic Stories',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
        ),
        VStoryCircleList(
          storyGroups: _basicStories,
          circleConfig: const VStoryCircleConfig(
            unseenColor: Colors.green,
            seenColor: Colors.grey,
            ringWidth: 4,
            segmentGap: 0.2,
          ),
          onUserTap: _openBasicViewer,
        ),
        const Divider(),
        Expanded(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: const [
              _FeatureCard(
                  icon: Icons.image,
                  title: 'Image Stories',
                  description:
                      'Sarah Photos - Multiple images with custom durations'),
              _FeatureCard(
                  icon: Icons.videocam,
                  title: 'Video Stories',
                  description:
                      'Mike Videos - Auto-duration from video metadata'),
              _FeatureCard(
                  icon: Icons.text_fields,
                  title: 'Text Stories',
                  description:
                      'Emma Quotes - Text with custom styles and colors'),
              _FeatureCard(
                  icon: Icons.mic,
                  title: 'Voice Stories',
                  description: 'DJ Alex - Audio playback with seek slider'),
              _FeatureCard(
                  icon: Icons.shuffle,
                  title: 'Mixed Content',
                  description:
                      'Chris Mix - Combined types, partial seen state'),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildAdvancedTab() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Padding(
          padding: EdgeInsets.all(16),
          child: Text('Advanced Features',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
        ),
        VStoryCircleList(
          storyGroups: _advancedStories,
          circleConfig: const VStoryCircleConfig(
            unseenColor: Colors.amber,
            seenColor: Colors.grey,
            ringWidth: 5,
            segmentGap: 0.15,
          ),
          onUserTap: _openAdvancedViewer,
        ),
        const Divider(),
        Expanded(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              const _FeatureCard(
                icon: Icons.layers,
                title: 'Overlay Stories',
                description: 'Captions, watermarks, location tags, hashtags',
              ),
              const _FeatureCard(
                icon: Icons.format_color_text,
                title: 'Rich Text',
                description: 'TextSpan with gradients, custom text builders',
              ),
              const _FeatureCard(
                icon: Icons.check_circle,
                title: 'All Seen',
                description: 'Grey ring when all stories viewed',
              ),
              const SizedBox(height: 24),
              const Text('Quick Actions',
                  style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 12),
              Wrap(
                spacing: 8,
                runSpacing: 8,
                children: [
                  ActionChip(
                    avatar: const Icon(Icons.visibility_off, size: 18),
                    label: const Text('Minimal UI'),
                    onPressed: _openMinimalViewer,
                  ),
                  ActionChip(
                    avatar: const Icon(Icons.dashboard_customize, size: 18),
                    label: const Text('Custom Header/Footer'),
                    onPressed: _openCustomHeaderViewer,
                  ),
                ],
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildCustomTab() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Padding(
          padding: EdgeInsets.all(16),
          child: Text('Custom Stories (VCustomStory)',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
        ),
        VStoryCircleList(
          storyGroups: _customStories,
          circleConfig: const VStoryCircleConfig(
            unseenColor: Colors.purple,
            seenColor: Colors.grey,
            ringWidth: 4,
            segmentGap: 0.25,
          ),
          onUserTap: _openCustomViewer,
        ),
        const Divider(),
        Expanded(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              const _FeatureCard(
                icon: Icons.poll,
                title: 'Interactive Poll',
                description: 'Poll Demo - Tap options to vote, see results',
              ),
              const _FeatureCard(
                icon: Icons.timer,
                title: 'Countdown Timer',
                description: 'Countdown - Live countdown with pause support',
              ),
              const _FeatureCard(
                icon: Icons.gradient,
                title: 'Animated Background',
                description: 'Animated BG - Smooth gradient animation',
              ),
              const _FeatureCard(
                icon: Icons.shopping_bag,
                title: 'Product Showcase',
                description: 'Shop Now - E-commerce story with CTA button',
              ),
              const SizedBox(height: 16),
              Card(
                color: Colors.purple.withAlpha(25),
                child: const Padding(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text('VCustomStory',
                          style: TextStyle(fontWeight: FontWeight.bold)),
                      SizedBox(height: 8),
                      Text(
                        'Use VCustomStory for:\n'
                        '• Interactive polls/quizzes\n'
                        '• Countdown timers\n'
                        '• Lottie animations\n'
                        '• 3D models\n'
                        '• Live data\n'
                        '• Any custom widget!',
                        style: TextStyle(fontSize: 13),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

// ═══════════════════════════════════════════════════════════════
// CUSTOM STORY CONTENT WIDGETS
// ═══════════════════════════════════════════════════════════════
class _PollStoryContent extends StatefulWidget {
  const _PollStoryContent();
  @override
  State<_PollStoryContent> createState() => _PollStoryContentState();
}

class _PollStoryContentState extends State<_PollStoryContent> {
  int? _selectedOption;
  final _votes = [42, 28, 15, 10];
  @override
  Widget build(BuildContext context) {
    final totalVotes = _votes.reduce((a, b) => a + b);
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
        ),
      ),
      child: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text(
                'What\'s your favorite Flutter feature?',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 32),
              ..._buildPollOptions(totalVotes),
              if (_selectedOption != null) ...[
                const SizedBox(height: 24),
                Text(
                  'Total votes: $totalVotes',
                  style: const TextStyle(color: Colors.white70),
                ),
              ],
            ],
          ),
        ),
      ),
    );
  }

  List<Widget> _buildPollOptions(int totalVotes) {
    final options = [
      'Hot Reload',
      'Widget System',
      'Cross-Platform',
      'Performance'
    ];
    return List.generate(options.length, (index) {
      final isSelected = _selectedOption == index;
      final hasVoted = _selectedOption != null;
      final percentage =
          hasVoted ? (_votes[index] / totalVotes * 100).round() : 0;
      return Padding(
        padding: const EdgeInsets.only(bottom: 12),
        child: GestureDetector(
          onTap: _selectedOption == null
              ? () => setState(() => _selectedOption = index)
              : null,
          child: Container(
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.white.withValues(alpha: isSelected ? 0.3 : 0.15),
              borderRadius: BorderRadius.circular(12),
              border:
                  isSelected ? Border.all(color: Colors.white, width: 2) : null,
            ),
            child: Row(
              children: [
                Expanded(
                  child: Text(
                    options[index],
                    style: const TextStyle(color: Colors.white, fontSize: 16),
                  ),
                ),
                if (hasVoted)
                  Text(
                    '$percentage%',
                    style: TextStyle(
                      color: Colors.white,
                      fontWeight:
                          isSelected ? FontWeight.bold : FontWeight.normal,
                    ),
                  ),
              ],
            ),
          ),
        ),
      );
    });
  }
}

class _CountdownStoryContent extends StatefulWidget {
  final bool isPaused;
  final void Function(Duration? duration) onLoaded;
  const _CountdownStoryContent(
      {required this.isPaused, required this.onLoaded});
  @override
  State<_CountdownStoryContent> createState() => _CountdownStoryContentState();
}

class _CountdownStoryContentState extends State<_CountdownStoryContent> {
  late Timer _timer;
  int _secondsRemaining = 10;
  bool _loaded = false;
  @override
  void initState() {
    super.initState();
    _startTimer();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!_loaded) {
        _loaded = true;
        widget.onLoaded(Duration(seconds: _secondsRemaining));
      }
    });
  }

  void _startTimer() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (!widget.isPaused && _secondsRemaining > 0) {
        setState(() => _secondsRemaining--);
      }
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
        ),
      ),
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'COMING SOON',
              style: TextStyle(
                color: Colors.white54,
                fontSize: 16,
                letterSpacing: 4,
              ),
            ),
            const SizedBox(height: 24),
            Container(
              width: 180,
              height: 180,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                border: Border.all(color: Colors.amber, width: 4),
              ),
              child: Center(
                child: Text(
                  '$_secondsRemaining',
                  style: const TextStyle(
                    color: Colors.amber,
                    fontSize: 72,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
            const SizedBox(height: 24),
            const Text(
              'New Feature Launch',
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 8),
            Text(
              widget.isPaused ? 'PAUSED' : 'Get ready!',
              style: TextStyle(
                color: widget.isPaused ? Colors.amber : Colors.white54,
                fontSize: 16,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _AnimatedGradientContent extends StatefulWidget {
  final bool isPaused;
  const _AnimatedGradientContent({required this.isPaused});
  @override
  State<_AnimatedGradientContent> createState() =>
      _AnimatedGradientContentState();
}

class _AnimatedGradientContentState extends State<_AnimatedGradientContent>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 3),
      vsync: this,
    )..repeat(reverse: true);
  }

  @override
  void didUpdateWidget(covariant _AnimatedGradientContent oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.isPaused) {
      _controller.stop();
    } else {
      _controller.repeat(reverse: true);
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: [
                Color.lerp(const Color(0xFFEC4899), const Color(0xFF6366F1),
                    _controller.value)!,
                Color.lerp(const Color(0xFF6366F1), const Color(0xFF10B981),
                    _controller.value)!,
                Color.lerp(const Color(0xFF10B981), const Color(0xFFF59E0B),
                    _controller.value)!,
              ],
            ),
          ),
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.auto_awesome, size: 64, color: Colors.white),
                const SizedBox(height: 24),
                const Text(
                  'Animated Background',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 28,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  widget.isPaused
                      ? 'Animation Paused'
                      : 'Smooth gradient transitions',
                  style: const TextStyle(color: Colors.white70, fontSize: 16),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

class _ProductShowcaseContent extends StatelessWidget {
  const _ProductShowcaseContent();
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [Color(0xFF1F2937), Color(0xFF111827)],
        ),
      ),
      child: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(24),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.purple.withValues(alpha: 0.3),
                      blurRadius: 30,
                      spreadRadius: 5,
                    ),
                  ],
                ),
                child: const Icon(Icons.headphones,
                    size: 100, color: Colors.purple),
              ),
              const SizedBox(height: 32),
              const Text(
                'Premium Headphones',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 28,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'Wireless • Noise Cancelling • 40h Battery',
                style: TextStyle(color: Colors.white54, fontSize: 14),
              ),
              const SizedBox(height: 24),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    '\$299',
                    style: TextStyle(
                      color: Colors.grey.shade500,
                      fontSize: 18,
                      decoration: TextDecoration.lineThrough,
                    ),
                  ),
                  const SizedBox(width: 12),
                  const Text(
                    '\$199',
                    style: TextStyle(
                      color: Colors.green,
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                decoration: BoxDecoration(
                  color: Colors.red,
                  borderRadius: BorderRadius.circular(20),
                ),
                child: const Text(
                  'LIMITED TIME OFFER',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ═══════════════════════════════════════════════════════════════
// FEATURE CARD WIDGET
// ═══════════════════════════════════════════════════════════════
class _FeatureCard extends StatelessWidget {
  final IconData icon;
  final String title;
  final String description;
  const _FeatureCard({
    required this.icon,
    required this.title,
    required this.description,
  });
  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: ListTile(
        leading: CircleAvatar(child: Icon(icon)),
        title: Text(title),
        subtitle: Text(description),
      ),
    );
  }
}
4
likes
150
points
178
downloads

Publisher

verified publishervchatsdk.com

Weekly Downloads

A high-performance Flutter story viewer like WhatsApp/Instagram. Supports image, video, text, voice stories with 3D cube transitions.

Repository (GitHub)
View/report issues

Topics

#story-viewer #instagram-stories #whatsapp-stories #story #v-story-viewer

Documentation

Documentation
API reference

License

MIT (license)

Dependencies

audioplayers, emoji_picker_flutter, extended_image, flutter, flutter_cache_manager, timeago, video_player

More

Packages that depend on v_story_viewer