Initial commit
This commit is contained in:
218
lib/widget/dotted_line.dart
Normal file
218
lib/widget/dotted_line.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Draw a dotted line.
|
||||
///
|
||||
/// Basic line settings
|
||||
/// * [direction]
|
||||
/// * [alignment]
|
||||
/// * [lineLength]
|
||||
/// * [lineThickness]
|
||||
/// Dash settings
|
||||
/// * [dashLength]
|
||||
/// * [dashColor]
|
||||
/// * [dashGradient]
|
||||
/// * [dashRadius]
|
||||
/// Dash gap settings
|
||||
/// * [dashGapLength]
|
||||
/// * [dashGapColor]
|
||||
/// * [dashGapRadius]
|
||||
/// * [dashGapGradient]
|
||||
class DottedLine extends StatelessWidget {
|
||||
/// Creates dotted line with the given parameters
|
||||
const DottedLine({
|
||||
super.key,
|
||||
this.direction = Axis.horizontal,
|
||||
this.alignment = WrapAlignment.center,
|
||||
this.lineLength = double.infinity,
|
||||
this.lineThickness = 1.0,
|
||||
this.dashLength = 4.0,
|
||||
this.dashColor = Colors.black,
|
||||
this.dashGradient,
|
||||
this.dashGapLength = 4.0,
|
||||
this.dashGapColor = Colors.transparent,
|
||||
this.dashGapGradient,
|
||||
this.dashRadius = 0.0,
|
||||
this.dashGapRadius = 0.0,
|
||||
}) : assert(
|
||||
dashGradient == null || dashGradient.length == 2,
|
||||
'The dashGradient must have only two colors.\n'
|
||||
'The beginning color and the ending color of the gradient.'),
|
||||
assert(
|
||||
dashGapGradient == null || dashGapGradient.length == 2,
|
||||
'The dashGapGradient must have only two colors.\n'
|
||||
'The beginning color and the ending color of the gradient.');
|
||||
|
||||
/// The direction of the entire dotted line. Default [Axis.horizontal].
|
||||
final Axis direction;
|
||||
|
||||
/// The alignment of the entire dotted line. Default [WrapAlignment.center].
|
||||
final WrapAlignment alignment;
|
||||
|
||||
/// The length of the entire dotted line. Default [double.infinity].
|
||||
final double lineLength;
|
||||
|
||||
/// The thickness of the entire dotted line. Default (1.0).
|
||||
final double lineThickness;
|
||||
|
||||
/// The length of the dash. Default (4.0).
|
||||
final double dashLength;
|
||||
|
||||
/// The color of the dash. Default [Colors.black].
|
||||
///
|
||||
/// This is ignored if [dashGradient] is non-null.
|
||||
final Color dashColor;
|
||||
|
||||
/// The gradient colors of the dash. Default null.
|
||||
/// The first color is beginning color, the second one is ending color.
|
||||
///
|
||||
/// If this is specified, [dashColor] has no effect.
|
||||
final List<Color>? dashGradient;
|
||||
|
||||
/// The radius of the dash. Default (0.0).
|
||||
final double dashRadius;
|
||||
|
||||
/// The length of the dash gap. Default (4.0).
|
||||
final double dashGapLength;
|
||||
|
||||
/// The color of the dash gap. Default [Colors.transparent].
|
||||
///
|
||||
/// This is ignored if [dashGapGradient] is non-null.
|
||||
final Color dashGapColor;
|
||||
|
||||
/// The gradient colors of the dash gap. Default null.
|
||||
/// The first color is beginning color, the second one is ending color.
|
||||
///
|
||||
/// If this is specified, [dashGapColor] has no effect.
|
||||
final List<Color>? dashGapGradient;
|
||||
|
||||
/// The radius of the dash gap. Default (0.0).
|
||||
final double dashGapRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isHorizontal = direction == Axis.horizontal;
|
||||
|
||||
return SizedBox(
|
||||
width: isHorizontal ? lineLength : lineThickness,
|
||||
height: isHorizontal ? lineThickness : lineLength,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
final lineLength = _getLineLength(constraints, isHorizontal);
|
||||
final dashAndDashGapCount = _calculateDashAndDashGapCount(lineLength);
|
||||
final dashCount = dashAndDashGapCount[0];
|
||||
final dashGapCount = dashAndDashGapCount[1];
|
||||
|
||||
return Wrap(
|
||||
direction: direction,
|
||||
alignment: alignment,
|
||||
children: List.generate(dashCount + dashGapCount, (index) {
|
||||
if (index % 2 == 0) {
|
||||
final dashColor = _getDashColor(dashCount, index ~/ 2);
|
||||
final dash = _buildDash(isHorizontal, dashColor);
|
||||
return dash;
|
||||
} else {
|
||||
final dashGapColor = _getDashGapColor(dashGapCount, index ~/ 2);
|
||||
final dashGap = _buildDashGap(isHorizontal, dashGapColor);
|
||||
return dashGap;
|
||||
}
|
||||
}).toList(growable: false),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// If [lineLength] is [double.infinity],
|
||||
/// get the maximum value of the parent widget.
|
||||
/// And if the value is specified, use the specified value.
|
||||
double _getLineLength(BoxConstraints constraints, bool isHorizontal) {
|
||||
return lineLength == double.infinity
|
||||
? isHorizontal
|
||||
? constraints.maxWidth
|
||||
: constraints.maxHeight
|
||||
: lineLength;
|
||||
}
|
||||
|
||||
/// Calculate the count of (dash + dashGap).
|
||||
///
|
||||
/// example1) [lineLength] is 10, [dashLength] is 1, [dashGapLength] is 1.
|
||||
/// "- - - - - "
|
||||
/// example2) [lineLength] is 10, [dashLength] is 1, [dashGapLength] is 2.
|
||||
/// "- - - -"
|
||||
List<int> _calculateDashAndDashGapCount(double lineLength) {
|
||||
var dashAndDashGapLength = dashLength + dashGapLength;
|
||||
var dashCount = lineLength ~/ dashAndDashGapLength;
|
||||
var dashGapCount = lineLength ~/ dashAndDashGapLength;
|
||||
if (dashLength <= lineLength % dashAndDashGapLength) {
|
||||
dashCount += 1;
|
||||
}
|
||||
return [dashCount, dashGapCount];
|
||||
}
|
||||
|
||||
Widget _buildDash(bool isHorizontal, Color color) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(dashRadius),
|
||||
),
|
||||
width: isHorizontal ? dashLength : lineThickness,
|
||||
height: isHorizontal ? lineThickness : dashLength,
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDashColor(int maxDashCount, int index) {
|
||||
return dashGradient == null
|
||||
? dashColor
|
||||
: _calculateGradientColor(
|
||||
dashGradient![0],
|
||||
dashGradient![1],
|
||||
maxDashCount,
|
||||
index,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDashGap(bool isHorizontal, Color color) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(dashGapRadius),
|
||||
),
|
||||
width: isHorizontal ? dashGapLength : lineThickness,
|
||||
height: isHorizontal ? lineThickness : dashGapLength,
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDashGapColor(int maxDashGapCount, int index) {
|
||||
return dashGapGradient == null
|
||||
? dashGapColor
|
||||
: _calculateGradientColor(
|
||||
dashGapGradient![0],
|
||||
dashGapGradient![1],
|
||||
maxDashGapCount,
|
||||
index,
|
||||
);
|
||||
}
|
||||
|
||||
Color _calculateGradientColor(
|
||||
Color startColor,
|
||||
Color endColor,
|
||||
int maxItemCount,
|
||||
int index,
|
||||
) {
|
||||
var diffAlpha = (endColor.alpha - startColor.alpha);
|
||||
var diffRed = (endColor.red - startColor.red);
|
||||
var diffGreen = (endColor.green - startColor.green);
|
||||
var diffBlue = (endColor.blue - startColor.blue);
|
||||
|
||||
var amountOfChangeInAlphaPerItem = diffAlpha ~/ maxItemCount;
|
||||
var amountOfChangeInRedPerItem = diffRed ~/ maxItemCount;
|
||||
var amountOfChangeInGreenPerItem = diffGreen ~/ maxItemCount;
|
||||
var amountOfChangeInBluePerItem = diffBlue ~/ maxItemCount;
|
||||
|
||||
return startColor
|
||||
.withAlpha(startColor.alpha + amountOfChangeInAlphaPerItem * index)
|
||||
.withRed(startColor.red + amountOfChangeInRedPerItem * index)
|
||||
.withGreen(startColor.green + amountOfChangeInGreenPerItem * index)
|
||||
.withBlue(startColor.blue + amountOfChangeInBluePerItem * index);
|
||||
}
|
||||
}
|
||||
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,15 @@
|
||||
// Flutter Packages
|
||||
import 'package:flutter/material.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 const Center(
|
||||
child: Text('Nothing found here...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:driver/widget/firebase_pagination/src/models/page_options.dart';
|
||||
import 'package:driver/widget/firebase_pagination/src/models/view_type.dart';
|
||||
import 'package:driver/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/point.dart';
|
||||
export 'src/models/distance_doc_snapshot.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/math.dart';
|
||||
import '../utils/arrays.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();
|
||||
}
|
||||
}
|
||||
65
lib/widget/geoflutterfire/src/collection/default.dart
Normal file
65
lib/widget/geoflutterfire/src/collection/default.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:driver/widget/geoflutterfire/src/models/distance_doc_snapshot.dart';
|
||||
import 'package:driver/widget/geoflutterfire/src/models/point.dart';
|
||||
import 'package:flutter/material.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:driver/widget/geoflutterfire/src/models/distance_doc_snapshot.dart';
|
||||
import 'package:driver/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:driver/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,
|
||||
});
|
||||
}
|
||||
61
lib/widget/geoflutterfire/src/models/point.dart
Normal file
61
lib/widget/geoflutterfire/src/models/point.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
279
lib/widget/geoflutterfire/src/utils/math.dart
Normal file
279
lib/widget/geoflutterfire/src/utils/math.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
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:driver/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);
|
||||
}
|
||||
}
|
||||
159
lib/widget/osm_map/map_picker_page.dart
Normal file
159
lib/widget/osm_map/map_picker_page.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
import 'package:driver/themes/app_them_data.dart';
|
||||
import 'package:driver/themes/round_button_fill.dart';
|
||||
import 'package:driver/themes/theme_controller.dart';
|
||||
import 'package:driver/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';
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/widget/osm_map/place_model.dart
Normal file
31
lib/widget/osm_map/place_model.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
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)';
|
||||
}
|
||||
}
|
||||
78
lib/widget/permission_dialog.dart
Normal file
78
lib/widget/permission_dialog.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:driver/themes/app_them_data.dart';
|
||||
import 'package:driver/themes/theme_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class PermissionDialog extends StatelessWidget {
|
||||
const PermissionDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeController = Get.find<ThemeController>();
|
||||
final isDark = themeController.isDark.value;
|
||||
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),
|
||||
const Text(
|
||||
'You denied location permission forever. Please allow location permission from your app settings and receive more accurate delivery.',
|
||||
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: const Text('close'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: double.infinity),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppThemeData.primary300,
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
side: BorderSide(
|
||||
color: AppThemeData.primary300,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'settings',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await Geolocator.openAppSettings();
|
||||
Get.back();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/widget/place_picker/location_controller.dart
Normal file
96
lib/widget/place_picker/location_controller.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:driver/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
lib/widget/place_picker/location_picker_screen.dart
Normal file
165
lib/widget/place_picker/location_picker_screen.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:driver/constant/constant.dart';
|
||||
import 'package:driver/themes/app_them_data.dart';
|
||||
import 'package:driver/themes/responsive.dart';
|
||||
import 'package:driver/themes/round_button_fill.dart';
|
||||
import 'package:driver/themes/theme_controller.dart';
|
||||
import 'package:driver/widget/place_picker/location_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,
|
||||
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: const Row(
|
||||
children: [
|
||||
Icon(Icons.search),
|
||||
SizedBox(width: 8),
|
||||
Text("Search place..."),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
84
lib/widget/restaurant_image_view.dart
Normal file
84
lib/widget/restaurant_image_view.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:driver/models/vendor_model.dart';
|
||||
import 'package:driver/themes/responsive.dart';
|
||||
import 'package:driver/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) {
|
||||
Timer.periodic(const Duration(seconds: 2), (Timer timer) {
|
||||
if (currentPage < widget.vendorModel.photos!.length) {
|
||||
currentPage++;
|
||||
} else {
|
||||
currentPage = 0;
|
||||
}
|
||||
|
||||
if (pageController.hasClients) {
|
||||
pageController.animateToPage(
|
||||
currentPage,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeIn,
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
itemCount: widget.vendorModel.photos!.length,
|
||||
padEnds: false,
|
||||
pageSnapping: true,
|
||||
onPageChanged: (value) {
|
||||
setState(() {
|
||||
currentPage = value;
|
||||
});
|
||||
},
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user