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

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

v_story_viewer #

A high-performance Flutter story viewer package inspired by WhatsApp and Instagram stories. Supports images, videos, text, voice, and custom content with beautiful 3D cube transitions.

pub package license

Showcase #

Story Circle List Image Story Video Story
Story List Image Story Video Story
Text Story Voice Story 3D Cube Transition
Text Story Voice Story Cube Transition

Features #

  • Multiple Story Types: Image, Video, Text, Voice, and fully Custom content
  • 3D Cube Transition: Smooth cube-style page transitions between users
  • Auto-Progress: Automatic advancement with synced progress bar
  • Gesture Controls: Tap, long-press, swipe navigation with RTL support
  • Keyboard Support: Arrow keys, Space, Escape for desktop/web
  • Caching: Built-in video/audio caching for mobile platforms
  • Preloading: Automatic preloading of next media content
  • 24-Hour Expiry: Stories automatically expire after 24 hours
  • Full Customization: Custom headers, footers, progress bars, and content builders
  • Internationalization: Configurable texts for all UI elements
  • Cross-Platform: Android, iOS, Web, Windows, Linux, macOS

Installation #

Add to your pubspec.yaml:

dependencies:
  v_story_viewer: ^2.0.0

Then run:

flutter pub get

Quick Start #

1. Import the Package #

import 'package:v_story_viewer/v_story_viewer.dart';

2. Create Story Data #

final storyGroups = [
  VStoryGroup(
    user: VStoryUser(
      id: 'user_1',
      name: 'John Doe',
      imageUrl: 'https://example.com/avatar.jpg',
    ),
    stories: [
      VImageStory(
        url: 'https://example.com/story1.jpg',
        createdAt: DateTime.now(),
        isSeen: false,
      ),
      VVideoStory(
        url: 'https://example.com/story2.mp4',
        createdAt: DateTime.now(),
        isSeen: false,
      ),
    ],
  ),
];

3. Display Story Circles #

VStoryCircleList(
  storyGroups: storyGroups,
  onUserTap: (group, index) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => VStoryViewer(
          storyGroups: storyGroups,
          initialGroupIndex: index,
          onClose: (_, __) => Navigator.pop(context),
          onComplete: (_, __) => Navigator.pop(context),
        ),
      ),
    );
  },
)

Story Types #

VImageStory #

Display static images from URL or local file.

VImageStory(
  url: 'https://example.com/image.jpg',      // Network URL
  // OR
  filePath: '/path/to/local/image.jpg',      // Local file
  caption: 'Beautiful sunset!',               // Optional caption
  duration: Duration(seconds: 7),             // Default: 5 seconds
  createdAt: DateTime.now(),
  isSeen: false,
  overlayBuilder: (context) => Positioned(    // Optional overlay
    bottom: 100,
    child: Text('Watermark', style: TextStyle(color: Colors.white)),
  ),
)

VVideoStory #

Auto-playing video with duration detection.

VVideoStory(
  url: 'https://example.com/video.mp4',
  // OR
  filePath: '/path/to/local/video.mp4',
  caption: 'Check this out!',
  duration: null,  // null = uses video's actual duration
  createdAt: DateTime.now(),
  isSeen: false,
)

Features:

  • Auto-play when story becomes active
  • Auto-pause on navigation, long-press, or app background
  • Mute/unmute toggle (web starts muted for autoplay compliance)
  • Optional caching on mobile platforms

VTextStory #

Text content with solid background color.

// Plain text
VTextStory(
  text: 'Hello World!',
  backgroundColor: Colors.purple,
  textStyle: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
  duration: Duration(seconds: 5),
  createdAt: DateTime.now(),
  isSeen: false,
)

// Rich text with TextSpan
VTextStory(
  text: '',
  richText: TextSpan(
    children: [
      TextSpan(text: 'Hello ', style: TextStyle(color: Colors.white)),
      TextSpan(text: 'World', style: TextStyle(color: Colors.yellow)),
    ],
  ),
  backgroundColor: Colors.blue,
  createdAt: DateTime.now(),
  isSeen: false,
)

// Custom text builder (e.g., for Markdown)
VTextStory(
  text: '# Hello\n**Bold** and *italic*',
  textBuilder: (context, text) => MarkdownBody(data: text),
  backgroundColor: Colors.black,
  createdAt: DateTime.now(),
  isSeen: false,
)

VVoiceStory #

Audio with visual progress slider.

VVoiceStory(
  url: 'https://example.com/voice.mp3',
  // OR
  filePath: '/path/to/local/audio.mp3',
  backgroundColor: Colors.indigo,
  caption: 'Voice message',
  duration: null,  // null = uses audio's actual duration
  createdAt: DateTime.now(),
  isSeen: false,
)

VCustomStory #

Fully custom content with lifecycle control.

VCustomStory(
  contentBuilder: (context, isPaused, isMuted, onLoaded, onError) {
    return Lottie.network(
      'https://example.com/animation.json',
      repeat: false,
      animate: !isPaused,
      onLoaded: (composition) {
        onLoaded(composition.duration);  // MUST call when ready
      },
      errorBuilder: (_, __, error) {
        onError(error ?? 'Failed to load');
        return Icon(Icons.error);
      },
    );
  },
  caption: 'Custom animation',
  createdAt: DateTime.now(),
  isSeen: false,
)

Important: Your contentBuilder MUST:

  1. Call onLoaded(duration) when content is ready (progress bar waits for this)
  2. Call onError(error) if loading fails
  3. Respond to isPaused to pause/resume animations
  4. Respect isMuted for any audio content

Configuration #

VStoryConfig #

Full configuration for the story viewer.

VStoryViewer(
  storyGroups: groups,
  config: VStoryConfig(
    // Colors
    unseenGradient: [Colors.purple, Colors.pink],
    seenColor: Colors.grey,
    progressColor: Colors.white,
    progressBackgroundColor: Colors.white.withOpacity(0.3),

    // Timing
    defaultDuration: Duration(seconds: 5),
    networkTimeout: 30,
    maxRetries: 5,

    // Caching (mobile only)
    enableCaching: true,
    maxCacheSize: 500 * 1024 * 1024,  // 500MB
    maxCacheAge: Duration(days: 7),
    maxCacheObjects: 100,
    enablePreloading: true,

    // Visibility toggles
    showHeader: true,
    showProgressBar: true,
    showBackButton: true,
    showUserInfo: true,
    showMenuButton: true,
    showCloseButton: true,
    showReplyField: true,
    showEmojiButton: true,
    autoPauseOnBackground: true,
    hideStatusBar: true,

    // Internationalization
    texts: VStoryTexts(
      replyHint: 'Send message...',
      closeLabel: 'Close',
      // ... more texts
    ),
  ),
)

Visibility Flags #

Flag Default Description
showHeader true Show/hide entire header section
showProgressBar true Show/hide progress segments
showBackButton true Show/hide back arrow
showUserInfo true Show/hide avatar + name + time
showMenuButton true Show/hide three dots menu
showCloseButton true Show/hide X button
showReplyField true Show/hide reply input
showEmojiButton true Show/hide emoji picker
autoPauseOnBackground true Auto-pause when app backgrounds
hideStatusBar true Immersive mode (mobile)

VStoryCircleConfig #

Configuration for story circle appearance.

VStoryCircleList(
  storyGroups: groups,
  circleConfig: VStoryCircleConfig(
    size: 72,                         // Total circle size
    unseenColor: Color(0xFF4CAF50),   // Green for unseen
    seenColor: Color(0xFF808080),     // Gray for seen
    ringWidth: 3.0,                   // Ring thickness
    ringPadding: 3.0,                 // Gap between ring and avatar
    segmentGap: 0.08,                 // 8% gap between segments
  ),
)

VStoryTexts (Internationalization) #

VStoryTexts(
  replyHint: 'Send message...',
  pauseLabel: 'Pause',
  playLabel: 'Play',
  muteLabel: 'Mute',
  unmuteLabel: 'Unmute',
  closeLabel: 'Close',
  nextLabel: 'Next',
  previousLabel: 'Previous',
  sendLabel: 'Send',
  viewedLabel: 'Viewed',
  errorLoadingMedia: 'Failed to load media',
  tapToRetry: 'Tap to retry',
  backLabel: 'Back',
  menuLabel: 'Menu',
  emojiLabel: 'Emoji',
  keyboardLabel: 'Keyboard',
  noRecentEmojis: 'No recent emojis',
  searchEmoji: 'Search emoji',
)

Custom Builders #

Replace any component with your own implementation.

Custom Header #

VStoryConfig(
  headerBuilder: (context, user, item, onClose) => Row(
    children: [
      CircleAvatar(backgroundImage: NetworkImage(user.imageUrl)),
      SizedBox(width: 8),
      Text(user.name, style: TextStyle(color: Colors.white)),
      Spacer(),
      IconButton(
        icon: Icon(Icons.close, color: Colors.white),
        onPressed: onClose,
      ),
    ],
  ),
)
VStoryConfig(
  footerBuilder: (context, group, item) => Padding(
    padding: EdgeInsets.all(16),
    child: Row(
      children: [
        Icon(Icons.favorite_border, color: Colors.white),
        SizedBox(width: 16),
        Icon(Icons.share, color: Colors.white),
      ],
    ),
  ),
)

Custom Progress Bar #

VStoryConfig(
  progressBuilder: (context, count, index, progress) => Row(
    children: List.generate(count, (i) => Expanded(
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: 2),
        child: LinearProgressIndicator(
          value: i < index ? 1.0 : (i == index ? progress : 0.0),
          backgroundColor: Colors.grey,
          valueColor: AlwaysStoppedAnimation(Colors.white),
        ),
      ),
    )),
  ),
)

Custom Content Builders #

VStoryConfig(
  // Custom image loading
  imageBuilder: (context, story, onLoaded, onError) {
    return CachedNetworkImage(
      imageUrl: story.url!,
      fit: BoxFit.contain,
      imageBuilder: (_, provider) {
        onLoaded();
        return Image(image: provider);
      },
      errorWidget: (_, __, error) {
        onError(error);
        return Icon(Icons.error);
      },
    );
  },

  // Custom video player
  videoBuilder: (context, story, isPaused, isMuted, onLoaded, onError) {
    return BetterPlayer.network(
      story.url!,
      betterPlayerConfiguration: BetterPlayerConfiguration(
        autoPlay: !isPaused,
      ),
    );
  },

  // Custom loading widget
  loadingBuilder: (context) => Center(
    child: SpinKitWave(color: Colors.white),
  ),

  // Custom error widget
  errorBuilder: (context, error, retry) => Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Icon(Icons.error, color: Colors.red),
      Text('Failed: $error'),
      ElevatedButton(onPressed: retry, child: Text('Retry')),
    ],
  ),
)

Callbacks #

VStoryViewer Callbacks #

VStoryViewer(
  storyGroups: groups,

  // Called when all stories viewed
  onComplete: (group, item) => Navigator.pop(context),

  // Called when close button tapped
  onClose: (group, item) => Navigator.pop(context),

  // Called when story becomes visible (track seen state here)
  onStoryViewed: (group, item) {
    markAsSeen(group.user.id, item);
  },

  // Called on pause (long press, app background)
  onPause: (group, item) => pauseBackgroundMusic(),

  // Called on resume
  onResume: (group, item) => resumeBackgroundMusic(),

  // Called when story skipped via tap
  onSkip: (group, item) => analytics.logSkip(item),

  // Called when reply submitted
  onReply: (group, item, text) {
    sendReply(group.user.id, text);
  },

  // Called on progress updates (~60fps, throttled)
  onProgress: (group, item, progress) {
    print('Progress: ${(progress * 100).toInt()}%');
  },

  // Called when content fails to load
  onError: (group, item, error) {
    logError(error);
  },

  // Called when content loaded
  onLoad: (group, item) {
    preloadNextContent();
  },

  // Called on swipe up gesture
  onSwipeUp: (group, item) {
    openLinkInBrowser();
  },

  // Called when user avatar/name tapped (async, pauses story)
  onUserTap: (group, item) async {
    await Navigator.push(context, UserProfileRoute(group.user));
    return true;  // Return true to resume, false to stay paused
  },

  // Called when menu button tapped (async, pauses story)
  onMenuTap: (group, item) async {
    final action = await showModalBottomSheet(...);
    if (action == 'report') reportStory(item);
    return true;  // Return true to resume
  },
)

Gesture Controls #

Gesture Action
Tap left side Previous story
Tap right side Next story
Long press Pause story
Swipe left/right Navigate between users (3D cube)
Swipe down Close viewer
Swipe up Triggers onSwipeUp callback

Keyboard Controls (Desktop/Web) #

Key Action
Left Arrow Previous story
Right Arrow Next story
Space Toggle pause/play
Escape Close viewer

Story Expiry #

Stories automatically expire 24 hours after createdAt:

final story = VImageStory(
  url: 'https://example.com/image.jpg',
  createdAt: DateTime.now().subtract(Duration(hours: 25)),
  isSeen: false,
);

print(story.isExpired);  // true - story is older than 24 hours

The viewer automatically filters expired stories. Use VStoryGroup.validStories to get non-expired stories.

Tracking Seen State #

The viewer does NOT automatically mark stories as seen. Track it yourself:

VStoryViewer(
  storyGroups: groups,
  onStoryViewed: (group, item) {
    // Update in your backend/database
    storyService.markAsSeen(
      userId: group.user.id,
      storyId: item.hashCode.toString(),
    );

    // Update local state
    setState(() {
      // Update isSeen in your data model
    });
  },
)

Caching #

Video and audio caching is supported on mobile platforms (Android/iOS):

VStoryConfig(
  enableCaching: true,                    // Enable caching
  maxCacheSize: 500 * 1024 * 1024,        // 500MB max cache size
  maxCacheAge: Duration(days: 7),         // Cache expires after 7 days
  maxCacheObjects: 100,                   // Max 100 cached files
  enablePreloading: true,                 // Preload next video/audio
)

Note: Web and desktop platforms always stream directly from URL (no caching).

RTL Support #

The viewer automatically adapts to RTL (right-to-left) text direction:

  • Tap zones are mirrored (left = next, right = previous)
  • Progress bar direction is preserved
  • Text alignment follows system settings

Complete Example #

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

class StoryPage extends StatefulWidget {
  @override
  State<StoryPage> createState() => _StoryPageState();
}

class _StoryPageState extends State<StoryPage> {
  late List<VStoryGroup> storyGroups;

  @override
  void initState() {
    super.initState();
    storyGroups = _createStoryData();
  }

  List<VStoryGroup> _createStoryData() {
    return [
      VStoryGroup(
        user: VStoryUser(
          id: 'user_1',
          name: 'Alice',
          imageUrl: 'https://i.pravatar.cc/150?u=alice',
        ),
        stories: [
          VImageStory(
            url: 'https://picsum.photos/1080/1920?random=1',
            caption: 'Beautiful day!',
            createdAt: DateTime.now().subtract(Duration(hours: 2)),
            isSeen: false,
          ),
          VVideoStory(
            url: 'https://sample-videos.com/video321/mp4/720/big_buck_bunny_720p_1mb.mp4',
            caption: 'Fun video',
            createdAt: DateTime.now().subtract(Duration(hours: 1)),
            isSeen: false,
          ),
        ],
      ),
      VStoryGroup(
        user: VStoryUser(
          id: 'user_2',
          name: 'Bob',
          imageUrl: 'https://i.pravatar.cc/150?u=bob',
        ),
        stories: [
          VTextStory(
            text: 'Hello World!',
            backgroundColor: Colors.deepPurple,
            textStyle: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
            createdAt: DateTime.now(),
            isSeen: false,
          ),
        ],
      ),
    ];
  }

  void _openViewer(int index) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => VStoryViewer(
          storyGroups: storyGroups,
          initialGroupIndex: index,
          config: VStoryConfig(
            showReplyField: true,
            progressColor: Colors.white,
          ),
          onStoryViewed: (group, item) {
            print('Viewed: ${group.user.name}');
          },
          onReply: (group, item, text) {
            print('Reply to ${group.user.name}: $text');
          },
          onComplete: (_, __) => Navigator.pop(context),
          onClose: (_, __) => Navigator.pop(context),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Stories')),
      body: Column(
        children: [
          VStoryCircleList(
            storyGroups: storyGroups,
            showUserName: true,
            circleConfig: VStoryCircleConfig(
              size: 80,
              unseenColor: Colors.green,
              seenColor: Colors.grey,
            ),
            onUserTap: (group, index) => _openViewer(index),
          ),
          // Rest of your content
        ],
      ),
    );
  }
}

Dependencies #

Platform Support #

Platform Support
Android Full support
iOS Full support
Web Full support (no caching)
Windows Full support (no caching)
Linux Full support (no caching)
macOS Full support (no caching)

License #

MIT License - see LICENSE for details.

Author #

Hatem Ragap

Contributing #

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Issues #

Found a bug or have a feature request? Please open an issue on the GitHub repository.

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