flutter_story_kit
Lightweight Instagram-style stories for Flutter: images, native videos, YouTube links, Facebook embeds, a horizontal tray, media preload, and callback-based call-to-action buttons.
The package stays small on purpose. It does not fetch your API, does not navigate to offers, and does not depend on GetX, Sizer, or Sentry. You own networking and navigation; the widget owns playback, gestures, and progress.

Features
- Image, video, YouTube, and Facebook slides
- Multi-slide stories with animated progress bars
- Tap left / right, hold to pause, swipe between stories
- Horizontal tray with seen / unseen gradient rings
- Optional video + image preloader
- CTA button with a typed
StoryAction(link, offer, category, partner, …) Story.fromMapcompatible with typical{ items: [...] }JSON- First-class
YoutubeLinkhelpers (id, embed URL, thumbnail, watch URL)
Install
dependencies:
flutter_story_kit: ^0.1.3
Then:
flutter pub get
Platform setup
video_player and webview_flutter need the usual platform config:
Android (android/app/src/main/AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET"/>
iOS (ios/Runner/Info.plist) — allow HTTP only if you really need it:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
YouTube and Facebook slides run inside a WebView. Test them on a real device; some embeds are blocked in simulators.
Quick start
import 'package:flutter/material.dart';
import 'package:flutter_story_kit/flutter_story_kit.dart';
final stories = [
Story(
id: '1',
title: 'Studio',
thumbnailUrl: 'https://picsum.photos/200',
media: [
StoryMedia.image('https://picsum.photos/1080/1920', duration: Duration(seconds: 5)),
StoryMedia.video('https://example.com/clip.mp4', duration: Duration(seconds: 12)),
StoryMedia.youtube('https://youtu.be/dQw4w9WgXcQ', duration: Duration(seconds: 15)),
],
action: StoryAction(type: 'link', url: 'https://flutter.dev'),
),
];
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: StoryTray(
stories: stories,
onStoryTap: (index) {
// Mark as seen on your backend here.
},
),
),
);
}
}
StoryTray opens StoryViewer for you. For full control (preload, seen callback, CTA):
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => StoryViewer(
stories: stories,
initialIndex: index,
preloader: preloader,
onSeen: (story) {
// POST /stories/:id/seen
},
onAction: (story, media, action) async {
switch (action.normalizedType) {
case 'link':
case 'weblink':
// open action.url in your webview
break;
case 'offre':
// open your offer page with action.relatedId
break;
}
},
),
),
);
YouTube links
YouTube is a first-class media kind, not a special case of Facebook. Pass any public URL:
StoryMedia.youtube('https://www.youtube.com/watch?v=VIDEO_ID');
StoryMedia.youtube('https://youtu.be/VIDEO_ID');
StoryMedia.youtube('https://www.youtube.com/shorts/VIDEO_ID');
Or use the helpers directly:
final id = YoutubeLink.videoId(url); // 'dQw4w9WgXcQ'
final embed = YoutubeLink.embedUrl(url); // autoplay + mute + playsinline
final thumb = YoutubeLink.thumbnailUrl(id!); // hqdefault.jpg
final watch = YoutubeLink.watchUrl(id);
Embeds mute by default so mobile autoplay is allowed. Pass mute: false if you want sound (the user may have to tap).
Models
Story(
id: '42',
title: 'Brand',
thumbnailUrl: '...',
seen: false,
media: [StoryMedia.image('...'), StoryMedia.youtube('...')],
action: StoryAction(type: 'weblink', url: 'https://...'),
extra: {'adult_content': false}, // anything your app still needs
)
Parse a backend payload:
final stories = storiesFromResponse(jsonDecode(response.body));
// or
final story = Story.fromMap(map);
fromMap understands both:
{ "video_url": "...", "duration": 5 }{ "items": [{ "media_type": "youtube", "media_url": "...", "duration": 15000 }] }
App-specific nested objects (offre, categorie, partenaire) are stored in Story.extra. CTA ids land on StoryAction.relatedId / StoryAction.data.
Preload
final preloader = StoryPreloader();
await preloader.preload(stories, context: context);
// later, when leaving the home screen
await preloader.disposeAll();
Only native videos and images are warmed up. YouTube / Facebook cannot be preloaded with video_player.
Architecture (why it is small)
| This package | Your app |
|---|---|
| Playback, gestures, progress | API (http, auth, tokens) |
| Models + JSON parsing | Mapping to your domain if needed |
| YouTube / Facebook embed URLs | Opening offers, categories, webviews |
| Optional preloader | When to preload / dispose |
That split is what keeps the package publishable and reusable. Heavy app services (Sentry, GetX, your baseUrl) stay out.
Publish to pub.dev
Source lives at github.com/Moettaz/oui_stories. From this folder:
dart pub publish --dry-run
dart pub publish
dry-run lists files that will be uploaded and the static analysis score. Fix warnings before the real publish. Packages cannot be deleted after publish, only discontinued, so start at 0.1.0.
Useful local checks:
dart format .
flutter analyze
flutter test
Example
See example/ for a runnable demo with an image, a video, and a YouTube slide.
Author
Created by Moatez Zaghdoudi.
- GitHub: Moettaz
- Repository: github.com/Moettaz/oui_stories
License
MIT © Moatez Zaghdoudi
Libraries
- flutter_story_kit
- Lightweight Instagram-style stories for Flutter.