875 lines
25 KiB
Dart
875 lines
25 KiB
Dart
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]);
|
|
}
|
|
}
|