INFRA: Set Up Project.

This commit is contained in:
2025-11-28 11:10:49 +05:00
commit c798279f7d
609 changed files with 77436 additions and 0 deletions

View 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(),
);
}
}

View 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();
}
}

View 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]);
}
}