INFRA: Set Up Project.
This commit is contained in:
37
lib/widget/story_view/controller/story_controller.dart
Normal file
37
lib/widget/story_view/controller/story_controller.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
enum PlaybackState { pause, play, next, previous }
|
||||
|
||||
/// Controller to sync playback between animated child (story) views. This
|
||||
/// helps make sure when stories are paused, the animation (gifs/slides) are
|
||||
/// also paused.
|
||||
/// Another reason for using the controller is to place the stories on `paused`
|
||||
/// state when a media is loading.
|
||||
class StoryController {
|
||||
/// Stream that broadcasts the playback state of the stories.
|
||||
final playbackNotifier = BehaviorSubject<PlaybackState>();
|
||||
|
||||
/// Notify listeners with a [PlaybackState.pause] state
|
||||
void pause() {
|
||||
playbackNotifier.add(PlaybackState.pause);
|
||||
}
|
||||
|
||||
/// Notify listeners with a [PlaybackState.play] state
|
||||
void play() {
|
||||
playbackNotifier.add(PlaybackState.play);
|
||||
}
|
||||
|
||||
void next() {
|
||||
playbackNotifier.add(PlaybackState.next);
|
||||
}
|
||||
|
||||
void previous() {
|
||||
playbackNotifier.add(PlaybackState.previous);
|
||||
}
|
||||
|
||||
/// Remember to call dispose when the story screen is disposed to close
|
||||
/// the notifier stream.
|
||||
void dispose() {
|
||||
playbackNotifier.close();
|
||||
}
|
||||
}
|
||||
5
lib/widget/story_view/story_view.dart
Normal file
5
lib/widget/story_view/story_view.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
export 'controller/story_controller.dart';
|
||||
export 'utils.dart';
|
||||
export 'widgets/story_image.dart';
|
||||
export 'widgets/story_video.dart';
|
||||
export 'widgets/story_view.dart';
|
||||
25
lib/widget/story_view/utils.dart
Normal file
25
lib/widget/story_view/utils.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
enum LoadState { loading, success, failure }
|
||||
|
||||
enum Direction { up, down, left, right }
|
||||
|
||||
class VerticalDragInfo {
|
||||
bool cancel = false;
|
||||
|
||||
Direction? direction;
|
||||
|
||||
void update(double primaryDelta) {
|
||||
Direction tmpDirection;
|
||||
|
||||
if (primaryDelta > 0) {
|
||||
tmpDirection = Direction.down;
|
||||
} else {
|
||||
tmpDirection = Direction.up;
|
||||
}
|
||||
|
||||
if (direction != null && tmpDirection != direction) {
|
||||
cancel = true;
|
||||
}
|
||||
|
||||
direction = tmpDirection;
|
||||
}
|
||||
}
|
||||
225
lib/widget/story_view/widgets/story_image.dart
Normal file
225
lib/widget/story_view/widgets/story_image.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:get/get_utils/src/extensions/internacionalization.dart';
|
||||
|
||||
import '../controller/story_controller.dart';
|
||||
import '../utils.dart';
|
||||
|
||||
/// Utitlity to load image (gif, png, jpg, etc) media just once. Resource is
|
||||
/// cached to disk with default configurations of [DefaultCacheManager].
|
||||
class ImageLoader {
|
||||
ui.Codec? frames;
|
||||
|
||||
String url;
|
||||
|
||||
Map<String, dynamic>? requestHeaders;
|
||||
|
||||
LoadState state = LoadState.loading; // by default
|
||||
|
||||
ImageLoader(this.url, {this.requestHeaders});
|
||||
|
||||
/// Load image from disk cache first, if not found then load from network.
|
||||
/// `onComplete` is called when [imageBytes] become available.
|
||||
void loadImage(VoidCallback onComplete) {
|
||||
if (frames != null) {
|
||||
state = LoadState.success;
|
||||
onComplete();
|
||||
}
|
||||
|
||||
final fileStream = DefaultCacheManager().getFileStream(url, headers: requestHeaders as Map<String, String>?);
|
||||
|
||||
fileStream.listen(
|
||||
(fileResponse) {
|
||||
if (fileResponse is! FileInfo) return;
|
||||
// the reason for this is that, when the cache manager fetches
|
||||
// the image again from network, the provided `onComplete` should
|
||||
// not be called again
|
||||
if (frames != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageBytes = fileResponse.file.readAsBytesSync();
|
||||
|
||||
state = LoadState.success;
|
||||
|
||||
ui.instantiateImageCodec(imageBytes).then((codec) {
|
||||
frames = codec;
|
||||
onComplete();
|
||||
}, onError: (error) {
|
||||
state = LoadState.failure;
|
||||
onComplete();
|
||||
});
|
||||
},
|
||||
onError: (error) {
|
||||
state = LoadState.failure;
|
||||
onComplete();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to display animated gifs or still images. Shows a loader while image
|
||||
/// is being loaded. Listens to playback states from [controller] to pause and
|
||||
/// forward animated media.
|
||||
class StoryImage extends StatefulWidget {
|
||||
final ImageLoader imageLoader;
|
||||
|
||||
final BoxFit? fit;
|
||||
|
||||
final StoryController? controller;
|
||||
final Widget? loadingWidget;
|
||||
final Widget? errorWidget;
|
||||
|
||||
StoryImage(
|
||||
this.imageLoader, {
|
||||
Key? key,
|
||||
this.controller,
|
||||
this.fit,
|
||||
this.loadingWidget,
|
||||
this.errorWidget,
|
||||
}) : super(key: key ?? UniqueKey());
|
||||
|
||||
/// Use this shorthand to fetch images/gifs from the provided [url]
|
||||
factory StoryImage.url(
|
||||
String url, {
|
||||
StoryController? controller,
|
||||
Map<String, dynamic>? requestHeaders,
|
||||
BoxFit fit = BoxFit.fitWidth,
|
||||
Widget? loadingWidget,
|
||||
Widget? errorWidget,
|
||||
Key? key,
|
||||
}) {
|
||||
return StoryImage(
|
||||
ImageLoader(
|
||||
url,
|
||||
requestHeaders: requestHeaders,
|
||||
),
|
||||
controller: controller,
|
||||
fit: fit,
|
||||
loadingWidget: loadingWidget,
|
||||
errorWidget: errorWidget,
|
||||
key: key,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => StoryImageState();
|
||||
}
|
||||
|
||||
class StoryImageState extends State<StoryImage> {
|
||||
ui.Image? currentFrame;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
StreamSubscription<PlaybackState>? _streamSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.controller != null) {
|
||||
_streamSubscription = widget.controller!.playbackNotifier.listen((playbackState) {
|
||||
// for the case of gifs we need to pause/play
|
||||
if (widget.imageLoader.frames == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackState == PlaybackState.pause) {
|
||||
_timer?.cancel();
|
||||
} else {
|
||||
forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
widget.controller?.pause();
|
||||
|
||||
widget.imageLoader.loadImage(() async {
|
||||
if (mounted) {
|
||||
if (widget.imageLoader.state == LoadState.success) {
|
||||
widget.controller?.play();
|
||||
forward();
|
||||
} else {
|
||||
// refresh to show error
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_streamSubscription?.cancel();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void setState(fn) {
|
||||
if (mounted) {
|
||||
super.setState(fn);
|
||||
}
|
||||
}
|
||||
|
||||
void forward() async {
|
||||
_timer?.cancel();
|
||||
|
||||
if (widget.controller != null && widget.controller!.playbackNotifier.stream.value == PlaybackState.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nextFrame = await widget.imageLoader.frames!.getNextFrame();
|
||||
|
||||
currentFrame = nextFrame.image;
|
||||
|
||||
if (nextFrame.duration > const Duration(milliseconds: 0)) {
|
||||
_timer = Timer(nextFrame.duration, forward);
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Widget getContentView() {
|
||||
switch (widget.imageLoader.state) {
|
||||
case LoadState.success:
|
||||
return RawImage(
|
||||
image: currentFrame,
|
||||
fit: widget.fit,
|
||||
);
|
||||
case LoadState.failure:
|
||||
return Center(
|
||||
child: widget.errorWidget ??
|
||||
Text(
|
||||
"Image failed to load.".tr,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
));
|
||||
default:
|
||||
return Center(
|
||||
child: widget.loadingWidget ??
|
||||
const SizedBox(
|
||||
width: 70,
|
||||
height: 70,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: getContentView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/widget/story_view/widgets/story_video.dart
Normal file
162
lib/widget/story_view/widgets/story_video.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import '../controller/story_controller.dart';
|
||||
import '../utils.dart';
|
||||
|
||||
class VideoLoader {
|
||||
String url;
|
||||
|
||||
File? videoFile;
|
||||
|
||||
Map<String, dynamic>? requestHeaders;
|
||||
|
||||
LoadState state = LoadState.loading;
|
||||
|
||||
VideoLoader(this.url, {this.requestHeaders});
|
||||
|
||||
void loadVideo(VoidCallback onComplete) {
|
||||
if (videoFile != null) {
|
||||
state = LoadState.success;
|
||||
onComplete();
|
||||
}
|
||||
|
||||
final fileStream = DefaultCacheManager().getFileStream(url, headers: requestHeaders as Map<String, String>?);
|
||||
|
||||
fileStream.listen((fileResponse) {
|
||||
if (fileResponse is FileInfo) {
|
||||
if (videoFile == null) {
|
||||
state = LoadState.success;
|
||||
videoFile = fileResponse.file;
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class StoryVideo extends StatefulWidget {
|
||||
final StoryController? storyController;
|
||||
final VideoLoader videoLoader;
|
||||
final Widget? loadingWidget;
|
||||
final Widget? errorWidget;
|
||||
|
||||
StoryVideo(
|
||||
this.videoLoader, {
|
||||
Key? key,
|
||||
this.storyController,
|
||||
this.loadingWidget,
|
||||
this.errorWidget,
|
||||
}) : super(key: key ?? UniqueKey());
|
||||
|
||||
static StoryVideo url(
|
||||
String url, {
|
||||
StoryController? controller,
|
||||
Map<String, dynamic>? requestHeaders,
|
||||
Key? key,
|
||||
Widget? loadingWidget,
|
||||
Widget? errorWidget,
|
||||
}) {
|
||||
return StoryVideo(
|
||||
VideoLoader(url, requestHeaders: requestHeaders),
|
||||
storyController: controller,
|
||||
key: key,
|
||||
loadingWidget: loadingWidget,
|
||||
errorWidget: errorWidget,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return StoryVideoState();
|
||||
}
|
||||
}
|
||||
|
||||
class StoryVideoState extends State<StoryVideo> {
|
||||
Future<void>? playerLoader;
|
||||
|
||||
StreamSubscription? _streamSubscription;
|
||||
|
||||
VideoPlayerController? playerController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.storyController!.pause();
|
||||
|
||||
widget.videoLoader.loadVideo(() {
|
||||
if (widget.videoLoader.state == LoadState.success) {
|
||||
playerController = VideoPlayerController.file(widget.videoLoader.videoFile!);
|
||||
|
||||
playerController!.initialize().then((v) {
|
||||
setState(() {});
|
||||
widget.storyController!.play();
|
||||
});
|
||||
|
||||
if (widget.storyController != null) {
|
||||
_streamSubscription = widget.storyController!.playbackNotifier.listen((playbackState) {
|
||||
if (playbackState == PlaybackState.pause) {
|
||||
playerController!.pause();
|
||||
} else {
|
||||
playerController!.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget getContentView() {
|
||||
if (widget.videoLoader.state == LoadState.success && playerController!.value.isInitialized) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: playerController!.value.aspectRatio,
|
||||
child: VideoPlayer(playerController!),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return widget.videoLoader.state == LoadState.loading
|
||||
? Center(
|
||||
child: widget.loadingWidget ??
|
||||
const SizedBox(
|
||||
width: 70,
|
||||
height: 70,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: widget.errorWidget ??
|
||||
const Text(
|
||||
"Media failed to load.",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
child: getContentView(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
playerController?.dispose();
|
||||
_streamSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
874
lib/widget/story_view/widgets/story_view.dart
Normal file
874
lib/widget/story_view/widgets/story_view.dart
Normal file
@@ -0,0 +1,874 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../controller/story_controller.dart';
|
||||
import '../utils.dart';
|
||||
import 'story_image.dart';
|
||||
import 'story_video.dart';
|
||||
|
||||
/// Indicates where the progress indicators should be placed.
|
||||
enum ProgressPosition { top, bottom, none }
|
||||
|
||||
/// This is used to specify the height of the progress indicator. Inline stories
|
||||
/// should use [small]
|
||||
enum IndicatorHeight { small, medium, large }
|
||||
|
||||
/// This is a representation of a story item (or page).
|
||||
class StoryItem {
|
||||
/// Specifies how long the page should be displayed. It should be a reasonable
|
||||
/// amount of time greater than 0 milliseconds.
|
||||
final Duration duration;
|
||||
|
||||
/// Has this page been shown already? This is used to indicate that the page
|
||||
/// has been displayed. If some pages are supposed to be skipped in a story,
|
||||
/// mark them as shown `shown = true`.
|
||||
///
|
||||
/// However, during initialization of the story view, all pages after the
|
||||
/// last unshown page will have their `shown` attribute altered to false. This
|
||||
/// is because the next item to be displayed is taken by the last unshown
|
||||
/// story item.
|
||||
bool shown;
|
||||
|
||||
/// The page content
|
||||
final Widget view;
|
||||
|
||||
StoryItem(
|
||||
this.view, {
|
||||
required this.duration,
|
||||
this.shown = false,
|
||||
});
|
||||
|
||||
/// Short hand to create text-only page.
|
||||
///
|
||||
/// [title] is the text to be displayed on [backgroundColor]. The text color
|
||||
/// alternates between [Colors.black] and [Colors.white] depending on the
|
||||
/// calculated contrast. This is to ensure readability of text.
|
||||
///
|
||||
/// Works for inline and full-page stories. See [StoryView.inline] for more on
|
||||
/// what inline/full-page means.
|
||||
static StoryItem text({
|
||||
required String title,
|
||||
required Color backgroundColor,
|
||||
Key? key,
|
||||
TextStyle? textStyle,
|
||||
bool shown = false,
|
||||
bool roundedTop = false,
|
||||
bool roundedBottom = false,
|
||||
EdgeInsetsGeometry? textOuterPadding,
|
||||
Duration? duration,
|
||||
}) {
|
||||
double contrast = ContrastHelper.contrast([
|
||||
backgroundColor.red,
|
||||
backgroundColor.green,
|
||||
backgroundColor.blue,
|
||||
], [
|
||||
255,
|
||||
255,
|
||||
255
|
||||
] /** white text */);
|
||||
|
||||
return StoryItem(
|
||||
Container(
|
||||
key: key,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(roundedTop ? 8 : 0),
|
||||
bottom: Radius.circular(roundedBottom ? 8 : 0),
|
||||
),
|
||||
),
|
||||
padding: textOuterPadding ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: textStyle?.copyWith(
|
||||
color: contrast > 1.8 ? Colors.white : Colors.black,
|
||||
) ??
|
||||
TextStyle(
|
||||
color: contrast > 1.8 ? Colors.white : Colors.black,
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
//color: backgroundColor,
|
||||
),
|
||||
shown: shown,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory constructor for page images. [controller] should be same instance as
|
||||
/// one passed to the `StoryView`
|
||||
factory StoryItem.pageImage({
|
||||
required String url,
|
||||
required StoryController controller,
|
||||
Key? key,
|
||||
BoxFit imageFit = BoxFit.fitWidth,
|
||||
Text? caption,
|
||||
bool shown = false,
|
||||
Map<String, dynamic>? requestHeaders,
|
||||
Widget? loadingWidget,
|
||||
Widget? errorWidget,
|
||||
EdgeInsetsGeometry? captionOuterPadding,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return StoryItem(
|
||||
Container(
|
||||
key: key,
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
StoryImage.url(
|
||||
url,
|
||||
controller: controller,
|
||||
fit: imageFit,
|
||||
requestHeaders: requestHeaders,
|
||||
loadingWidget: loadingWidget,
|
||||
errorWidget: errorWidget,
|
||||
),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 24,
|
||||
),
|
||||
padding: captionOuterPadding ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
color: caption != null ? Colors.black54 : Colors.transparent,
|
||||
child: caption ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
shown: shown,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shorthand for creating inline image. [controller] should be same instance as
|
||||
/// one passed to the `StoryView`
|
||||
factory StoryItem.inlineImage({
|
||||
required String url,
|
||||
Text? caption,
|
||||
required StoryController controller,
|
||||
Key? key,
|
||||
BoxFit imageFit = BoxFit.cover,
|
||||
Map<String, dynamic>? requestHeaders,
|
||||
bool shown = false,
|
||||
bool roundedTop = true,
|
||||
bool roundedBottom = false,
|
||||
Widget? loadingWidget,
|
||||
Widget? errorWidget,
|
||||
EdgeInsetsGeometry? captionOuterPadding,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return StoryItem(
|
||||
ClipRRect(
|
||||
key: key,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(roundedTop ? 8 : 0),
|
||||
bottom: Radius.circular(roundedBottom ? 8 : 0),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.grey[100],
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
StoryImage.url(
|
||||
url,
|
||||
controller: controller,
|
||||
fit: imageFit,
|
||||
requestHeaders: requestHeaders,
|
||||
loadingWidget: loadingWidget,
|
||||
errorWidget: errorWidget,
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: captionOuterPadding ?? const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: caption ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
shown: shown,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shorthand for creating page video. [controller] should be same instance as
|
||||
/// one passed to the `StoryView`
|
||||
factory StoryItem.pageVideo(
|
||||
String url, {
|
||||
required StoryController controller,
|
||||
Key? key,
|
||||
Duration? duration,
|
||||
BoxFit imageFit = BoxFit.fitWidth,
|
||||
Widget? caption,
|
||||
bool shown = false,
|
||||
Map<String, dynamic>? requestHeaders,
|
||||
Widget? loadingWidget,
|
||||
Widget? errorWidget,
|
||||
}) {
|
||||
return StoryItem(
|
||||
Container(
|
||||
key: key,
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
StoryVideo.url(
|
||||
url,
|
||||
controller: controller,
|
||||
requestHeaders: requestHeaders,
|
||||
loadingWidget: loadingWidget,
|
||||
errorWidget: errorWidget,
|
||||
),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
color: caption != null ? Colors.black54 : Colors.transparent,
|
||||
child: caption ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
shown: shown,
|
||||
duration: duration ?? const Duration(seconds: 10));
|
||||
}
|
||||
|
||||
/// Shorthand for creating a story item from an image provider such as `AssetImage`
|
||||
/// or `NetworkImage`. However, the story continues to play while the image loads
|
||||
/// up.
|
||||
factory StoryItem.pageProviderImage(
|
||||
ImageProvider image, {
|
||||
Key? key,
|
||||
BoxFit imageFit = BoxFit.fitWidth,
|
||||
String? caption,
|
||||
bool shown = false,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return StoryItem(
|
||||
Container(
|
||||
key: key,
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Center(
|
||||
child: Image(
|
||||
image: image,
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
fit: imageFit,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 24,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
color: caption != null ? Colors.black54 : Colors.transparent,
|
||||
child: caption != null
|
||||
? Text(
|
||||
caption,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
shown: shown,
|
||||
duration: duration ?? const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
/// Shorthand for creating an inline story item from an image provider such as `AssetImage`
|
||||
/// or `NetworkImage`. However, the story continues to play while the image loads
|
||||
/// up.
|
||||
factory StoryItem.inlineProviderImage(
|
||||
ImageProvider image, {
|
||||
Key? key,
|
||||
Text? caption,
|
||||
bool shown = false,
|
||||
bool roundedTop = true,
|
||||
bool roundedBottom = false,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return StoryItem(
|
||||
Container(
|
||||
key: key,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(roundedTop ? 8 : 0),
|
||||
bottom: Radius.circular(roundedBottom ? 8 : 0),
|
||||
),
|
||||
image: DecorationImage(
|
||||
image: image,
|
||||
fit: BoxFit.cover,
|
||||
)),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 16,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: caption ?? const SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
shown: shown,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to display stories just like Whatsapp and Instagram. Can also be used
|
||||
/// inline/inside [ListView] or [Column] just like Google News app. Comes with
|
||||
/// gestures to pause, forward and go to previous page.
|
||||
class StoryView extends StatefulWidget {
|
||||
/// The pages to displayed.
|
||||
final List<StoryItem?> storyItems;
|
||||
|
||||
/// Callback for when a full cycle of story is shown. This will be called
|
||||
/// each time the full story completes when [repeat] is set to `true`.
|
||||
final VoidCallback? onComplete;
|
||||
|
||||
/// Callback for when a vertical swipe gesture is detected. If you do not
|
||||
/// want to listen to such event, do not provide it. For instance,
|
||||
/// for inline stories inside ListViews, it is preferrable to not to
|
||||
/// provide this callback so as to enable scroll events on the list view.
|
||||
final Function(Direction?)? onVerticalSwipeComplete;
|
||||
|
||||
/// Callback for when a story and it index is currently being shown.
|
||||
final void Function(StoryItem storyItem, int index)? onStoryShow;
|
||||
|
||||
/// Where the progress indicator should be placed.
|
||||
final ProgressPosition progressPosition;
|
||||
|
||||
/// Should the story be repeated forever?
|
||||
final bool repeat;
|
||||
|
||||
/// If you would like to display the story as full-page, then set this to
|
||||
/// `false`. But in case you would display this as part of a page (eg. in
|
||||
/// a [ListView] or [Column]) then set this to `true`.
|
||||
final bool inline;
|
||||
|
||||
/// Controls the playback of the stories
|
||||
final StoryController controller;
|
||||
|
||||
/// Indicator Color
|
||||
final Color? indicatorColor;
|
||||
|
||||
/// Indicator Foreground Color
|
||||
final Color? indicatorForegroundColor;
|
||||
|
||||
/// Determine the height of the indicator
|
||||
final IndicatorHeight indicatorHeight;
|
||||
|
||||
/// Use this if you want to give outer padding to the indicator
|
||||
final EdgeInsetsGeometry indicatorOuterPadding;
|
||||
|
||||
const StoryView({
|
||||
super.key,
|
||||
required this.storyItems,
|
||||
required this.controller,
|
||||
this.onComplete,
|
||||
this.onStoryShow,
|
||||
this.progressPosition = ProgressPosition.top,
|
||||
this.repeat = false,
|
||||
this.inline = false,
|
||||
this.onVerticalSwipeComplete,
|
||||
this.indicatorColor,
|
||||
this.indicatorForegroundColor,
|
||||
this.indicatorHeight = IndicatorHeight.large,
|
||||
this.indicatorOuterPadding = const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return StoryViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class StoryViewState extends State<StoryView> with TickerProviderStateMixin {
|
||||
AnimationController? _animationController;
|
||||
Animation<double>? _currentAnimation;
|
||||
Timer? _nextDebouncer;
|
||||
|
||||
StreamSubscription<PlaybackState>? _playbackSubscription;
|
||||
|
||||
VerticalDragInfo? verticalDragInfo;
|
||||
|
||||
StoryItem? get _currentStory {
|
||||
return widget.storyItems.firstWhereOrNull((it) => !it!.shown);
|
||||
}
|
||||
|
||||
Widget get _currentView {
|
||||
var item = widget.storyItems.firstWhereOrNull((it) => !it!.shown);
|
||||
item ??= widget.storyItems.last;
|
||||
return item?.view ?? Container();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// All pages after the first unshown page should have their shown value as
|
||||
// false
|
||||
final firstPage = widget.storyItems.firstWhereOrNull((it) => !it!.shown);
|
||||
if (firstPage == null) {
|
||||
for (var it2 in widget.storyItems) {
|
||||
it2!.shown = false;
|
||||
}
|
||||
} else {
|
||||
final lastShownPos = widget.storyItems.indexOf(firstPage);
|
||||
widget.storyItems.sublist(lastShownPos).forEach((it) {
|
||||
it!.shown = false;
|
||||
});
|
||||
}
|
||||
|
||||
_playbackSubscription = widget.controller.playbackNotifier.listen((playbackStatus) {
|
||||
switch (playbackStatus) {
|
||||
case PlaybackState.play:
|
||||
_removeNextHold();
|
||||
_animationController?.forward();
|
||||
break;
|
||||
|
||||
case PlaybackState.pause:
|
||||
_holdNext(); // then pause animation
|
||||
_animationController?.stop(canceled: false);
|
||||
break;
|
||||
|
||||
case PlaybackState.next:
|
||||
_removeNextHold();
|
||||
_goForward();
|
||||
break;
|
||||
|
||||
case PlaybackState.previous:
|
||||
_removeNextHold();
|
||||
_goBack();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_play();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clearDebouncer();
|
||||
|
||||
_animationController?.dispose();
|
||||
_playbackSubscription?.cancel();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void setState(fn) {
|
||||
if (mounted) {
|
||||
super.setState(fn);
|
||||
}
|
||||
}
|
||||
|
||||
void _play() {
|
||||
_animationController?.dispose();
|
||||
// get the next playing page
|
||||
final storyItem = widget.storyItems.firstWhere((it) {
|
||||
return !it!.shown;
|
||||
})!;
|
||||
|
||||
final storyItemIndex = widget.storyItems.indexOf(storyItem);
|
||||
|
||||
if (widget.onStoryShow != null) {
|
||||
widget.onStoryShow!(storyItem, storyItemIndex);
|
||||
}
|
||||
|
||||
_animationController = AnimationController(duration: storyItem.duration, vsync: this);
|
||||
|
||||
_animationController!.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
storyItem.shown = true;
|
||||
if (widget.storyItems.last != storyItem) {
|
||||
_beginPlay();
|
||||
} else {
|
||||
// done playing
|
||||
_onComplete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_currentAnimation = Tween(begin: 0.0, end: 1.0).animate(_animationController!);
|
||||
|
||||
widget.controller.play();
|
||||
}
|
||||
|
||||
void _beginPlay() {
|
||||
setState(() {});
|
||||
_play();
|
||||
}
|
||||
|
||||
void _onComplete() {
|
||||
if (widget.onComplete != null) {
|
||||
widget.controller.pause();
|
||||
widget.onComplete!();
|
||||
}
|
||||
|
||||
if (widget.repeat) {
|
||||
for (var it in widget.storyItems) {
|
||||
it!.shown = false;
|
||||
}
|
||||
|
||||
_beginPlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _goBack() {
|
||||
_animationController!.stop();
|
||||
|
||||
if (_currentStory == null) {
|
||||
widget.storyItems.last!.shown = false;
|
||||
}
|
||||
|
||||
if (_currentStory == widget.storyItems.first) {
|
||||
_beginPlay();
|
||||
} else {
|
||||
_currentStory!.shown = false;
|
||||
int lastPos = widget.storyItems.indexOf(_currentStory);
|
||||
final previous = widget.storyItems[lastPos - 1]!;
|
||||
|
||||
previous.shown = false;
|
||||
|
||||
_beginPlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _goForward() {
|
||||
if (_currentStory != widget.storyItems.last) {
|
||||
_animationController!.stop();
|
||||
|
||||
// get last showing
|
||||
final last = _currentStory;
|
||||
|
||||
if (last != null) {
|
||||
last.shown = true;
|
||||
if (last != widget.storyItems.last) {
|
||||
_beginPlay();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// this is the last page, progress animation should skip to end
|
||||
_animationController!.animateTo(1.0, duration: const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDebouncer() {
|
||||
_nextDebouncer?.cancel();
|
||||
_nextDebouncer = null;
|
||||
}
|
||||
|
||||
void _removeNextHold() {
|
||||
_nextDebouncer?.cancel();
|
||||
_nextDebouncer = null;
|
||||
}
|
||||
|
||||
void _holdNext() {
|
||||
_nextDebouncer?.cancel();
|
||||
_nextDebouncer = Timer(const Duration(milliseconds: 500), () {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_currentView,
|
||||
Visibility(
|
||||
visible: widget.progressPosition != ProgressPosition.none,
|
||||
child: Align(
|
||||
alignment: widget.progressPosition == ProgressPosition.top ? Alignment.topCenter : Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
bottom: widget.inline ? false : true,
|
||||
// we use SafeArea here for notched and bezeles phones
|
||||
child: Container(
|
||||
padding: widget.indicatorOuterPadding,
|
||||
child: PageBar(
|
||||
widget.storyItems.map((it) => PageData(it!.duration, it.shown)).toList(),
|
||||
_currentAnimation,
|
||||
key: UniqueKey(),
|
||||
indicatorHeight: widget.indicatorHeight,
|
||||
indicatorColor: widget.indicatorColor,
|
||||
indicatorForegroundColor: widget.indicatorForegroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
heightFactor: 1,
|
||||
child: GestureDetector(
|
||||
onTapDown: (details) {
|
||||
widget.controller.pause();
|
||||
},
|
||||
onTapCancel: () {
|
||||
widget.controller.play();
|
||||
},
|
||||
onTapUp: (details) {
|
||||
// if debounce timed out (not active) then continue anim
|
||||
if (_nextDebouncer?.isActive == false) {
|
||||
widget.controller.play();
|
||||
} else {
|
||||
widget.controller.next();
|
||||
}
|
||||
},
|
||||
onVerticalDragStart: widget.onVerticalSwipeComplete == null
|
||||
? null
|
||||
: (details) {
|
||||
widget.controller.pause();
|
||||
},
|
||||
onVerticalDragCancel: widget.onVerticalSwipeComplete == null
|
||||
? null
|
||||
: () {
|
||||
widget.controller.play();
|
||||
},
|
||||
onVerticalDragUpdate: widget.onVerticalSwipeComplete == null
|
||||
? null
|
||||
: (details) {
|
||||
verticalDragInfo ??= VerticalDragInfo();
|
||||
|
||||
verticalDragInfo!.update(details.primaryDelta!);
|
||||
|
||||
// TODO: provide callback interface for animation purposes
|
||||
},
|
||||
onVerticalDragEnd: widget.onVerticalSwipeComplete == null
|
||||
? null
|
||||
: (details) {
|
||||
widget.controller.play();
|
||||
// finish up drag cycle
|
||||
if (!verticalDragInfo!.cancel && widget.onVerticalSwipeComplete != null) {
|
||||
widget.onVerticalSwipeComplete!(verticalDragInfo!.direction);
|
||||
}
|
||||
|
||||
verticalDragInfo = null;
|
||||
},
|
||||
)),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
heightFactor: 1,
|
||||
child: SizedBox(
|
||||
width: 70,
|
||||
child: GestureDetector(onTap: () {
|
||||
widget.controller.previous();
|
||||
})),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Capsule holding the duration and shown property of each story. Passed down
|
||||
/// to the pages bar to render the page indicators.
|
||||
class PageData {
|
||||
Duration duration;
|
||||
bool shown;
|
||||
|
||||
PageData(this.duration, this.shown);
|
||||
}
|
||||
|
||||
/// Horizontal bar displaying a row of [StoryProgressIndicator] based on the
|
||||
/// [pages] provided.
|
||||
class PageBar extends StatefulWidget {
|
||||
final List<PageData> pages;
|
||||
final Animation<double>? animation;
|
||||
final IndicatorHeight indicatorHeight;
|
||||
final Color? indicatorColor;
|
||||
final Color? indicatorForegroundColor;
|
||||
|
||||
const PageBar(
|
||||
this.pages,
|
||||
this.animation, {
|
||||
this.indicatorHeight = IndicatorHeight.large,
|
||||
this.indicatorColor,
|
||||
this.indicatorForegroundColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return PageBarState();
|
||||
}
|
||||
}
|
||||
|
||||
class PageBarState extends State<PageBar> {
|
||||
double spacing = 4;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
int count = widget.pages.length;
|
||||
spacing = (count > 15) ? 2 : ((count > 10) ? 3 : 4);
|
||||
|
||||
widget.animation!.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void setState(fn) {
|
||||
if (mounted) {
|
||||
super.setState(fn);
|
||||
}
|
||||
}
|
||||
|
||||
bool isPlaying(PageData page) {
|
||||
return widget.pages.firstWhereOrNull((it) => !it.shown) == page;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: widget.pages.map((it) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(right: widget.pages.last == it ? 0 : spacing),
|
||||
child: StoryProgressIndicator(
|
||||
isPlaying(it) ? widget.animation!.value : (it.shown ? 1 : 0),
|
||||
indicatorHeight: widget.indicatorHeight == IndicatorHeight.large
|
||||
? 5
|
||||
: widget.indicatorHeight == IndicatorHeight.medium
|
||||
? 3
|
||||
: 2,
|
||||
indicatorColor: widget.indicatorColor,
|
||||
indicatorForegroundColor: widget.indicatorForegroundColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom progress bar. Supposed to be lighter than the
|
||||
/// original [ProgressIndicator], and rounded at the sides.
|
||||
class StoryProgressIndicator extends StatelessWidget {
|
||||
/// From `0.0` to `1.0`, determines the progress of the indicator
|
||||
final double value;
|
||||
final double indicatorHeight;
|
||||
final Color? indicatorColor;
|
||||
final Color? indicatorForegroundColor;
|
||||
|
||||
const StoryProgressIndicator(
|
||||
this.value, {
|
||||
super.key,
|
||||
this.indicatorHeight = 5,
|
||||
this.indicatorColor,
|
||||
this.indicatorForegroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: Size.fromHeight(
|
||||
indicatorHeight,
|
||||
),
|
||||
foregroundPainter: IndicatorOval(
|
||||
indicatorForegroundColor ?? Colors.white.withOpacity(0.8),
|
||||
value,
|
||||
),
|
||||
painter: IndicatorOval(
|
||||
indicatorColor ?? Colors.white.withOpacity(0.4),
|
||||
1.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IndicatorOval extends CustomPainter {
|
||||
final Color color;
|
||||
final double widthFactor;
|
||||
|
||||
IndicatorOval(this.color, this.widthFactor);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, size.width * widthFactor, size.height), const Radius.circular(3)), paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Concept source: https://stackoverflow.com/a/9733420
|
||||
class ContrastHelper {
|
||||
static double luminance(int? r, int? g, int? b) {
|
||||
final a = [r, g, b].map((it) {
|
||||
double value = it!.toDouble() / 255.0;
|
||||
return value <= 0.03928 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4);
|
||||
}).toList();
|
||||
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
|
||||
static double contrast(rgb1, rgb2) {
|
||||
return luminance(rgb2[0], rgb2[1], rgb2[2]) / luminance(rgb1[0], rgb1[1], rgb1[2]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user