FlutterStoryPresenter

FlutterStoryPresenter

This Flutter package makes it easy to create story and news views like popular social media apps with just a few lines of code! πŸ“±βœ¨ It's loaded with features for customizing and managing stories, perfect for showcasing stories inside your awesome app.

✨ Demo

Video Story Image Story Web Story Custom Story 1 Custom Story 2 Audio Story

πŸŽ₯ Video Demo

https://devkrest.com/github/flutter-story-presenter-demo.mp4

🀌🏻 Features

πŸ”Ή Supported Media Types: Images, Videos, Text, Web & Custom

πŸ”Ή Custom Loader & Thumbnails: Add your custom thumbnails or loaders while loading the story

πŸ”Ή Option to Cache: Image, Video, and show Loading Widgets

πŸ”Ή Accurate Animated Progress Bar

πŸ”Ή Color Customization: For Progress Bars

πŸ”Ή Header & Footer Widgets : For adding Profiles & Read more or Textfields

πŸ”Ή Controls: Pause, Resume, Next, Previous, Jump to, Mute, Unmute

πŸ”Ή Gestures: Tap, Right Tap, Left Tap, Slide or Drag Down

πŸ”Ή Customizable Widget: Display your own widgets as Story

πŸ”Ή Callbacks: Receive different callbacks based on the type of story item for handling state management.

βš™οΈ Installation

Add flutter_story_presenter to your pubspec.yaml dependencies. And Import it as

import 'package:flutter_story_presenter/flutter_story_presenter.dart';

πŸ”­ Guide to use

For a detailed view and more examples, check out example/main.dart.

Use FlutterStoryPresenter to display stories on any screen. It accepts a list of StoryItem, each containing URLs for displaying images, videos, and web pages.

FlutterStoryPresenter(
    controller : FlutterStoryController(),
    items: [
        /// For Image Story Item
        StoryItem(
            url:'https://picsum.photos/1000/1000',
            type: StoryItemType.image,
        ),
        /// For Video Story Item
        StoryItem(
            url:'https://videos.pexels.com/video-files/7297870/7297870-hd_1080_1920_30fps.mp4',
            type: StoryItemType.video,
        ),
        /// For Text Story Item
        StoryItem(
            url:'We won this tournament ... Many more to go',
            type: StoryItemType.text,
        ),
        /// For Web Story Item
        StoryItem(
            url:'https://devkrest.com/',
            type: StoryItemType.web,
        ),
        /// For Custom Story Item
        StoryItem(
            customWidget: YourOwnBakedStoryWidget(),
            type: StoryItemType.custom,
        ),
    ]
)

FlutterStoryPresenter Properties

Here are some of the properties you may have look for further customisations & callbacks.

/// List of StoryItem objects to display in the story view.
final List<StoryItem> items;

/// Controller for managing the current playing media.
final FlutterStoryController? flutterStoryController;

/// Callback function triggered whenever the story changes or the user navigates to the previous/next story.
final OnStoryChanged? onStoryChanged;

/// Callback function triggered when all items in the list have been played.
final OnCompleted? onCompleted;

/// Callback function triggered when the user taps on the left half of the screen.
final OnLeftTap? onLeftTap;

/// Callback function triggered when the user taps on the right half of the screen.
final OnRightTap? onRightTap;

/// Callback function triggered when user drag downs the storyview.
final OnSlideDown? onSlideDown;

/// Callback function triggered when user starts drag downs the storyview.
final OnSlideStart? onSlideStart;

/// Indicates whether the story view should restart from the beginning after all items have been played.
final bool restartOnCompleted;

/// Index to start playing the story from initially.
final int initialIndex;

/// Configuration and styling options for the story view indicator.
final StoryViewIndicatorConfig? storyViewIndicatorConfig;

/// Callback function to retrieve the VideoPlayerController when it is initialized and ready to play.
final OnVideoLoad? onVideoLoad;

/// Widget to display user profile or other details at the top of the screen.
final Widget? headerWidget;

/// Widget to display text field or other content at the bottom of the screen.
final Widget? footerWidget;

Using FlutterStoryController

FlutterStoryController is a controller class designed to manage the current story displayed on FlutterStoryPresenter. It includes various methods such as play(), pause(), jumpTo(), next(), previous(), mute(), and unMute(), allowing you to control the existing StoryItem.

final storyController = FlutterStoryController();
FlutterStoryPresenter(
    controller : storyController,
    items: [
        /// Story Item goes here
    ]
)
...
/// Somewhere in your code
storyController.pause();
storyController.play();
storyController.next();
storyController.previous();
storyController.jumpTo(2);
storyController.mute();
storyController.unMute();

Using StoryItem

The StoryItem class is used to define the individual items displayed in a FlutterStoryPresenter. Each StoryItem can represent an image, video, text, web content, or a custom widget.

const StoryItem({
  this.url,
  required this.storyItemType,
  this.thumbnail,
  this.isMuteByDefault = false,
  this.duration = const Duration(seconds: 3),
  this.storyItemSource = StoryItemSource.network,
  this.videoConfig,
  this.errorWidget,
  this.imageConfig,
  this.textConfig,
  this.webConfig,
  this.customWidget,
});

Parameters

  • url: The URL, file path, or web URL of the story item. Required unless storyItemType is custom.
  • storyItemType: The type of story item. Required. It can be an image, video, text, web content, or custom.
  • thumbnail: A widget to display as a thumbnail.
  • isMuteByDefault: A boolean indicating whether the video should be muted by default. Applicable when storyItemType is video.
  • duration: The duration for displaying the widget. Defaults to 3 seconds.
  • storyItemSource: The source type of the story item. Defaults to StoryItemSource.network.
  • videoConfig: Configuration for video stories.
  • errorWidget: A widget to display when an error occurs while loading the view.
  • imageConfig: Configuration for image stories.
  • textConfig: Configuration for text stories.
  • webConfig: Configuration for web stories.
  • customWidget: A custom widget to display fully instead of any other view. Required when storyItemType is custom.

Example

StoryItem(
  url: 'https://example.com/image.jpg',
  storyItemType: StoryItemType.image,
  storyItemSource: StoryItemSource.network,
  duration: Duration(seconds: 5),
  thumbnail: Image.network('https://example.com/thumbnail.jpg'),
  imageConfig: StoryViewImageConfig(
    fit: BoxFit.cover,
    height: 300,
    width: 300,
    progressIndicatorBuilder: (_,_,loadProgress){
      return YourImageLoaderWidget();
    }
  ),
)

StoryItem(
  url: 'https://example.com/video.mp4',
  storyItemType: StoryItemType.video,
  isMuteByDefault: true,
  storyItemSource: StoryItemSource.network,
  duration: Duration(seconds: 10),
  videoConfig: StoryViewVideoConfig(
    cacheVideo: true,
    fit: BoxFit.cover,
    height: 500,
    width: 500,
    loadingWidget: MyVideoLoadingWidget(),
    /// If you opt to cache video, 
    // it will store to user device and then played, 
    // if already cached, will play without buffer 
    // from next time
    cacheVideo: false,
    useVideoAspectRatio: false,
    videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true,),
  ),
)

StoryItem(
  storyItemType: StoryItemType.custom,
  customWidget: MyCustomWidget(),
)

Full Example

This is the full example demonstrating the usage of FlutterStoryPresenter. For more insights and detailed reference, refer to the official documentation.

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_story_presenter/flutter_story_presenter.dart';

void main() {
  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
  ));
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      themeMode: ThemeMode.dark,
      darkTheme: ThemeData.dark(),
      home: const Home(),
    );
  }
}

class Home extends StatefulWidget {
  const Home({super.key});

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  PageController pageController = PageController();

  //Story Data
  List<StoryModel> sampleStory = [
    StoryModel(
      userName: 'Kaival Patel',
      userProfile: 'https://avatars.githubusercontent.com/u/39383435?v=4',
      stories: [
        const StoryItem(
          storyItemType: StoryItemType.video,
          storyItemSource: StoryItemSource.asset,
          url: 'assets/fb8512a35d6f4b2e8917b74a048de71a.MP4',
          thumbnail: Center(
              child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CupertinoActivityIndicator(
                radius: 15,
              ),
              SizedBox(
                width: 10,
              ),
              Text('Video Loading')
            ],
          )),
          videoConfig: StoryViewVideoConfig(
            fit: BoxFit.cover,
          ),
        ),
        const StoryItem(
            storyItemType: StoryItemType.video,
            url:
                'https://videos.pexels.com/video-files/5913245/5913245-uhd_1440_2560_30fps.mp4',
            thumbnail: Center(
                child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                CupertinoActivityIndicator(
                  radius: 15,
                ),
                SizedBox(
                  width: 10,
                ),
                Text('Video Loading')
              ],
            )),
            videoConfig: StoryViewVideoConfig(
              fit: BoxFit.cover,
              height: double.infinity,
              width: double.infinity,
              loadingWidget: Center(child: CupertinoActivityIndicator()),
            )),
        StoryItem(
          storyItemType: StoryItemType.custom,
          customWidget: (p0) => TextOverlayView(
            controller: p0,
          ),
          imageConfig: StoryViewImageConfig(
            fit: BoxFit.contain,
            progressIndicatorBuilder: (p0, p1, p2) => const Center(
              child: CupertinoActivityIndicator(),
            ),
          ),
        ),
      ],
    ),
    StoryModel(
      userName: 'Lakhan P.',
      userProfile: 'https://devkrest.com/team/lakhan.png',
      stories: [
        StoryItem(
          storyItemType: StoryItemType.custom,
          duration: const Duration(seconds: 20),
          customWidget: (p0) => PostOverlayView(
            controller: p0,
          ),
          imageConfig: StoryViewImageConfig(
            fit: BoxFit.contain,
            progressIndicatorBuilder: (p0, p1, p2) => const Center(
              child: CupertinoActivityIndicator(),
            ),
          ),
        ),
        const StoryItem(
          storyItemType: StoryItemType.video,
          storyItemSource: StoryItemSource.asset,
          url: 'assets/StorySaver.net-_spindia_-Video-1718781607686.mp4',
          thumbnail: Center(
              child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CupertinoActivityIndicator(
                radius: 15,
              ),
              SizedBox(
                width: 10,
              ),
              Text('Video Loading')
            ],
          )),
          videoConfig: StoryViewVideoConfig(
            fit: BoxFit.contain,
          ),
        ),
      ],
    ),
    StoryModel(
      userName: 'Harsh P.',
      userProfile: 'https://devkrest.com/team/harsh.jpg',
      stories: [
        StoryItem(
          storyItemType: StoryItemType.web,
          url: 'https://quirkywanderer.com/web-stories/bijli-mahadev-trek/',
          duration: const Duration(seconds: 20),
          imageConfig: StoryViewImageConfig(
            fit: BoxFit.contain,
            progressIndicatorBuilder: (p0, p1, p2) => const Center(
              child: CupertinoActivityIndicator(),
            ),
          ),
        ),
        StoryItem(
          storyItemType: StoryItemType.web,
          url:
              'https://www.ndtv.com/webstories/travel/10-things-to-do-in-amritsar-from-golden-temple-visit-to-wagah-border-47',
          duration: const Duration(seconds: 20),
          imageConfig: StoryViewImageConfig(
            fit: BoxFit.contain,
            progressIndicatorBuilder: (p0, p1, p2) => const Center(
              child: CupertinoActivityIndicator(),
            ),
          ),
        ),
      ],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: PageView.builder(
        itemCount: sampleStory.length,
        physics: const NeverScrollableScrollPhysics(),
        controller: pageController,
        itemBuilder: (context, index) {
          return MyStoryView(
            storyModel: sampleStory[index],
            pageController: pageController,
          );
        },
      ),
    );
  }
}

class MyStoryView extends StatefulWidget {
  const MyStoryView({
    super.key,
    required this.storyModel,
    required this.pageController,
  });

  final StoryModel storyModel;
  final PageController pageController;

  @override
  State<MyStoryView> createState() => _MyStoryViewState();
}

class _MyStoryViewState extends State<MyStoryView> {
  late FlutterStoryController controller;

  @override
  void initState() {
    controller = FlutterStoryController();
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    final storyViewIndicatorConfig = StoryViewIndicatorConfig(
      height: 4,
      activeColor: Colors.white,
      backgroundCompletedColor: Colors.white,
      backgroundDisabledColor: Colors.white.withOpacity(0.5),
      horizontalGap: 1,
      borderRadius: 1.5,
    );

    return FlutterStoryView(
      flutterStoryController: controller,
      items: widget.storyModel.stories,
      footerWidget: MessageBoxView(controller: controller),
      storyViewIndicatorConfig: storyViewIndicatorConfig,
      initialIndex: 0,
      headerWidget: ProfileView(storyModel: widget.storyModel),
      onStoryChanged: (p0) {
        // For Custom type story need to play custom view manually
        if (widget.storyModel.stories[p0].storyItemType ==
            StoryItemType.custom) {
          controller.playCustomWidget();
        }
      },
      onPreviousCompleted: () async {
        await widget.pageController.previousPage(
            duration: const Duration(milliseconds: 500),
            curve: Curves.decelerate);
      },
      onCompleted: () async {
        await widget.pageController.nextPage(
            duration: const Duration(milliseconds: 500),
            curve: Curves.decelerate);
        controller = FlutterStoryController();
      },
    );
  }
}

class MessageBoxView extends StatelessWidget {
  const MessageBoxView({
    super.key,
    required this.controller,
  });

  final FlutterStoryController controller;

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Padding(
        padding:
            const EdgeInsets.symmetric(horizontal: 20).copyWith(bottom: 10),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Expanded(
              child: Container(
                decoration: BoxDecoration(
                  border: Border.all(
                    color: Colors.white,
                  ),
                  borderRadius: BorderRadius.circular(999),
                ),
                padding: const EdgeInsets.symmetric(horizontal: 15),
                child: TextFormField(
                  onTap: () {
                    controller.pause();
                  },
                  onTapOutside: (event) {
                    controller.play();
                    FocusScope.of(context).unfocus();
                  },
                  style: const TextStyle(
                    fontWeight: FontWeight.w400,
                  ),
                  decoration: const InputDecoration(
                      hintText: 'Enter Message',
                      border: InputBorder.none,
                      isDense: true,
                      contentPadding: EdgeInsets.symmetric(vertical: 6)),
                ),
              ),
            ),
            const SizedBox(
              width: 5,
            ),
            IconButton(
                onPressed: () {},
                iconSize: 30,
                icon: Transform.rotate(
                    angle: -0.6,
                    child: const Padding(
                      padding: EdgeInsets.only(bottom: 9),
                      child: Icon(
                        Icons.send,
                        color: Colors.white,
                      ),
                    )))
          ],
        ),
      ),
    );
  }
}

class ProfileView extends StatelessWidget {
  const ProfileView({
    super.key,
    required this.storyModel,
  });

  final StoryModel storyModel;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(1),
      ),
      child: Padding(
        padding: const EdgeInsets.only(top: 30, left: 15, right: 15),
        child: Row(
          children: [
            Container(
              decoration: const BoxDecoration(
                color: Colors.amber,
                shape: BoxShape.circle,
              ),
              padding: const EdgeInsets.all(1),
              child: ClipOval(
                child: CachedNetworkImage(
                  imageUrl: storyModel.userProfile,
                  height: 35,
                  width: 35,
                  fit: BoxFit.cover,
                ),
              ),
            ),
            const SizedBox(
              width: 10,
            ),
            Expanded(
              child: Row(
                children: [
                  Flexible(
                    child: Text(
                      storyModel.userName,
                      style: const TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 14,
                      ),
                    ),
                  ),
                  const SizedBox(
                    width: 5,
                  ),
                  const Icon(
                    Icons.verified,
                    size: 15,
                  ),
                  const SizedBox(
                    width: 3,
                  ),
                  const Text(
                    '1d',
                    style: TextStyle(color: Colors.white, fontSize: 10),
                  )
                ],
              ),
            ),
            IconButton(
              onPressed: () {},
              icon: const Icon(
                Icons.more_horiz,
                color: Colors.white,
              ),
            )
          ],
        ),
      ),
    );
  }
}

// Custom Story Data Model
class StoryModel {
  String userName;
  String userProfile;
  List<StoryItem> stories;

  StoryModel({
    required this.userName,
    required this.userProfile,
    required this.stories,
  });
}

// Custom Widget - Question
class TextOverlayView extends StatelessWidget {
  const TextOverlayView({super.key, required this.controller});

  final FlutterStoryController? controller;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 40),
      decoration: const BoxDecoration(
          image: DecorationImage(
              fit: BoxFit.cover,
              image: CachedNetworkImageProvider(
                  'https://images.pexels.com/photos/1761279/pexels-photo-1761279.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2'))),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Stack(
            clipBehavior: Clip.none,
            children: [
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 22, vertical: 30),
                decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(10),
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black.withOpacity(0.2),
                        blurRadius: 10,
                        spreadRadius: 0,
                      )
                    ]),
                child: Column(
                  children: [
                    const SizedBox(
                      height: 20,
                    ),
                    const Text(
                      "What’s your favorite outdoor activity and why?",
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        color: Colors.black,
                        fontWeight: FontWeight.w600,
                        fontSize: 18,
                      ),
                    ),
                    const SizedBox(
                      height: 10,
                    ),
                    TapRegion(
                      onTapOutside: (event) {
                        controller?.play();
                        FocusScope.of(context).unfocus();
                      },
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.black.withOpacity(0.1),
                          borderRadius: BorderRadius.circular(10),
                        ),
                        padding: const EdgeInsets.symmetric(horizontal: 20),
                        child: IntrinsicWidth(
                          child: TextFormField(
                            style: const TextStyle(
                              color: Colors.black,
                            ),
                            onTap: () {
                              controller?.pause();
                            },
                            decoration: InputDecoration(
                                hintText: 'Type something...',
                                hintStyle: TextStyle(
                                  color: Colors.black.withOpacity(0.6),
                                ),
                                border: InputBorder.none),
                          ),
                        ),
                      ),
                    )
                  ],
                ),
              ),
              Positioned(
                top: -40,
                left: 0,
                right: 0,
                child: Container(
                  decoration: BoxDecoration(
                      color: const Color(0xffE2DCFF),
                      shape: BoxShape.circle,
                      boxShadow: [
                        BoxShadow(
                          color: Colors.black.withOpacity(0.2),
                          blurRadius: 20,
                        )
                      ]),
                  padding: const EdgeInsets.all(20),
                  child: CachedNetworkImage(
                    imageUrl: 'https://devkrest.com/logo/devkrest_outlined.png',
                    height: 40,
                    width: 40,
                  ),
                ),
              )
            ],
          ),
        ],
      ),
    );
  }
}

// Custom Widget - Post View
class PostOverlayView extends StatelessWidget {
  const PostOverlayView({super.key, required this.controller});

  final FlutterStoryController? controller;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 40),
      decoration: const BoxDecoration(
          gradient:
              LinearGradient(colors: [Color(0xffff8800), Color(0xffff3300)])),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 5),
            decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(10),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.1),
                    blurRadius: 0,
                    spreadRadius: 0,
                  )
                ]),
            child: IntrinsicWidth(
              child: Column(
                children: [
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 10),
                    child: Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Container(
                          decoration: const BoxDecoration(
                            color: Color(0xffE2DCFF),
                            shape: BoxShape.circle,
                          ),
                          padding: const EdgeInsets.all(8),
                          child: CachedNetworkImage(
                            imageUrl:
                                'https://devkrest.com/logo/devkrest_outlined.png',
                            height: 15,
                            width: 15,
                          ),
                        ),
                        const SizedBox(
                          width: 8,
                        ),
                        const Expanded(
                          child: Text(
                            'devkrest',
                            style: TextStyle(
                              color: Colors.black,
                              fontWeight: FontWeight.w600,
                              fontSize: 12,
                            ),
                          ),
                        ),
                        IconButton(
                          onPressed: () {},
                          icon: const Icon(
                            Icons.more_horiz,
                          ),
                        )
                      ],
                    ),
                  ),
                  const SizedBox(
                    height: 5,
                  ),
                  CachedNetworkImage(
                      height: MediaQuery.of(context).size.height * 0.40,
                      fit: BoxFit.cover,
                      imageUrl:
                          'https://scontent.cdninstagram.com/v/t51.29350-15/448680084_2197193763952189_5110658492947027914_n.webp?stp=dst-jpg_e35&efg=eyJ2ZW5jb2RlX3RhZyI6ImltYWdlX3VybGdlbi4xNDQweDE4MDAuc2RyLmYyOTM1MCJ9&_nc_ht=scontent.cdninstagram.com&_nc_cat=1&_nc_ohc=VtYwOfs3y44Q7kNvgEfDjM0&edm=APs17CUBAAAA&ccb=7-5&ig_cache_key=MzM5MzIyNzQ4MjcwNjA5NzYzNQ%3D%3D.2-ccb7-5&oh=00_AYAEOmKhroMeZensvVXMuCbC8rB0vr_0P7-ecR8AKLk5Lw&oe=6678548B&_nc_sid=10d13b'),
                  const Padding(
                    padding: EdgeInsets.symmetric(horizontal: 10, vertical: 10),
                    child: Text(
                      "India vs Afganistan",
                      textAlign: TextAlign.center,
                      style: TextStyle(
                          color: Colors.black,
                          fontWeight: FontWeight.w400,
                          fontSize: 18,
                          fontStyle: FontStyle.italic),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Team Devkrest

We would like to extend our heartfelt thanks to the following contributors for their invaluable contributions to this package.

Kaival Patel
Kaival P

CEO

Harsh Prajapati
Harsh P

Mobile Head

Lakhan Purohit
Lakhan P

CTO

Devkrest Technologies