Initial commit

This commit is contained in:
2025-12-08 23:25:00 +05:00
commit ee5cb4ac1a
851 changed files with 115172 additions and 0 deletions

218
lib/widget/dotted_line.dart Normal file
View 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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...'),
);
}
}

View File

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

View File

@@ -0,0 +1,177 @@
// Flutter Packages
import 'package: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);
},
);
}
}
}

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

View File

@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';
import '../models/distance_doc_snapshot.dart';
import '../models/point.dart';
import '../utils/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();
}
}

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

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

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

View File

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

View File

@@ -0,0 +1,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);
}

View File

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

View File

@@ -0,0 +1,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);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package: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);
}
}

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,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),
);
},
),
);
}
}