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