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,13 @@
/// A package to paginate your firebase related data with realtime updates.
///
/// It can be used for `Firestore` and `Firebase Realtime Database`.
///
/// Data can be shown in `list`, `grid` and `scrollable wrap` view.
library;
export 'src/firestore_pagination.dart';
// Data Models
export 'src/models/view_type.dart';
export 'src/models/wrap_options.dart';
// Widgets
export 'src/realtime_db_pagination.dart';

View File

@@ -0,0 +1,335 @@
// Dart Packages
import 'dart:async';
// Firebase Packages
import 'package:cloud_firestore/cloud_firestore.dart';
// Flutter Packages
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
// Functions
import 'functions/separator_builder.dart';
// Data Models
import 'models/page_options.dart';
import 'models/view_type.dart';
import 'models/wrap_options.dart';
// Widgets
import 'widgets/defaults/bottom_loader.dart';
import 'widgets/defaults/empty_screen.dart';
import 'widgets/defaults/initial_loader.dart';
import 'widgets/views/build_pagination.dart';
/// A [StreamBuilder] that automatically loads more data when the user scrolls
/// to the bottom.
///
/// Optimized for [FirebaseFirestore] with fields like `createdAt` and
/// `timestamp` to sort the data.
///
/// Supports live updates and realtime updates to loaded data.
///
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
class FirestorePagination extends StatefulWidget {
/// Creates a [StreamBuilder] widget that automatically loads more data when
/// the user scrolls to the bottom.
///
/// Optimized for [FirebaseFirestore] with fields like `createdAt` and
/// `timestamp` to sort the data.
///
/// Supports live updates and realtime updates to loaded data.
///
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
const FirestorePagination({
required this.query,
required this.itemBuilder,
super.key,
this.separatorBuilder,
this.limit = 10,
this.viewType = ViewType.list,
this.isLive = false,
this.gridDelegate = const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
this.wrapOptions = const WrapOptions(),
this.pageOptions = const PageOptions(),
this.onEmpty = const EmptyScreen(),
this.bottomLoader = const BottomLoader(),
this.initialLoader = const InitialLoader(),
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.shrinkWrap = false,
this.physics,
this.padding,
this.controller,
this.pageController,
});
/// The query to use to fetch data from Firestore.
///
/// ### Note:
/// - The query must **NOT** contain a `limit` itself.
/// - The `limit` must be set using the [limit] property of this widget.
final Query query;
/// The builder to use to build the items in the list.
///
/// The builder is passed the build context, snapshot of the document and
/// index of the item in the list.
final Widget Function(BuildContext, List<DocumentSnapshot>, int) itemBuilder;
/// The builder to use to render the separator.
///
/// Only used if [viewType] is [ViewType.list].
///
/// Default [Widget] is [SizedBox.shrink].
final Widget Function(BuildContext, int)? separatorBuilder;
/// The number of items to fetch from Firestore at once.
///
/// Defaults to `10`.
final int limit;
/// The type of view to use for the list.
///
/// Defaults to [ViewType.list].
final ViewType viewType;
/// Whether to fetch newly added items as they are added to Firestore.
///
/// Defaults to `false`.
final bool isLive;
/// The delegate to use for the [GridView].
///
/// Defaults to [SliverGridDelegateWithFixedCrossAxisCount].
final SliverGridDelegate gridDelegate;
/// The [Wrap] widget properties to use.
///
/// Defaults to [WrapOptions].
final WrapOptions wrapOptions;
/// The [PageView] properties to use.
///
/// Defaults to [PageOptions].
final PageOptions pageOptions;
/// The widget to use when data is empty.
///
/// Defaults to [EmptyScreen].
final Widget onEmpty;
/// The widget to use when more data is loading.
///
/// Defaults to [BottomLoader].
final Widget bottomLoader;
/// The widget to use when data is loading initially.
///
/// Defaults to [InitialLoader].
final Widget initialLoader;
/// The scrolling direction of the [ScrollView].
final Axis scrollDirection;
/// Whether the [ScrollView] scrolls in the reading direction.
final bool reverse;
/// Should the [ScrollView] be shrink-wrapped.
final bool shrinkWrap;
/// The scroll behavior to use for the [ScrollView].
final ScrollPhysics? physics;
/// The padding to use for the [ScrollView].
final EdgeInsetsGeometry? padding;
/// The scroll controller to use for the [ScrollView].
///
/// Defaults to [ScrollController].
final ScrollController? controller;
/// The page controller to use for the [PageView].
///
/// Defaults to [PageController].
final PageController? pageController;
@override
State<FirestorePagination> createState() => _FirestorePaginationState();
}
/// The state of the [FirestorePagination] widget.
class _FirestorePaginationState extends State<FirestorePagination> {
/// All the data that has been loaded from Firestore.
final List<DocumentSnapshot> _docs = [];
/// Snapshot subscription for the query.
///
/// Also handles updates to loaded data.
StreamSubscription<QuerySnapshot>? _streamSub;
/// Snapshot subscription for the query to handle newly added data.
StreamSubscription<QuerySnapshot>? _liveStreamSub;
/// [ScrollController] to listen to scroll end and load more data.
late final ScrollController _controller = widget.controller ?? ScrollController();
/// [PageController] to listen to page changes and load more data.
late final PageController _pageController = widget.pageController ?? PageController();
/// Whether initial data is loading.
bool _isInitialLoading = true;
/// Whether more data is loading.
bool _isFetching = false;
/// Whether the end for given query has been reached.
///
/// This is used to determine if more data should be loaded when the user
/// scrolls to the bottom.
bool _isEnded = false;
/// Loads more data from Firestore and handles updates to loaded data.
///
/// Setting [getMore] to `false` will only set listener for the currently
/// loaded data.
Future<void> _loadDocuments({bool getMore = true}) async {
// To cancel previous updates listener when new one is set.
final tempSub = _streamSub;
if (getMore) setState(() => _isFetching = true);
final docsLimit = _docs.length + (getMore ? widget.limit : 0);
var docsQuery = widget.query.limit(docsLimit);
if (_docs.isNotEmpty) {
docsQuery = docsQuery.startAtDocument(_docs.first);
}
_streamSub = docsQuery.snapshots().listen((QuerySnapshot snapshot) async {
await tempSub?.cancel();
_docs
..clear()
..addAll(snapshot.docs);
// To set new updates listener for the existing data
// or to set new live listener if the first document is removed.
final isDocRemoved = snapshot.docChanges.any(
(DocumentChange change) => change.type == DocumentChangeType.removed,
);
_isFetching = false;
if (!isDocRemoved) {
_isEnded = snapshot.docs.length < docsLimit;
}
if (isDocRemoved || _isInitialLoading) {
_isInitialLoading = false;
if (snapshot.docs.isNotEmpty) {
// Set updates listener for the existing data starting from the first
// document only.
await _loadDocuments(getMore: false);
} else {
_streamSub?.cancel();
}
if (widget.isLive) _setLiveListener();
}
if (mounted) setState(() {});
// Add data till the view is scrollable. This ensures that the user can
// scroll to the bottom and load more data.
if (_isInitialLoading || _isFetching || _isEnded) return;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_controller.hasClients && _controller.position.maxScrollExtent <= 0) {
_loadDocuments();
}
});
});
}
/// Sets the live listener for the query.
///
/// Fires when new data is added to the query.
Future<void> _setLiveListener() async {
// To cancel previous live listener when new one is set.
final tempSub = _liveStreamSub;
var latestDocQuery = widget.query.limit(1);
if (_docs.isNotEmpty) {
latestDocQuery = latestDocQuery.endBeforeDocument(_docs.first);
}
_liveStreamSub = latestDocQuery.snapshots(includeMetadataChanges: true).listen(
(QuerySnapshot snapshot) async {
await tempSub?.cancel();
if (snapshot.docs.isEmpty || snapshot.docs.first.metadata.hasPendingWrites) return;
_docs.insert(0, snapshot.docs.first);
// To handle newly added data after this curently loaded data.
await _setLiveListener();
// Set updates listener for the newly added data.
_loadDocuments(getMore: false);
},
);
}
/// To handle scroll end event and load more data.
void _scrollListener() {
if (_isInitialLoading || _isFetching || _isEnded) return;
if (!_controller.hasClients) return;
final position = _controller.position;
if (position.pixels >= (position.maxScrollExtent - 50)) {
_loadDocuments();
}
}
@override
void initState() {
super.initState();
_loadDocuments();
_controller.addListener(_scrollListener);
}
@override
void dispose() {
_streamSub?.cancel();
_liveStreamSub?.cancel();
_controller
..removeListener(_scrollListener)
..dispose();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _isInitialLoading
? widget.initialLoader
: _docs.isEmpty
? widget.onEmpty
: BuildPagination(
items: _docs,
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder ?? separatorBuilder,
isLoading: _isFetching,
viewType: widget.viewType,
bottomLoader: widget.bottomLoader,
gridDelegate: widget.gridDelegate,
wrapOptions: widget.wrapOptions,
pageOptions: widget.pageOptions,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
controller: _controller,
pageController: _pageController,
shrinkWrap: widget.shrinkWrap,
physics: widget.physics,
padding: widget.padding,
onPageChanged: (index) {
if (index >= _docs.length - 1) _loadDocuments();
},
);
}
}

View File

@@ -0,0 +1,7 @@
// Flutter Packages
import 'package:flutter/widgets.dart';
/// Returns a [Widget] to be render as separator in a [ListView].
Widget separatorBuilder(BuildContext context, int index) {
return const SizedBox.shrink();
}

View File

@@ -0,0 +1,69 @@
// Flutter Packages
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
// Data Models
import 'view_type.dart';
/// The properties of the [PageView] widget in the [ViewType.page] view.
class PageOptions {
/// Creates a object that contains the properties of the [PageView] widget.
const PageOptions({
this.clipBehavior = Clip.hardEdge,
this.pageSnapping = true,
this.padEnds = true,
this.scrollBehavior,
this.allowImplicitScrolling = false,
this.dragStartBehavior = DragStartBehavior.start,
});
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// Set to false to disable page snapping, useful for custom scroll behavior.
///
/// If the [padEnds] is false and [PageController.viewportFraction] < 1.0,
/// the page will snap to the beginning of the viewport; otherwise, the page
/// will snap to the center of the viewport.
final bool pageSnapping;
/// Whether to add padding to both ends of the list.
///
/// If this is set to true and [PageController.viewportFraction] < 1.0, padding will be added
/// such that the first and last child slivers will be in the center of
/// the viewport when scrolled all the way to the start or end, respectively.
///
/// If [PageController.viewportFraction] >= 1.0, this property has no effect.
///
/// This property defaults to true.
final bool padEnds;
/// {@macro flutter.widgets.shadow.scrollBehavior}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [physics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
///
/// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be
/// modified by default to not apply a [Scrollbar].
final ScrollBehavior? scrollBehavior;
/// Controls whether the widget's pages will respond to
/// [RenderObject.showOnScreen], which will allow for implicit accessibility
/// scrolling.
///
/// With this flag set to false, when accessibility focus reaches the end of
/// the current page and the user attempts to move it to the next element, the
/// focus will traverse to the next widget outside of the page view.
///
/// With this flag set to true, when accessibility focus reaches the end of
/// the current page and user attempts to move it to the next element, focus
/// will traverse to the next page in the page view.
final bool allowImplicitScrolling;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
}

View File

@@ -0,0 +1,19 @@
// Flutter Packages
import 'package:flutter/widgets.dart';
/// The [ScrollView] to use for the loaded data.
///
/// Supports [list], [grid], and [wrap].
enum ViewType {
/// Loads the data as a [ListView].
list,
/// Loads the data as a [GridView].
grid,
/// Loads the data as a scrollable [Wrap].
wrap,
/// Loads the data as a [PageView].
page,
}

View File

@@ -0,0 +1,67 @@
// Flutter Packages
import 'package:flutter/widgets.dart';
// Data Models
import 'view_type.dart';
/// The properties of the [Wrap] widget in the [ViewType.wrap] view.
class WrapOptions {
/// Creates a object that contains the properties of the [Wrap] widget.
const WrapOptions({
this.direction = Axis.horizontal,
this.alignment = WrapAlignment.center,
this.spacing = 5.0,
this.runAlignment = WrapAlignment.start,
this.runSpacing = 5.0,
this.crossAxisAlignment = WrapCrossAlignment.start,
this.textDirection,
this.verticalDirection = VerticalDirection.down,
this.clipBehavior = Clip.none,
});
/// The direction to use as the main axis.
///
/// Defaults to [Axis.horizontal].
final Axis direction;
/// How the children within a run should be placed in the main axis.
///
/// Defaults to [WrapAlignment.center].
final WrapAlignment alignment;
/// How much space to place between children in a run in the main axis.
///
/// Defaults to 5.0.
final double spacing;
/// How the runs themselves should be placed in the cross axis.
///
/// Defaults to [WrapAlignment.start].
final WrapAlignment runAlignment;
/// How much space to place between the runs themselves in the cross axis.
///
/// Defaults to 5.0.
final double runSpacing;
/// How the children within a run should be aligned relative to each other in
/// the cross axis.
///
/// Defaults to [WrapCrossAlignment.start].
final WrapCrossAlignment crossAxisAlignment;
/// Determines the order to lay children out horizontally and how to interpret
/// `start` and `end` in the horizontal direction.
final TextDirection? textDirection;
/// Determines the order to lay children out vertically and how to interpret
/// `start` and `end` in the vertical direction.
///
/// Defaults to [VerticalDirection.down].
final VerticalDirection verticalDirection;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.none].
final Clip clipBehavior;
}

View File

@@ -0,0 +1,400 @@
// Dart Packages
import 'dart:async';
// Firebase Packages
import 'package:firebase_database/firebase_database.dart';
// Flutter Packages
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
// Functions
import 'functions/separator_builder.dart';
// Data Models
import 'models/page_options.dart';
import 'models/view_type.dart';
import 'models/wrap_options.dart';
// Widgets
import 'widgets/defaults/bottom_loader.dart';
import 'widgets/defaults/empty_screen.dart';
import 'widgets/defaults/initial_loader.dart';
import 'widgets/views/build_pagination.dart';
/// A [StreamBuilder] that automatically loads more data when the user scrolls
/// to the bottom.
///
/// Optimized for [FirebaseDatabase] with fields like `createdAt` and
/// `timestamp` to sort the data.
///
/// Supports live updates and realtime updates to loaded data.
///
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
class RealtimeDBPagination extends StatefulWidget {
/// Creates a [StreamBuilder] widget that automatically loads more data when
/// the user scrolls to the bottom.
///
/// Optimized for [FirebaseDatabase] with fields like `createdAt` and
/// `timestamp` to sort the data.
///
/// Supports live updates and realtime updates to loaded data.
///
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
const RealtimeDBPagination({
required this.query,
required this.itemBuilder,
required this.orderBy,
super.key,
this.descending = false,
this.separatorBuilder,
this.limit = 10,
this.viewType = ViewType.list,
this.isLive = false,
this.gridDelegate = const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
this.wrapOptions = const WrapOptions(),
this.pageOptions = const PageOptions(),
this.onEmpty = const EmptyScreen(),
this.bottomLoader = const BottomLoader(),
this.initialLoader = const InitialLoader(),
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.shrinkWrap = false,
this.physics,
this.padding,
this.controller,
this.pageController,
});
/// The query to use to fetch data from Firebase Realtime Database.
///
/// ### Note:
/// - The query must **NOT** contain a `limitToFirst` or `limitToLast` itself.
/// - The `limit` must be set using the [limit] property of this widget.
final Query query;
/// The builder to use to build the items in the list.
///
/// The builder is passed the build context, snapshot of data and index of
/// the item in the list.
final Widget Function(BuildContext, List<DataSnapshot>, int) itemBuilder;
/// The field to use to sort the data. Give the same value as the field
/// used to order the data in the query.
///
/// ## Example
/// If the query is:
/// ```dart
/// FirebaseDatabase.instance.ref('messages').orderByChild('createdAt')
/// ```
/// Then the value of [orderBy] should be `createdAt`.
///
/// If null, the data will be sorted by the key.
final String? orderBy;
/// Fetches data is decending order for the given [orderBy] field.
///
/// Default value is `false`.
final bool descending;
/// The builder to use to render the separator.
///
/// Only used if [viewType] is [ViewType.list].
///
/// Default [Widget] is [SizedBox.shrink].
final Widget Function(BuildContext, int)? separatorBuilder;
/// The number of items to fetch from Firebase Realtime Database at once.
///
/// Defaults to `10`.
final int limit;
/// The type of view to use for the list.
///
/// Defaults to [ViewType.list].
final ViewType viewType;
/// Whether to fetch newly added items as they are added to
/// Firebase Realtime Database.
///
/// Defaults to `false`.
final bool isLive;
/// The delegate to use for the [GridView].
///
/// Defaults to [SliverGridDelegateWithFixedCrossAxisCount].
final SliverGridDelegate gridDelegate;
/// The [Wrap] widget properties to use.
///
/// Defaults to [WrapOptions].
final WrapOptions wrapOptions;
/// The [PageView] properties to use.
///
/// Defaults to [PageOptions].
final PageOptions pageOptions;
/// The widget to use when data is empty.
///
/// Defaults to [EmptyScreen].
final Widget onEmpty;
/// The widget to use when more data is loading.
///
/// Defaults to [BottomLoader].
final Widget bottomLoader;
/// The widget to use when data is loading initially.
///
/// Defaults to [InitialLoader].
final Widget initialLoader;
/// The scrolling direction of the [ScrollView].
final Axis scrollDirection;
/// Whether the [ScrollView] scrolls in the reading direction.
final bool reverse;
/// Should the [ScrollView] be shrink-wrapped.
final bool shrinkWrap;
/// The scroll behavior to use for the [ScrollView].
final ScrollPhysics? physics;
/// The padding to use for the [ScrollView].
final EdgeInsetsGeometry? padding;
/// The scroll controller to use for the [ScrollView].
///
/// Defaults to [ScrollController].
final ScrollController? controller;
/// The page controller to use for the [PageView].
///
/// Defaults to [PageController].
final PageController? pageController;
@override
State<RealtimeDBPagination> createState() => _RealtimeDBPaginationState();
}
/// The state of the [RealtimeDBPagination] widget.
class _RealtimeDBPaginationState extends State<RealtimeDBPagination> {
/// All the data that has been loaded from Firebase Realtime Database.
final List<DataSnapshot> _data = [];
/// Snapshot subscription for the query.
///
/// Also handles updates to loaded data.
StreamSubscription<DatabaseEvent>? _streamSub;
/// Snapshot subscription for the query to handle newly added data.
StreamSubscription<DatabaseEvent>? _liveStreamSub;
/// [ScrollController] to listen to scroll end and load more data.
late final ScrollController _controller = widget.controller ?? ScrollController();
/// [PageController] to listen to page changes and load more data.
late final PageController _pageController = widget.pageController ?? PageController();
/// Whether initial data is loading.
bool _isInitialLoading = true;
/// Whether more data is loading.
bool _isFetching = false;
/// Whether the end for given query has been reached.
///
/// This is used to determine if more data should be loaded when the user
/// scrolls to the bottom.
bool _isEnded = false;
/// Loads more data from Firebase Realtime Database and handles
/// updates to loaded data.
///
/// Setting [getMore] to `false` will only set listener for the
/// currently loaded data.
Future<void> _loadData({bool getMore = true}) async {
// To cancel previous updates listener when new one is set.
final tempSub = _streamSub;
if (getMore) setState(() => _isFetching = true);
// Sets limit of nodes to fetch.
// If currently 15 items are loaded, and limit is 5 then total 20 items
// will be fetched including the ones already present.
final docsLimit = _data.length + (getMore ? widget.limit : 0);
var docsQuery = widget.descending ? widget.query.limitToLast(docsLimit) : widget.query.limitToFirst(docsLimit);
if (_data.isNotEmpty) {
if (widget.descending) {
// Sets ending point from where before data should be fetched.
// If currently 15 items are loaded, and limit is 5 then total 20 items
// will be fetched where below mentioned value will be the largest and
// last in the fetched array (But first in callback array as using
// reversed in build method)
docsQuery = docsQuery.endAt(
Map<String, dynamic>.from(
_data.last.value! as Map<Object?, Object?>,
)[widget.orderBy],
);
} else {
// Sets starting point from where after data should be fetched.
// If currently 15 items are loaded, and limit is 5 then total 20 items
// will be fetched where below mentioned value will be the smallest and
// first in array
docsQuery = docsQuery.startAt(
Map<String, dynamic>.from(
_data.first.value! as Map<Object?, Object?>,
)[widget.orderBy],
);
}
}
_streamSub = docsQuery.onValue.listen((DatabaseEvent snapshot) async {
await tempSub?.cancel();
_data
..clear()
..addAll(snapshot.snapshot.children);
// To set new updates listener for the existing data
// or to set new live listener if the first data node is removed.
final isDataRemoved = snapshot.type == DatabaseEventType.childRemoved;
_isFetching = false;
if (!isDataRemoved) {
_isEnded = snapshot.snapshot.children.length < docsLimit;
}
if (isDataRemoved || _isInitialLoading) {
_isInitialLoading = false;
if (snapshot.snapshot.children.isNotEmpty) {
// Set updates listener for the existing data starting from the
// first data node only.
await _loadData(getMore: false);
} else {
_streamSub?.cancel();
}
if (widget.isLive) _setLiveListener();
}
if (mounted) setState(() {});
// Add data till the view is scrollable. This ensures that the user can
// scroll to the bottom and load more data.
if (_isInitialLoading || _isFetching || _isEnded) return;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_controller.hasClients && _controller.position.maxScrollExtent <= 0) {
_loadData();
}
});
});
}
/// Sets the live listener for the query.
///
/// Fires when new data is added to the query.
Future<void> _setLiveListener() async {
// To cancel previous live listener when new one is set.
final tempSub = _liveStreamSub;
var latestDocQuery = widget.descending ? widget.query.limitToLast(1) : widget.query.limitToFirst(1);
if (_data.isNotEmpty) {
if (widget.descending) {
// Sets query to fetch data after the last element in the array,
// which is the largest value.
latestDocQuery = latestDocQuery.startAfter(
Map<String, dynamic>.from(
_data.last.value! as Map<Object?, Object?>,
)[widget.orderBy],
);
} else {
// Sets query to fetch data before the first element in the array,
// whch is the smallest value
latestDocQuery = latestDocQuery.endBefore(
Map<String, dynamic>.from(
_data.first.value! as Map<Object?, Object?>,
)[widget.orderBy],
);
}
}
_liveStreamSub = latestDocQuery.onValue.listen(
(DatabaseEvent snapshot) async {
await tempSub?.cancel();
if (snapshot.snapshot.children.isEmpty) return;
_data.insert(
widget.descending ? _data.length : 0,
snapshot.snapshot.children.first,
);
// To handle newly added data after this curently loaded data.
await _setLiveListener();
// Set updates listener for the newly added data.
_loadData(getMore: false);
},
);
}
/// To handle scroll end event and load more data.
void _scrollListener() {
if (_isInitialLoading || _isFetching || _isEnded) return;
if (!_controller.hasClients) return;
final position = _controller.position;
if (position.pixels >= (position.maxScrollExtent - 50)) {
_loadData();
}
}
@override
void initState() {
super.initState();
_loadData();
_controller.addListener(_scrollListener);
}
@override
void dispose() {
_streamSub?.cancel();
_liveStreamSub?.cancel();
_controller
..removeListener(_scrollListener)
..dispose();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _isInitialLoading
? widget.initialLoader
: _data.isEmpty
? widget.onEmpty
: BuildPagination(
items: widget.descending ? _data.reversed.toList() : _data,
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder ?? separatorBuilder,
isLoading: _isFetching,
viewType: widget.viewType,
bottomLoader: widget.bottomLoader,
gridDelegate: widget.gridDelegate,
wrapOptions: widget.wrapOptions,
pageOptions: widget.pageOptions,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
controller: _controller,
pageController: _pageController,
shrinkWrap: widget.shrinkWrap,
physics: widget.physics,
padding: widget.padding,
onPageChanged: (index) {
if (index >= _data.length - 1) _loadData();
},
);
}
}

View File

@@ -0,0 +1,28 @@
// Flutter Packages
import 'package:flutter/material.dart';
/// A circular progress indicator that spins when the [Stream] is loading.
///
/// Used at the bottom of a [ScrollView] to indicate that more data is loading.
class BottomLoader extends StatelessWidget {
/// Creates a circular progress indicator that spins when the [Stream] is
/// loading.
///
/// Used at the bottom of a [ScrollView] to indicate that more data is
/// loading.
const BottomLoader({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 25,
height: 25,
margin: const EdgeInsets.all(10),
child: const CircularProgressIndicator.adaptive(
strokeWidth: 2.5,
),
),
);
}
}

View File

@@ -0,0 +1,16 @@
// Flutter Packages
import 'package:flutter/material.dart';
import 'package:get/get.dart';
/// A [Widget] to show when there is no data to display.
class EmptyScreen extends StatelessWidget {
/// Creates a [Widget] to show when there is no data to display.
const EmptyScreen({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text('Nothing found here...'.tr),
);
}
}

View File

@@ -0,0 +1,20 @@
// Flutter Packages
import 'package:flutter/material.dart';
/// A circular progress indicator that spins when the [Stream] is loading.
///
/// Used when the [Stream] is loading the first time.
class InitialLoader extends StatelessWidget {
/// Creates a circular progress indicator that spins when the [Stream] is
/// loading.
///
/// Used when the [Stream] is loading the first time.
const InitialLoader({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
}

View File

@@ -0,0 +1,177 @@
// Flutter Packages
import 'package:customer/widget/firebase_pagination/src/models/page_options.dart';
import 'package:customer/widget/firebase_pagination/src/models/view_type.dart';
import 'package:customer/widget/firebase_pagination/src/models/wrap_options.dart';
import 'package:flutter/material.dart';
/// A [ScrollView] to use for the provided [items].
///
/// The [items] are loaded into the [ScrollView] based on the [viewType].
class BuildPagination<T> extends StatelessWidget {
/// Creates a [ScrollView] to use for the provided [items].
///
/// The [items] are rendered in the [ScrollView] using the [itemBuilder].
///
/// The [viewType] determines the type of [ScrollView] to use.
const BuildPagination({
super.key,
required this.items,
required this.itemBuilder,
required this.separatorBuilder,
required this.isLoading,
required this.viewType,
required this.bottomLoader,
required this.gridDelegate,
required this.wrapOptions,
required this.pageOptions,
required this.scrollDirection,
required this.reverse,
required this.controller,
required this.pageController,
required this.shrinkWrap,
this.physics,
this.padding,
this.onPageChanged,
});
/// The items to display in the [ScrollView].
final List<T> items;
/// The builder to use to render the items.
final Widget Function(BuildContext, List<T>, int) itemBuilder;
/// The builder to use to render the separator.
///
/// Only used if [viewType] is [ViewType.list].
final Widget Function(BuildContext, int) separatorBuilder;
/// Whether more [items] are being loaded.
final bool isLoading;
/// The type of [ScrollView] to use.
final ViewType viewType;
/// A [Widget] to show when more [items] are being loaded.
final Widget bottomLoader;
/// The delegate to use for the [GridView].
final SliverGridDelegate gridDelegate;
/// The options to use for the [Wrap].
final WrapOptions wrapOptions;
/// The options to use for the [PageView].
final PageOptions pageOptions;
/// The scrolling direction of the [ScrollView].
final Axis scrollDirection;
/// Whether the [ScrollView] scrolls in the reading direction.
final bool reverse;
/// The scroll controller to handle the scroll events.
final ScrollController controller;
/// The page controller to handle page view events.
final PageController pageController;
/// Should the [ScrollView] be shrink-wrapped.
final bool shrinkWrap;
/// The scroll behavior to use for the [ScrollView].
final ScrollPhysics? physics;
/// The padding to use for the [ScrollView].
final EdgeInsetsGeometry? padding;
/// Specifies what to do when page changes in the [PageView].
final void Function(int)? onPageChanged;
@override
Widget build(BuildContext context) {
switch (viewType) {
case ViewType.list:
return ListView.separated(
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
itemCount: items.length + (isLoading ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index >= items.length) return bottomLoader;
return itemBuilder(context, items, index);
},
separatorBuilder: separatorBuilder,
);
case ViewType.grid:
return GridView.builder(
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
itemCount: items.length + (isLoading ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index >= items.length) return bottomLoader;
return itemBuilder(context, items, index);
},
gridDelegate: gridDelegate,
);
case ViewType.wrap:
return SingleChildScrollView(
scrollDirection: scrollDirection,
reverse: reverse,
padding: padding,
physics: physics,
controller: controller,
child: Wrap(
direction: wrapOptions.direction,
alignment: wrapOptions.alignment,
spacing: wrapOptions.spacing,
runAlignment: wrapOptions.runAlignment,
runSpacing: wrapOptions.runSpacing,
crossAxisAlignment: wrapOptions.crossAxisAlignment,
textDirection: wrapOptions.textDirection,
verticalDirection: wrapOptions.verticalDirection,
clipBehavior: wrapOptions.clipBehavior,
children: List.generate(
items.length + (isLoading ? 1 : 0),
(int index) {
if (index >= items.length) return bottomLoader;
return itemBuilder(context, items, index);
},
),
),
);
case ViewType.page:
return PageView.builder(
scrollDirection: scrollDirection,
reverse: reverse,
controller: pageController,
physics: physics,
clipBehavior: pageOptions.clipBehavior,
pageSnapping: pageOptions.pageSnapping,
onPageChanged: onPageChanged,
padEnds: pageOptions.padEnds,
scrollBehavior: pageOptions.scrollBehavior,
allowImplicitScrolling: pageOptions.allowImplicitScrolling,
dragStartBehavior: pageOptions.dragStartBehavior,
itemCount: items.length + (isLoading ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index >= items.length) return bottomLoader;
return itemBuilder(context, items, index);
},
);
}
}
}

View File

@@ -0,0 +1,6 @@
library;
export 'src/collection/default.dart';
export 'src/geoflutterfire.dart';
export 'src/models/distance_doc_snapshot.dart';
export 'src/models/point.dart';

View File

@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';
import '../models/distance_doc_snapshot.dart';
import '../models/point.dart';
import '../utils/arrays.dart';
import '../utils/math.dart';
class BaseGeoFireCollectionRef<T> {
final Query<T> _collectionReference;
late final Stream<QuerySnapshot<T>>? _stream;
BaseGeoFireCollectionRef(this._collectionReference) {
_stream = _createStream(_collectionReference).shareReplay(maxSize: 1);
}
/// return QuerySnapshot stream
Stream<QuerySnapshot<T>>? snapshot() {
return _stream;
}
/// return the Document mapped to the [id]
Stream<List<DocumentSnapshot<T>>> data(String id) {
return _stream!.map((querySnapshot) {
querySnapshot.docs.where((documentSnapshot) {
return documentSnapshot.id == id;
});
return querySnapshot.docs;
});
}
/// add a document to collection with [data]
Future<DocumentReference<T>> add(
T data,
) {
try {
final colRef = _collectionReference as CollectionReference<T>;
return colRef.add(data);
} catch (e) {
throw Exception('cannot call add on Query, use collection reference instead');
}
}
/// delete document with [id] from the collection
Future<void> delete(id) {
try {
CollectionReference colRef = _collectionReference as CollectionReference;
return colRef.doc(id).delete();
} catch (e) {
throw Exception('cannot call delete on Query, use collection reference instead');
}
}
/// create or update a document with [id], [merge] defines whether the document should overwrite
Future<void> setDoc(String id, Object? data, {bool merge = false}) {
try {
CollectionReference colRef = _collectionReference as CollectionReference;
return colRef.doc(id).set(data, SetOptions(merge: merge));
} catch (e) {
throw Exception('cannot call set on Query, use collection reference instead');
}
}
/// set a geo point with [latitude] and [longitude] using [field] as the object key to the document with [id]
Future<void> setPoint(
String id,
String field,
double latitude,
double longitude,
) {
try {
CollectionReference colRef = _collectionReference as CollectionReference;
var point = GeoFirePoint(latitude, longitude).data;
return colRef.doc(id).set({field: point}, SetOptions(merge: true));
} catch (e) {
throw Exception('cannot call set on Query, use collection reference instead');
}
}
@protected
Stream<List<DocumentSnapshot<T>>> protectedWithin({
required GeoFirePoint center,
required double radius,
required String field,
required GeoPoint? Function(T t) geopointFrom,
required bool? strictMode,
}) =>
protectedWithinWithDistance(
center: center,
radius: radius,
field: field,
geopointFrom: geopointFrom,
strictMode: strictMode,
).map((snapshots) => snapshots.map((snapshot) => snapshot.documentSnapshot).toList());
/// query firestore documents based on geographic [radius] from geoFirePoint [center]
/// [field] specifies the name of the key in the document
@protected
Stream<List<DistanceDocSnapshot<T>>> protectedWithinWithDistance({
required GeoFirePoint center,
required double radius,
required String field,
required GeoPoint? Function(T t) geopointFrom,
required bool? strictMode,
}) {
final nonNullStrictMode = strictMode ?? false;
final precision = MathUtils.setPrecision(radius);
final centerHash = center.hash.substring(0, precision);
final area = GeoFirePoint.neighborsOf(hash: centerHash)..add(centerHash);
final queries = area.map((hash) {
final tempQuery = _queryPoint(hash, field);
return _createStream(tempQuery).map((querySnapshot) {
return querySnapshot.docs;
});
});
final mergedObservable = mergeObservable(queries);
final filtered = mergedObservable.map((list) {
final mappedList = list.map((documentSnapshot) {
final snapData = documentSnapshot.exists ? documentSnapshot.data() : null;
assert(snapData != null, 'Data in one of the docs is empty');
if (snapData == null) return null;
// We will handle it to fail gracefully
final geoPoint = geopointFrom(snapData);
assert(geoPoint != null, 'Couldnt find geopoint from stored data');
if (geoPoint == null) return null;
// We will handle it to fail gracefully
final kmDistance = center.kmDistance(
lat: geoPoint.latitude,
lng: geoPoint.longitude,
);
return DistanceDocSnapshot(
documentSnapshot: documentSnapshot,
kmDistance: kmDistance,
);
});
final nullableFilteredList = nonNullStrictMode
? mappedList
.where((doc) => doc != null && doc.kmDistance <= radius * 1.02 // buffer for edge distances;
)
.toList()
: mappedList.toList();
final filteredList = nullableFilteredList.whereNotNull().toList();
filteredList.sort(
(a, b) => (a.kmDistance * 1000).toInt() - (b.kmDistance * 1000).toInt(),
);
return filteredList;
});
return filtered.asBroadcastStream();
}
Stream<List<QueryDocumentSnapshot<T>>> mergeObservable(
Iterable<Stream<List<QueryDocumentSnapshot<T>>>> queries,
) {
final mergedObservable = Rx.combineLatest<List<QueryDocumentSnapshot<T>>, List<QueryDocumentSnapshot<T>>>(queries, (originalList) {
final reducedList = <QueryDocumentSnapshot<T>>[];
for (final t in originalList) {
reducedList.addAll(t);
}
return reducedList;
});
return mergedObservable;
}
/// INTERNAL FUNCTIONS
/// construct a query for the [geoHash] and [field]
Query<T> _queryPoint(String geoHash, String field) {
final end = '$geoHash~';
final temp = _collectionReference;
return temp.orderBy('$field.geohash').startAt([geoHash]).endAt([end]);
}
/// create an observable for [ref], [ref] can be [Query] or [CollectionReference]
Stream<QuerySnapshot<T>> _createStream(Query<T> ref) {
return ref.snapshots();
}
}

View File

@@ -0,0 +1,64 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:customer/widget/geoflutterfire/src/models/distance_doc_snapshot.dart';
import 'package:customer/widget/geoflutterfire/src/models/point.dart';
import 'package:flutter/cupertino.dart';
import 'base.dart';
class GeoFireCollectionRef extends BaseGeoFireCollectionRef<Map<String, dynamic>> {
GeoFireCollectionRef(super.collectionReference);
Stream<List<DocumentSnapshot<Map<String, dynamic>>>> within({
required GeoFirePoint center,
required double radius,
required String field,
bool? strictMode,
}) {
return protectedWithin(
center: center,
radius: radius,
field: field,
geopointFrom: (snapData) => geopointFromMap(
field: field,
snapData: snapData,
),
strictMode: strictMode,
);
}
Stream<List<DistanceDocSnapshot<Map<String, dynamic>>>> withinWithDistance({
required GeoFirePoint center,
required double radius,
required String field,
bool? strictMode,
}) {
return protectedWithinWithDistance(
center: center,
radius: radius,
field: field,
geopointFrom: (snapData) => geopointFromMap(
field: field,
snapData: snapData,
),
strictMode: strictMode,
);
}
@visibleForTesting
static GeoPoint? geopointFromMap({
required String field,
required Map<String, dynamic> snapData,
}) {
// split and fetch geoPoint from the nested Map
final fieldList = field.split('.');
Map<dynamic, dynamic>? geoPointField = snapData[fieldList[0]];
if (fieldList.length > 1) {
for (int i = 1; i < fieldList.length; i++) {
geoPointField = geoPointField?[fieldList[i]];
}
}
return geoPointField?['geopoint'] as GeoPoint?;
}
}

View File

@@ -0,0 +1,43 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:customer/widget/geoflutterfire/src/models/distance_doc_snapshot.dart';
import 'package:customer/widget/geoflutterfire/src/models/point.dart';
import 'base.dart';
class GeoFireCollectionWithConverterRef<T> extends BaseGeoFireCollectionRef<T> {
GeoFireCollectionWithConverterRef(super.collectionReference);
Stream<List<DocumentSnapshot<T>>> within({
required GeoFirePoint center,
required double radius,
required String field,
required GeoPoint Function(T) geopointFrom,
bool? strictMode,
}) {
return protectedWithin(
center: center,
radius: radius,
field: field,
geopointFrom: geopointFrom,
strictMode: strictMode,
);
}
Stream<List<DistanceDocSnapshot<T>>> withinWithDistance({
required GeoFirePoint center,
required double radius,
required String field,
required GeoPoint Function(T) geopointFrom,
bool? strictMode,
}) {
return protectedWithinWithDistance(
center: center,
radius: radius,
field: field,
geopointFrom: geopointFrom,
strictMode: strictMode,
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:customer/widget/geoflutterfire/src/collection/with_converter.dart';
import 'collection/default.dart';
import 'models/point.dart';
class Geoflutterfire {
Geoflutterfire();
GeoFireCollectionRef collection({
required Query<Map<String, dynamic>> collectionRef,
}) {
return GeoFireCollectionRef(collectionRef);
}
GeoFireCollectionWithConverterRef<T> collectionWithConverter<T>({
required Query<T> collectionRef,
}) {
return GeoFireCollectionWithConverterRef<T>(collectionRef);
}
GeoFireCollectionRef customCollection({
required Query<Map<String, dynamic>> collectionRef,
}) {
return GeoFireCollectionRef(collectionRef);
}
GeoFirePoint point({required double latitude, required double longitude}) {
return GeoFirePoint(latitude, longitude);
}
}

View File

@@ -0,0 +1,11 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class DistanceDocSnapshot<T> {
final DocumentSnapshot<T> documentSnapshot;
final double kmDistance;
DistanceDocSnapshot({
required this.documentSnapshot,
required this.kmDistance,
});
}

View File

@@ -0,0 +1,60 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../utils/math.dart';
class GeoFirePoint {
static final MathUtils _util = MathUtils();
double latitude, longitude;
GeoFirePoint(this.latitude, this.longitude);
/// return geographical distance between two Co-ordinates
static double kmDistanceBetween({required Coordinates to, required Coordinates from}) {
return MathUtils.kmDistance(to, from);
}
/// return neighboring geo-hashes of [hash]
static List<String> neighborsOf({required String hash}) {
return _util.neighbors(hash);
}
/// return hash of [GeoFirePoint]
String get hash {
return _util.encode(latitude, longitude, 9);
}
/// return all neighbors of [GeoFirePoint]
List<String> get neighbors {
return _util.neighbors(hash);
}
/// return [GeoPoint] of [GeoFirePoint]
GeoPoint get geoPoint {
return GeoPoint(latitude, longitude);
}
Coordinates get coords {
return Coordinates(latitude, longitude);
}
/// return distance between [GeoFirePoint] and ([lat], [lng])
double kmDistance({required double lat, required double lng}) {
return kmDistanceBetween(from: coords, to: Coordinates(lat, lng));
}
Map<String, Object> get data {
return {'geopoint': geoPoint, 'geohash': hash};
}
/// haversine distance between [GeoFirePoint] and ([lat], [lng])
double haversineDistance({required double lat, required double lng}) {
return GeoFirePoint.kmDistanceBetween(from: coords, to: Coordinates(lat, lng));
}
}
class Coordinates {
double latitude;
double longitude;
Coordinates(this.latitude, this.longitude);
}

View File

@@ -0,0 +1,5 @@
extension NullableListExtensions<T> on Iterable<T?> {
Iterable<T> whereNotNull() {
return where((e) => e != null).map((e) => e as T);
}
}

View File

@@ -0,0 +1,271 @@
import 'dart:math';
import '../models/point.dart';
class MathUtils {
static const base32Codes = '0123456789bcdefghjkmnpqrstuvwxyz';
Map<String, int> base32CodesDic = {};
MathUtils() {
for (var i = 0; i < base32Codes.length; i++) {
base32CodesDic.putIfAbsent(base32Codes[i], () => i);
}
}
var encodeAuto = 'auto';
///
/// Significant Figure Hash Length
///
/// This is a quick and dirty lookup to figure out how long our hash
/// should be in order to guarantee a certain amount of trailing
/// significant figures. This was calculated by determining the error:
/// 45/2^(n-1) where n is the number of bits for a latitude or
/// longitude. Key is # of desired sig figs, value is minimum length of
/// the geohash.
/// @type Array
// Desired sig figs: 0 1 2 3 4 5 6 7 8 9 10
var sigfigHashLength = [0, 5, 7, 8, 11, 12, 13, 15, 16, 17, 18];
///
/// Encode
/// Create a geohash from latitude and longitude
/// that is 'number of chars' long
String encode(var latitude, var longitude, var numberOfChars) {
if (numberOfChars == encodeAuto) {
if (latitude.runtimeType == double || longitude.runtimeType == double) {
throw Exception('string notation required for auto precision.');
}
int decSigFigsLat = latitude.split('.')[1].length;
int decSigFigsLon = longitude.split('.')[1].length;
int numberOfSigFigs = max(decSigFigsLat, decSigFigsLon);
numberOfChars = sigfigHashLength[numberOfSigFigs];
} else {
numberOfChars ??= 9;
}
var chars = [], bits = 0, bitsTotal = 0, hashValue = 0;
double maxLat = 90, minLat = -90, maxLon = 180, minLon = -180, mid;
while (chars.length < numberOfChars) {
if (bitsTotal % 2 == 0) {
mid = (maxLon + minLon) / 2;
if (longitude > mid) {
hashValue = (hashValue << 1) + 1;
minLon = mid;
} else {
hashValue = (hashValue << 1) + 0;
maxLon = mid;
}
} else {
mid = (maxLat + minLat) / 2;
if (latitude > mid) {
hashValue = (hashValue << 1) + 1;
minLat = mid;
} else {
hashValue = (hashValue << 1) + 0;
maxLat = mid;
}
}
bits++;
bitsTotal++;
if (bits == 5) {
var code = base32Codes[hashValue];
chars.add(code);
bits = 0;
hashValue = 0;
}
}
return chars.join('');
}
///
/// Decode Bounding box
///
/// Decode a hashString into a bound box that matches it.
/// Data returned in a List [minLat, minLon, maxLat, maxLon]
List<double> decodeBbox(String hashString) {
var isLon = true;
double maxLat = 90, minLat = -90, maxLon = 180, minLon = -180, mid;
int? hashValue = 0;
for (var i = 0, l = hashString.length; i < l; i++) {
var code = hashString[i].toLowerCase();
hashValue = base32CodesDic[code];
for (var bits = 4; bits >= 0; bits--) {
var bit = (hashValue! >> bits) & 1;
if (isLon) {
mid = (maxLon + minLon) / 2;
if (bit == 1) {
minLon = mid;
} else {
maxLon = mid;
}
} else {
mid = (maxLat + minLat) / 2;
if (bit == 1) {
minLat = mid;
} else {
maxLat = mid;
}
}
isLon = !isLon;
}
}
return [minLat, minLon, maxLat, maxLon];
}
///
/// Decode a [hashString] into a pair of latitude and longitude.
/// A map is returned with keys 'latitude', 'longitude','latitudeError','longitudeError'
Map<String, double> decode(String hashString) {
List<double> bbox = decodeBbox(hashString);
double lat = (bbox[0] + bbox[2]) / 2;
double lon = (bbox[1] + bbox[3]) / 2;
double latErr = bbox[2] - lat;
double lonErr = bbox[3] - lon;
return {
'latitude': lat,
'longitude': lon,
'latitudeError': latErr,
'longitudeError': lonErr,
};
}
///
/// Neighbor
///
/// Find neighbor of a geohash string in certain direction.
/// Direction is a two-element array, i.e. [1,0] means north, [-1,-1] means southwest.
///
/// direction [lat, lon], i.e.
/// [1,0] - north
/// [1,1] - northeast
String neighbor(String hashString, var direction) {
var lonLat = decode(hashString);
var neighborLat = lonLat['latitude']! + direction[0] * lonLat['latitudeError'] * 2;
var neighborLon = lonLat['longitude']! + direction[1] * lonLat['longitudeError'] * 2;
return encode(neighborLat, neighborLon, hashString.length);
}
///
/// Neighbors
/// Returns all neighbors' hashstrings clockwise from north around to northwest
/// 7 0 1
/// 6 X 2
/// 5 4 3
List<String> neighbors(String hashString) {
int hashStringLength = hashString.length;
var lonlat = decode(hashString);
double? lat = lonlat['latitude'];
double? lon = lonlat['longitude'];
double latErr = lonlat['latitudeError']! * 2;
double lonErr = lonlat['longitudeError']! * 2;
num neighborLat, neighborLon;
String encodeNeighbor(num neighborLatDir, num neighborLonDir) {
neighborLat = lat! + neighborLatDir * latErr;
neighborLon = lon! + neighborLonDir * lonErr;
return encode(neighborLat, neighborLon, hashStringLength);
}
var neighborHashList = [
encodeNeighbor(1, 0),
encodeNeighbor(1, 1),
encodeNeighbor(0, 1),
encodeNeighbor(-1, 1),
encodeNeighbor(-1, 0),
encodeNeighbor(-1, -1),
encodeNeighbor(0, -1),
encodeNeighbor(1, -1)
];
return neighborHashList;
}
static int setPrecision(double km) {
/*
* 1 ≤ 5,000km × 5,000km
* 2 ≤ 1,250km × 625km
* 3 ≤ 156km × 156km
* 4 ≤ 39.1km × 19.5km
* 5 ≤ 4.89km × 4.89km
* 6 ≤ 1.22km × 0.61km
* 7 ≤ 153m × 153m
* 8 ≤ 38.2m × 19.1m
* 9 ≤ 4.77m × 4.77m
*
*/
if (km <= 0.00477) {
return 9;
} else if (km <= 0.0382) {
return 8;
} else if (km <= 0.153) {
return 7;
} else if (km <= 1.22) {
return 6;
} else if (km <= 4.89) {
return 5;
} else if (km <= 39.1) {
return 4;
} else if (km <= 156) {
return 3;
} else if (km <= 1250) {
return 2;
} else {
return 1;
}
}
static const double maxSupportedRadius = 8587;
// Length of a degree latitude at the equator
static const double metersPerDegreeLatitude = 110574;
// The equatorial circumference of the earth in meters
static const double earthMeridionalCircumference = 40007860;
// The equatorial radius of the earth in meters
static const double earthEqRadius = 6378137;
// The meridional radius of the earth in meters
static const double earthPolarRadius = 6357852.3;
/* The following value assumes a polar radius of
* r_p = 6356752.3
* and an equatorial radius of
* r_e = 6378137
* The value is calculated as e2 == (r_e^2 - r_p^2)/(r_e^2)
* Use exact value to avoid rounding errors
*/
static const double earthE2 = 0.00669447819799;
// Cutoff for floating point calculations
static const double epsilon = 1e-12;
/// distance in km
static double kmDistance(Coordinates location1, Coordinates location2) {
return kmCalcDistance(location1.latitude, location1.longitude, location2.latitude, location2.longitude);
}
/// distance in km
static double kmCalcDistance(double lat1, double long1, double lat2, double long2) {
// Earth's mean radius in meters
const radius = (earthEqRadius + earthPolarRadius) / 2;
double latDelta = _toRadians(lat1 - lat2);
double lonDelta = _toRadians(long1 - long2);
double a = (sin(latDelta / 2) * sin(latDelta / 2)) + (cos(_toRadians(lat1)) * cos(_toRadians(lat2)) * sin(lonDelta / 2) * sin(lonDelta / 2));
double distance = radius * 2 * atan2(sqrt(a), sqrt(1 - a)) / 1000;
return double.parse(distance.toStringAsFixed(3));
}
static double _toRadians(double num) {
return num * (pi / 180.0);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
class GradientText extends StatelessWidget {
const GradientText(
this.text, {
super.key,
required this.gradient,
this.style,
});
final String text;
final TextStyle? style;
final Gradient gradient;
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => gradient.createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height),
),
child: Text(text, style: style),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class MySeparator extends StatelessWidget {
const MySeparator({super.key, this.height = 1, this.color = Colors.black});
final double height;
final Color color;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final boxWidth = constraints.constrainWidth();
const dashWidth = 5.0;
final dashHeight = height;
final dashCount = (boxWidth / (2 * dashWidth)).floor();
return Flex(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
direction: Axis.horizontal,
children: List.generate(dashCount, (_) {
return SizedBox(
width: dashWidth,
height: dashHeight,
child: DecoratedBox(
decoration: BoxDecoration(color: color),
),
);
}),
);
},
);
}
}

View File

@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:customer/widget/osm_map/place_model.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import '../../utils/utils.dart';
import 'package:latlong2/latlong.dart';
class OSMMapController extends GetxController {
final mapController = MapController();
// Store only one picked place instead of multiple
var pickedPlace = Rxn<PlaceModel>(); // Use Rxn to hold a nullable value
var searchResults = [].obs;
Future<void> searchPlace(String query) async {
if (query.length < 3) {
searchResults.clear();
return;
}
final url = Uri.parse(
'https://nominatim.openstreetmap.org/search?q=$query&format=json&addressdetails=1&limit=10');
final response = await http.get(url, headers: {
'User-Agent': 'FlutterMapApp/1.0 (menil.siddhiinfosoft@gmail.com)',
});
if (response.statusCode == 200) {
final data = json.decode(response.body);
searchResults.value = data;
}
}
void selectSearchResult(Map<String, dynamic> place) {
final lat = double.parse(place['lat']);
final lon = double.parse(place['lon']);
final address = place['display_name'];
// Store only the selected place
pickedPlace.value = PlaceModel(
coordinates: LatLng(lat, lon),
address: address,
);
searchResults.clear();
}
void addLatLngOnly(LatLng coords) async {
final address = await _getAddressFromLatLng(coords);
pickedPlace.value = PlaceModel(coordinates: coords, address: address);
}
Future<String> _getAddressFromLatLng(LatLng coords) async {
final url = Uri.parse(
'https://nominatim.openstreetmap.org/reverse?lat=${coords.latitude}&lon=${coords.longitude}&format=json');
final response = await http.get(url, headers: {
'User-Agent': 'FlutterMapApp/1.0 (menil.siddhiinfosoft@gmail.com)',
});
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['display_name'] ?? 'Unknown location';
} else {
return 'Unknown location';
}
}
void clearAll() {
pickedPlace.value = null; // Clear the selected place
}
@override
void onInit() {
// TODO: implement onInit
super.onInit();
getCurrentLocation();
}
Future<void> getCurrentLocation() async {
Position? location = await Utils.getCurrentLocation();
LatLng latlng =
LatLng(location?.latitude ?? 0.0, location?.longitude ?? 0.0);
addLatLngOnly(
LatLng(location?.latitude ?? 0.0, location?.longitude ?? 0.0));
mapController.move(latlng, mapController.camera.zoom);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:customer/themes/app_them_data.dart';
import 'package:customer/themes/round_button_fill.dart';
import 'package:customer/widget/osm_map/map_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import '../../controllers/theme_controller.dart';
class MapPickerPage extends StatelessWidget {
final OSMMapController controller = Get.put(OSMMapController());
final TextEditingController searchController = TextEditingController();
MapPickerPage({super.key});
@override
Widget build(BuildContext context) {
final themeController = Get.find<ThemeController>();
final isDark = themeController.isDark.value;
return Scaffold(
appBar: AppBar(
backgroundColor: isDark ? AppThemeData.surfaceDark : AppThemeData.surface,
centerTitle: false,
titleSpacing: 0,
title: Text("PickUp Location".tr, textAlign: TextAlign.start, style: TextStyle(fontFamily: AppThemeData.medium, fontSize: 16, color: isDark ? AppThemeData.grey50 : AppThemeData.grey900)),
),
body: Stack(
children: [
Obx(
() => FlutterMap(
mapController: controller.mapController,
options: MapOptions(
initialCenter: controller.pickedPlace.value?.coordinates ?? LatLng(20.5937, 78.9629), // Default India center
initialZoom: 13,
onTap: (tapPos, latlng) {
controller.addLatLngOnly(latlng);
controller.mapController.move(latlng, controller.mapController.camera.zoom);
},
),
children: [
TileLayer(urlTemplate: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', subdomains: const ['a', 'b', 'c'], userAgentPackageName: 'com.emart.app'),
MarkerLayer(
markers:
controller.pickedPlace.value != null
? [Marker(point: controller.pickedPlace.value!.coordinates, width: 40, height: 40, child: const Icon(Icons.location_pin, size: 36, color: Colors.red))]
: [],
),
],
),
),
Positioned(
top: 16,
left: 16,
right: 16,
child: Column(
children: [
Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: TextField(
controller: searchController,
style: TextStyle(color: isDark ? AppThemeData.grey900 : AppThemeData.grey900),
decoration: InputDecoration(
hintText: 'Search location...'.tr,
hintStyle: TextStyle(color: isDark ? AppThemeData.grey900 : AppThemeData.grey900),
contentPadding: EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(Icons.search),
),
onChanged: controller.searchPlace,
),
),
Obx(() {
if (controller.searchResults.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.searchResults.length,
itemBuilder: (context, index) {
final place = controller.searchResults[index];
return ListTile(
title: Text(place['display_name']),
onTap: () {
controller.selectSearchResult(place);
final lat = double.parse(place['lat']);
final lon = double.parse(place['lon']);
final pos = LatLng(lat, lon);
controller.mapController.move(pos, 15);
searchController.text = place['display_name'];
},
);
},
),
);
}),
],
),
),
],
),
bottomNavigationBar: Obx(() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
controller.pickedPlace.value != null ? "Picked Location:".tr : "No Location Picked".tr,
style: TextStyle(color: isDark ? AppThemeData.primary300 : AppThemeData.primary300, fontFamily: AppThemeData.semiBold, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
if (controller.pickedPlace.value != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Text(
"${controller.pickedPlace.value!.address}\n(${controller.pickedPlace.value!.coordinates.latitude.toStringAsFixed(5)}, ${controller.pickedPlace.value!.coordinates.longitude.toStringAsFixed(5)})",
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: RoundedButtonFill(
title: "Confirm Location".tr,
color: AppThemeData.primary300,
textColor: AppThemeData.grey50,
height: 5,
onPress: () async {
final selected = controller.pickedPlace.value;
if (selected != null) {
Get.back(result: selected); // ✅ Return the selected place
print("Selected location: $selected");
}
},
),
),
const SizedBox(width: 10),
IconButton(icon: const Icon(Icons.delete_forever, color: Colors.red), onPressed: controller.clearAll),
],
),
],
),
);
}),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:latlong2/latlong.dart';
class PlaceModel {
final LatLng coordinates;
final String address;
PlaceModel({required this.coordinates, required this.address});
factory PlaceModel.fromJson(Map<String, dynamic> json) {
return PlaceModel(coordinates: LatLng(json['lat'], json['lng']), address: json['address']);
}
Map<String, dynamic> toJson() {
return {'lat': coordinates.latitude, 'lng': coordinates.longitude, 'address': address};
}
@override
String toString() {
return 'Place(lat: ${coordinates.latitude}, lng: ${coordinates.longitude}, address: $address)';
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import '../themes/app_them_data.dart';
import '../themes/round_button_fill.dart';
class PermissionDialog extends StatelessWidget {
const PermissionDialog({super.key});
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
insetPadding: const EdgeInsets.all(30),
clipBehavior: Clip.antiAliasWithSaveLayer,
child: Padding(
padding: const EdgeInsets.all(30),
child: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_location_alt_rounded, color: Theme.of(context).primaryColor, size: 100),
const SizedBox(height: 20),
Text(
'You denied location permission forever. Please allow location permission from your app settings and receive more accurate delivery.'.tr,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: TextButton(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30), side: BorderSide(width: 2, color: Theme.of(context).primaryColor)),
minimumSize: const Size(1, 50),
),
child: Text('close'.tr),
onPressed: () => Navigator.pop(context),
),
),
const SizedBox(width: 10),
Expanded(
child: RoundedButtonFill(
title: "Settings".tr,
color: AppThemeData.grey900,
textColor: AppThemeData.grey50,
onPress: () async {
await Geolocator.openAppSettings();
Navigator.pop(context);
},
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:customer/widget/place_picker/selected_location_model.dart';
import 'package:get/get.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';
import 'package:flutter/material.dart';
class LocationController extends GetxController {
GoogleMapController? mapController;
var selectedLocation = Rxn<LatLng>();
var selectedPlaceAddress = Rxn<Placemark>();
var address = "Move the map to select a location".obs;
TextEditingController searchController = TextEditingController();
RxString zipCode = ''.obs;
@override
void onInit() {
super.onInit();
getArgument();
getCurrentLocation();
}
void getArgument() {
dynamic argumentData = Get.arguments;
if (argumentData != null) {
zipCode.value = argumentData['zipCode'] ?? '';
if (zipCode.value.isNotEmpty) {
getCoordinatesFromZipCode(zipCode.value);
}
}
update();
}
Future<void> getCurrentLocation() async {
try {
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
selectedLocation.value = LatLng(position.latitude, position.longitude);
if (mapController != null) {
mapController!.animateCamera(CameraUpdate.newLatLngZoom(selectedLocation.value!, 15));
}
await getAddressFromLatLng(selectedLocation.value!);
} catch (e) {
print("Error fetching current location: $e");
}
}
Future<void> getAddressFromLatLng(LatLng latLng) async {
try {
List<Placemark> placemarks = await placemarkFromCoordinates(latLng.latitude, latLng.longitude);
if (placemarks.isNotEmpty) {
Placemark place = placemarks.first;
selectedPlaceAddress.value = place;
address.value = "${place.street}, ${place.locality}, ${place.administrativeArea}, ${place.country}";
} else {
address.value = "Address not found";
}
} catch (e) {
print("Error getting address: $e");
address.value = "Error getting address";
}
}
void onMapMoved(CameraPosition position) {
selectedLocation.value = position.target;
}
Future<void> getCoordinatesFromZipCode(String zipCode) async {
try {
List<Location> locations = await locationFromAddress(zipCode);
if (locations.isNotEmpty) {
selectedLocation.value = LatLng(locations.first.latitude, locations.first.longitude);
}
} catch (e) {
print("Error getting coordinates for ZIP code: $e");
}
}
void confirmLocation() {
if (selectedLocation.value != null) {
SelectedLocationModel selectedLocationModel = SelectedLocationModel(address: selectedPlaceAddress.value, latLng: selectedLocation.value);
Get.back(result: selectedLocationModel);
}
}
}

View File

@@ -0,0 +1,121 @@
import 'package:customer/themes/app_them_data.dart';
import 'package:customer/themes/responsive.dart';
import 'package:customer/themes/round_button_fill.dart';
import 'package:customer/widget/place_picker/location_controller.dart';
import 'package:customer/constant/constant.dart';
import 'package:customer/controllers/theme_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_google_places_hoc081098/flutter_google_places_hoc081098.dart';
import 'package:flutter_google_places_hoc081098/google_maps_webservice_places.dart';
import 'package:get/get.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
final GoogleMapsPlaces _places = GoogleMapsPlaces(apiKey: Constant.mapAPIKey);
class LocationPickerScreen extends StatelessWidget {
const LocationPickerScreen({super.key});
@override
Widget build(BuildContext context) {
final themeController = Get.find<ThemeController>();
final isDark = themeController.isDark.value;
return GetX<LocationController>(
init: LocationController(),
builder: (controller) {
return Scaffold(
body: Stack(
children: [
controller.selectedLocation.value == null
? const Center(child: CircularProgressIndicator())
: GoogleMap(
onMapCreated: (controllers) {
controller.mapController = controllers;
},
initialCameraPosition: CameraPosition(target: controller.selectedLocation.value!, zoom: 15),
onTap: (LatLng tappedPosition) {
controller.selectedLocation.value = tappedPosition;
controller.getAddressFromLatLng(tappedPosition);
},
markers:
controller.selectedLocation.value == null
? {}
: {
Marker(
markerId: const MarkerId("selected-location"),
position: controller.selectedLocation.value!,
onTap: () {
controller.getAddressFromLatLng(controller.selectedLocation.value!);
},
),
},
onCameraMove: controller.onMapMoved,
onCameraIdle: () {
if (controller.selectedLocation.value != null) {
controller.getAddressFromLatLng(controller.selectedLocation.value!);
}
},
),
Positioned(
top: 60,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
Get.back();
},
child: Container(
decoration: BoxDecoration(color: isDark ? AppThemeData.greyDark50 : AppThemeData.grey50, borderRadius: BorderRadius.circular(30)),
child: Padding(padding: const EdgeInsets.all(10), child: Icon(Icons.arrow_back_ios_new_outlined, color: isDark ? AppThemeData.greyDark900 : AppThemeData.grey900)),
),
),
SizedBox(height: 20),
GestureDetector(
onTap: () async {
Prediction? p = await PlacesAutocomplete.show(context: context, apiKey: Constant.mapAPIKey, mode: Mode.overlay, language: "en");
if (p != null) {
final detail = await _places.getDetailsByPlaceId(p.placeId!);
final lat = detail.result.geometry!.location.lat;
final lng = detail.result.geometry!.location.lng;
final LatLng pos = LatLng(lat, lng);
controller.selectedLocation.value = pos;
controller.mapController?.animateCamera(CameraUpdate.newLatLngZoom(pos, 15));
controller.getAddressFromLatLng(pos);
}
},
child: Container(
width: Responsive.width(100, context),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(60)),
child: Row(children: [Icon(Icons.search), SizedBox(width: 8), Text("Search place...".tr)]),
),
),
],
),
),
Positioned(
bottom: 100,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10), boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 5)]),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Obx(() => Text(controller.address.value, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500))),
const SizedBox(height: 10),
RoundedButtonFill(title: "Confirm Location".tr, height: 5.5, color: AppThemeData.primary300, textColor: AppThemeData.grey50, onPress: () => controller.confirmLocation()),
],
),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:geocoding/geocoding.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class SelectedLocationModel {
Placemark? address;
LatLng? latLng;
SelectedLocationModel({this.address,this.latLng});
SelectedLocationModel.fromJson(Map<String, dynamic> json) {
address = json['address'];
latLng = json['latLng'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['address'] = address;
data['latLng'] = latLng;
return data;
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:customer/models/vendor_model.dart';
import 'package:customer/themes/responsive.dart';
import 'package:customer/utils/network_image_widget.dart';
import 'package:flutter/material.dart';
class RestaurantImageView extends StatefulWidget {
final VendorModel vendorModel;
const RestaurantImageView({super.key, required this.vendorModel});
@override
State<RestaurantImageView> createState() => _RestaurantImageViewState();
}
class _RestaurantImageViewState extends State<RestaurantImageView> {
int currentPage = 0;
PageController pageController = PageController(initialPage: 1);
@override
void initState() {
animateSlider();
super.initState();
}
void animateSlider() {
if (widget.vendorModel.photos != null && widget.vendorModel.photos!.isNotEmpty) {
if (widget.vendorModel.photos!.length > 1) {
Timer.periodic(const Duration(seconds: 2), (Timer timer) {
if (currentPage < widget.vendorModel.photos!.length - 1) {
currentPage++;
} else {
currentPage = 0;
}
if (pageController.hasClients) {
pageController.animateToPage(
currentPage,
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
});
}
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: Responsive.height(20, context),
child: widget.vendorModel.photos == null || widget.vendorModel.photos!.isEmpty
? NetworkImageWidget(
imageUrl: widget.vendorModel.photo.toString(),
fit: BoxFit.cover,
height: Responsive.height(20, context),
width: Responsive.width(100, context),
)
: PageView.builder(
physics: const BouncingScrollPhysics(),
controller: pageController,
scrollDirection: Axis.horizontal,
allowImplicitScrolling: true,
itemCount: widget.vendorModel.photos!.length,
padEnds: false,
pageSnapping: true,
itemBuilder: (BuildContext context, int index) {
String image = widget.vendorModel.photos![index];
return NetworkImageWidget(
imageUrl: image.toString(),
fit: BoxFit.cover,
height: Responsive.height(20, context),
width: Responsive.width(100, context),
);
},
),
);
}
}

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

View 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';

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

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

View File

@@ -0,0 +1,159 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class VideoWidget extends StatefulWidget {
final dynamic url;
final double width;
const VideoWidget({super.key, this.width = 140, required this.url});
@override
VideoWidgetState createState() => VideoWidgetState();
}
class VideoWidgetState extends State<VideoWidget> {
late VideoPlayerController _controller;
late Future<void> _initializeVideoPlayerFuture;
@override
void initState() {
super.initState();
_controller = widget.url is File
? VideoPlayerController.file(
widget.url,
)
: VideoPlayerController.network(
widget.url,
);
_initializeVideoPlayerFuture = _controller.initialize();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.width,
height: MediaQuery.of(context).size.height,
child: FutureBuilder(
future: _initializeVideoPlayerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(children: [
VideoPlayer(_controller),
Center(
child: InkWell(
onTap: () {
if (_controller.value.isPlaying) {
_controller.pause();
} else {
_controller.play();
}
setState(() {});
},
child: Icon(
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
)),
)
]),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
);
}
}
class VideoAdvWidget extends StatefulWidget {
final dynamic url;
final double width;
final double? height;
const VideoAdvWidget({super.key, this.height, this.width = 140, required this.url});
@override
VideoAdvWidgetState createState() => VideoAdvWidgetState();
}
class VideoAdvWidgetState extends State<VideoAdvWidget> {
late VideoPlayerController _controller;
late Future<void> _initializeVideoPlayerFuture;
@override
void initState() {
super.initState();
_controller = widget.url is File
? VideoPlayerController.file(
widget.url,
)
: VideoPlayerController.network(
widget.url,
);
_initializeVideoPlayerFuture = _controller.initialize();
_controller.play();
_controller.setLooping(true);
_controller.setVolume(0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.width,
height: widget.height ?? MediaQuery.of(context).size.height,
child: FutureBuilder(
future: _initializeVideoPlayerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return ClipRRect(
borderRadius: BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
child: Stack(children: [
VideoPlayer(_controller),
Center(
child: InkWell(
onTap: () {
if (_controller.value.isPlaying) {
_controller.pause();
} else {
_controller.play();
}
setState(() {});
},
child: SizedBox(
width: 100,
height: 100,
)),
)
]),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
);
}
}