v_story_viewer 2.0.1
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.
Showcase #
| Story Circle List | Image Story | Video Story |
|---|---|---|
![]() |
![]() |
![]() |
| Text Story | Voice Story | 3D 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:
- Call
onLoaded(duration)when content is ready (progress bar waits for this) - Call
onError(error)if loading fails - Respond to
isPausedto pause/resume animations - Respect
isMutedfor 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,
),
],
),
)
Custom Footer #
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 #
- video_player - Video playback
- extended_image - Image loading with state callbacks
- audioplayers - Audio playback
- timeago - Relative time formatting
- emoji_picker_flutter - Emoji picker for replies
- flutter_cache_manager - Media caching
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
- Email: hatemragapdev@gmail.com
- GitHub: github.com/hatemragap
Contributing #
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Issues #
Found a bug or have a feature request? Please open an issue on the GitHub repository.





