Initial commit
This commit is contained in:
13
lib/widget/firebase_pagination/firebase_pagination.dart
Normal file
13
lib/widget/firebase_pagination/firebase_pagination.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
/// A package to paginate your firebase related data with realtime updates.
|
||||
///
|
||||
/// It can be used for `Firestore` and `Firebase Realtime Database`.
|
||||
///
|
||||
/// Data can be shown in `list`, `grid` and `scrollable wrap` view.
|
||||
library;
|
||||
|
||||
export 'src/firestore_pagination.dart';
|
||||
// Data Models
|
||||
export 'src/models/view_type.dart';
|
||||
export 'src/models/wrap_options.dart';
|
||||
// Widgets
|
||||
export 'src/realtime_db_pagination.dart';
|
||||
335
lib/widget/firebase_pagination/src/firestore_pagination.dart
Normal file
335
lib/widget/firebase_pagination/src/firestore_pagination.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
// Dart Packages
|
||||
import 'dart:async';
|
||||
|
||||
// Firebase Packages
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
// Flutter Packages
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
// Functions
|
||||
import 'functions/separator_builder.dart';
|
||||
// Data Models
|
||||
import 'models/page_options.dart';
|
||||
import 'models/view_type.dart';
|
||||
import 'models/wrap_options.dart';
|
||||
// Widgets
|
||||
import 'widgets/defaults/bottom_loader.dart';
|
||||
import 'widgets/defaults/empty_screen.dart';
|
||||
import 'widgets/defaults/initial_loader.dart';
|
||||
import 'widgets/views/build_pagination.dart';
|
||||
|
||||
/// A [StreamBuilder] that automatically loads more data when the user scrolls
|
||||
/// to the bottom.
|
||||
///
|
||||
/// Optimized for [FirebaseFirestore] with fields like `createdAt` and
|
||||
/// `timestamp` to sort the data.
|
||||
///
|
||||
/// Supports live updates and realtime updates to loaded data.
|
||||
///
|
||||
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
|
||||
class FirestorePagination extends StatefulWidget {
|
||||
/// Creates a [StreamBuilder] widget that automatically loads more data when
|
||||
/// the user scrolls to the bottom.
|
||||
///
|
||||
/// Optimized for [FirebaseFirestore] with fields like `createdAt` and
|
||||
/// `timestamp` to sort the data.
|
||||
///
|
||||
/// Supports live updates and realtime updates to loaded data.
|
||||
///
|
||||
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
|
||||
const FirestorePagination({
|
||||
required this.query,
|
||||
required this.itemBuilder,
|
||||
super.key,
|
||||
this.separatorBuilder,
|
||||
this.limit = 10,
|
||||
this.viewType = ViewType.list,
|
||||
this.isLive = false,
|
||||
this.gridDelegate = const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
this.wrapOptions = const WrapOptions(),
|
||||
this.pageOptions = const PageOptions(),
|
||||
this.onEmpty = const EmptyScreen(),
|
||||
this.bottomLoader = const BottomLoader(),
|
||||
this.initialLoader = const InitialLoader(),
|
||||
this.scrollDirection = Axis.vertical,
|
||||
this.reverse = false,
|
||||
this.shrinkWrap = false,
|
||||
this.physics,
|
||||
this.padding,
|
||||
this.controller,
|
||||
this.pageController,
|
||||
});
|
||||
|
||||
/// The query to use to fetch data from Firestore.
|
||||
///
|
||||
/// ### Note:
|
||||
/// - The query must **NOT** contain a `limit` itself.
|
||||
/// - The `limit` must be set using the [limit] property of this widget.
|
||||
final Query query;
|
||||
|
||||
/// The builder to use to build the items in the list.
|
||||
///
|
||||
/// The builder is passed the build context, snapshot of the document and
|
||||
/// index of the item in the list.
|
||||
final Widget Function(BuildContext, List<DocumentSnapshot>, int) itemBuilder;
|
||||
|
||||
/// The builder to use to render the separator.
|
||||
///
|
||||
/// Only used if [viewType] is [ViewType.list].
|
||||
///
|
||||
/// Default [Widget] is [SizedBox.shrink].
|
||||
final Widget Function(BuildContext, int)? separatorBuilder;
|
||||
|
||||
/// The number of items to fetch from Firestore at once.
|
||||
///
|
||||
/// Defaults to `10`.
|
||||
final int limit;
|
||||
|
||||
/// The type of view to use for the list.
|
||||
///
|
||||
/// Defaults to [ViewType.list].
|
||||
final ViewType viewType;
|
||||
|
||||
/// Whether to fetch newly added items as they are added to Firestore.
|
||||
///
|
||||
/// Defaults to `false`.
|
||||
final bool isLive;
|
||||
|
||||
/// The delegate to use for the [GridView].
|
||||
///
|
||||
/// Defaults to [SliverGridDelegateWithFixedCrossAxisCount].
|
||||
final SliverGridDelegate gridDelegate;
|
||||
|
||||
/// The [Wrap] widget properties to use.
|
||||
///
|
||||
/// Defaults to [WrapOptions].
|
||||
final WrapOptions wrapOptions;
|
||||
|
||||
/// The [PageView] properties to use.
|
||||
///
|
||||
/// Defaults to [PageOptions].
|
||||
final PageOptions pageOptions;
|
||||
|
||||
/// The widget to use when data is empty.
|
||||
///
|
||||
/// Defaults to [EmptyScreen].
|
||||
final Widget onEmpty;
|
||||
|
||||
/// The widget to use when more data is loading.
|
||||
///
|
||||
/// Defaults to [BottomLoader].
|
||||
final Widget bottomLoader;
|
||||
|
||||
/// The widget to use when data is loading initially.
|
||||
///
|
||||
/// Defaults to [InitialLoader].
|
||||
final Widget initialLoader;
|
||||
|
||||
/// The scrolling direction of the [ScrollView].
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// Whether the [ScrollView] scrolls in the reading direction.
|
||||
final bool reverse;
|
||||
|
||||
/// Should the [ScrollView] be shrink-wrapped.
|
||||
final bool shrinkWrap;
|
||||
|
||||
/// The scroll behavior to use for the [ScrollView].
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// The padding to use for the [ScrollView].
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// The scroll controller to use for the [ScrollView].
|
||||
///
|
||||
/// Defaults to [ScrollController].
|
||||
final ScrollController? controller;
|
||||
|
||||
/// The page controller to use for the [PageView].
|
||||
///
|
||||
/// Defaults to [PageController].
|
||||
final PageController? pageController;
|
||||
|
||||
@override
|
||||
State<FirestorePagination> createState() => _FirestorePaginationState();
|
||||
}
|
||||
|
||||
/// The state of the [FirestorePagination] widget.
|
||||
class _FirestorePaginationState extends State<FirestorePagination> {
|
||||
/// All the data that has been loaded from Firestore.
|
||||
final List<DocumentSnapshot> _docs = [];
|
||||
|
||||
/// Snapshot subscription for the query.
|
||||
///
|
||||
/// Also handles updates to loaded data.
|
||||
StreamSubscription<QuerySnapshot>? _streamSub;
|
||||
|
||||
/// Snapshot subscription for the query to handle newly added data.
|
||||
StreamSubscription<QuerySnapshot>? _liveStreamSub;
|
||||
|
||||
/// [ScrollController] to listen to scroll end and load more data.
|
||||
late final ScrollController _controller = widget.controller ?? ScrollController();
|
||||
|
||||
/// [PageController] to listen to page changes and load more data.
|
||||
late final PageController _pageController = widget.pageController ?? PageController();
|
||||
|
||||
/// Whether initial data is loading.
|
||||
bool _isInitialLoading = true;
|
||||
|
||||
/// Whether more data is loading.
|
||||
bool _isFetching = false;
|
||||
|
||||
/// Whether the end for given query has been reached.
|
||||
///
|
||||
/// This is used to determine if more data should be loaded when the user
|
||||
/// scrolls to the bottom.
|
||||
bool _isEnded = false;
|
||||
|
||||
/// Loads more data from Firestore and handles updates to loaded data.
|
||||
///
|
||||
/// Setting [getMore] to `false` will only set listener for the currently
|
||||
/// loaded data.
|
||||
Future<void> _loadDocuments({bool getMore = true}) async {
|
||||
// To cancel previous updates listener when new one is set.
|
||||
final tempSub = _streamSub;
|
||||
|
||||
if (getMore) setState(() => _isFetching = true);
|
||||
|
||||
final docsLimit = _docs.length + (getMore ? widget.limit : 0);
|
||||
var docsQuery = widget.query.limit(docsLimit);
|
||||
if (_docs.isNotEmpty) {
|
||||
docsQuery = docsQuery.startAtDocument(_docs.first);
|
||||
}
|
||||
|
||||
_streamSub = docsQuery.snapshots().listen((QuerySnapshot snapshot) async {
|
||||
await tempSub?.cancel();
|
||||
|
||||
_docs
|
||||
..clear()
|
||||
..addAll(snapshot.docs);
|
||||
|
||||
// To set new updates listener for the existing data
|
||||
// or to set new live listener if the first document is removed.
|
||||
final isDocRemoved = snapshot.docChanges.any(
|
||||
(DocumentChange change) => change.type == DocumentChangeType.removed,
|
||||
);
|
||||
|
||||
_isFetching = false;
|
||||
if (!isDocRemoved) {
|
||||
_isEnded = snapshot.docs.length < docsLimit;
|
||||
}
|
||||
|
||||
if (isDocRemoved || _isInitialLoading) {
|
||||
_isInitialLoading = false;
|
||||
if (snapshot.docs.isNotEmpty) {
|
||||
// Set updates listener for the existing data starting from the first
|
||||
// document only.
|
||||
await _loadDocuments(getMore: false);
|
||||
} else {
|
||||
_streamSub?.cancel();
|
||||
}
|
||||
if (widget.isLive) _setLiveListener();
|
||||
}
|
||||
|
||||
if (mounted) setState(() {});
|
||||
|
||||
// Add data till the view is scrollable. This ensures that the user can
|
||||
// scroll to the bottom and load more data.
|
||||
if (_isInitialLoading || _isFetching || _isEnded) return;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (_controller.hasClients && _controller.position.maxScrollExtent <= 0) {
|
||||
_loadDocuments();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the live listener for the query.
|
||||
///
|
||||
/// Fires when new data is added to the query.
|
||||
Future<void> _setLiveListener() async {
|
||||
// To cancel previous live listener when new one is set.
|
||||
final tempSub = _liveStreamSub;
|
||||
|
||||
var latestDocQuery = widget.query.limit(1);
|
||||
if (_docs.isNotEmpty) {
|
||||
latestDocQuery = latestDocQuery.endBeforeDocument(_docs.first);
|
||||
}
|
||||
|
||||
_liveStreamSub = latestDocQuery.snapshots(includeMetadataChanges: true).listen(
|
||||
(QuerySnapshot snapshot) async {
|
||||
await tempSub?.cancel();
|
||||
if (snapshot.docs.isEmpty || snapshot.docs.first.metadata.hasPendingWrites) return;
|
||||
|
||||
_docs.insert(0, snapshot.docs.first);
|
||||
|
||||
// To handle newly added data after this curently loaded data.
|
||||
await _setLiveListener();
|
||||
|
||||
// Set updates listener for the newly added data.
|
||||
_loadDocuments(getMore: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// To handle scroll end event and load more data.
|
||||
void _scrollListener() {
|
||||
if (_isInitialLoading || _isFetching || _isEnded) return;
|
||||
if (!_controller.hasClients) return;
|
||||
|
||||
final position = _controller.position;
|
||||
if (position.pixels >= (position.maxScrollExtent - 50)) {
|
||||
_loadDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocuments();
|
||||
_controller.addListener(_scrollListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub?.cancel();
|
||||
_liveStreamSub?.cancel();
|
||||
_controller
|
||||
..removeListener(_scrollListener)
|
||||
..dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isInitialLoading
|
||||
? widget.initialLoader
|
||||
: _docs.isEmpty
|
||||
? widget.onEmpty
|
||||
: BuildPagination(
|
||||
items: _docs,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
separatorBuilder: widget.separatorBuilder ?? separatorBuilder,
|
||||
isLoading: _isFetching,
|
||||
viewType: widget.viewType,
|
||||
bottomLoader: widget.bottomLoader,
|
||||
gridDelegate: widget.gridDelegate,
|
||||
wrapOptions: widget.wrapOptions,
|
||||
pageOptions: widget.pageOptions,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
reverse: widget.reverse,
|
||||
controller: _controller,
|
||||
pageController: _pageController,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
physics: widget.physics,
|
||||
padding: widget.padding,
|
||||
onPageChanged: (index) {
|
||||
if (index >= _docs.length - 1) _loadDocuments();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Flutter Packages
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Returns a [Widget] to be render as separator in a [ListView].
|
||||
Widget separatorBuilder(BuildContext context, int index) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
69
lib/widget/firebase_pagination/src/models/page_options.dart
Normal file
69
lib/widget/firebase_pagination/src/models/page_options.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
// Flutter Packages
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Data Models
|
||||
import 'view_type.dart';
|
||||
|
||||
/// The properties of the [PageView] widget in the [ViewType.page] view.
|
||||
class PageOptions {
|
||||
/// Creates a object that contains the properties of the [PageView] widget.
|
||||
const PageOptions({
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
this.pageSnapping = true,
|
||||
this.padEnds = true,
|
||||
this.scrollBehavior,
|
||||
this.allowImplicitScrolling = false,
|
||||
this.dragStartBehavior = DragStartBehavior.start,
|
||||
});
|
||||
|
||||
/// {@macro flutter.material.Material.clipBehavior}
|
||||
///
|
||||
/// Defaults to [Clip.hardEdge].
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// Set to false to disable page snapping, useful for custom scroll behavior.
|
||||
///
|
||||
/// If the [padEnds] is false and [PageController.viewportFraction] < 1.0,
|
||||
/// the page will snap to the beginning of the viewport; otherwise, the page
|
||||
/// will snap to the center of the viewport.
|
||||
final bool pageSnapping;
|
||||
|
||||
/// Whether to add padding to both ends of the list.
|
||||
///
|
||||
/// If this is set to true and [PageController.viewportFraction] < 1.0, padding will be added
|
||||
/// such that the first and last child slivers will be in the center of
|
||||
/// the viewport when scrolled all the way to the start or end, respectively.
|
||||
///
|
||||
/// If [PageController.viewportFraction] >= 1.0, this property has no effect.
|
||||
///
|
||||
/// This property defaults to true.
|
||||
final bool padEnds;
|
||||
|
||||
/// {@macro flutter.widgets.shadow.scrollBehavior}
|
||||
///
|
||||
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
|
||||
/// [ScrollPhysics] is provided in [physics], it will take precedence,
|
||||
/// followed by [scrollBehavior], and then the inherited ancestor
|
||||
/// [ScrollBehavior].
|
||||
///
|
||||
/// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be
|
||||
/// modified by default to not apply a [Scrollbar].
|
||||
final ScrollBehavior? scrollBehavior;
|
||||
|
||||
/// Controls whether the widget's pages will respond to
|
||||
/// [RenderObject.showOnScreen], which will allow for implicit accessibility
|
||||
/// scrolling.
|
||||
///
|
||||
/// With this flag set to false, when accessibility focus reaches the end of
|
||||
/// the current page and the user attempts to move it to the next element, the
|
||||
/// focus will traverse to the next widget outside of the page view.
|
||||
///
|
||||
/// With this flag set to true, when accessibility focus reaches the end of
|
||||
/// the current page and user attempts to move it to the next element, focus
|
||||
/// will traverse to the next page in the page view.
|
||||
final bool allowImplicitScrolling;
|
||||
|
||||
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
|
||||
final DragStartBehavior dragStartBehavior;
|
||||
}
|
||||
19
lib/widget/firebase_pagination/src/models/view_type.dart
Normal file
19
lib/widget/firebase_pagination/src/models/view_type.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// Flutter Packages
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The [ScrollView] to use for the loaded data.
|
||||
///
|
||||
/// Supports [list], [grid], and [wrap].
|
||||
enum ViewType {
|
||||
/// Loads the data as a [ListView].
|
||||
list,
|
||||
|
||||
/// Loads the data as a [GridView].
|
||||
grid,
|
||||
|
||||
/// Loads the data as a scrollable [Wrap].
|
||||
wrap,
|
||||
|
||||
/// Loads the data as a [PageView].
|
||||
page,
|
||||
}
|
||||
67
lib/widget/firebase_pagination/src/models/wrap_options.dart
Normal file
67
lib/widget/firebase_pagination/src/models/wrap_options.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
// Flutter Packages
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// Data Models
|
||||
import 'view_type.dart';
|
||||
|
||||
/// The properties of the [Wrap] widget in the [ViewType.wrap] view.
|
||||
class WrapOptions {
|
||||
/// Creates a object that contains the properties of the [Wrap] widget.
|
||||
const WrapOptions({
|
||||
this.direction = Axis.horizontal,
|
||||
this.alignment = WrapAlignment.center,
|
||||
this.spacing = 5.0,
|
||||
this.runAlignment = WrapAlignment.start,
|
||||
this.runSpacing = 5.0,
|
||||
this.crossAxisAlignment = WrapCrossAlignment.start,
|
||||
this.textDirection,
|
||||
this.verticalDirection = VerticalDirection.down,
|
||||
this.clipBehavior = Clip.none,
|
||||
});
|
||||
|
||||
/// The direction to use as the main axis.
|
||||
///
|
||||
/// Defaults to [Axis.horizontal].
|
||||
final Axis direction;
|
||||
|
||||
/// How the children within a run should be placed in the main axis.
|
||||
///
|
||||
/// Defaults to [WrapAlignment.center].
|
||||
final WrapAlignment alignment;
|
||||
|
||||
/// How much space to place between children in a run in the main axis.
|
||||
///
|
||||
/// Defaults to 5.0.
|
||||
final double spacing;
|
||||
|
||||
/// How the runs themselves should be placed in the cross axis.
|
||||
///
|
||||
/// Defaults to [WrapAlignment.start].
|
||||
final WrapAlignment runAlignment;
|
||||
|
||||
/// How much space to place between the runs themselves in the cross axis.
|
||||
///
|
||||
/// Defaults to 5.0.
|
||||
final double runSpacing;
|
||||
|
||||
/// How the children within a run should be aligned relative to each other in
|
||||
/// the cross axis.
|
||||
///
|
||||
/// Defaults to [WrapCrossAlignment.start].
|
||||
final WrapCrossAlignment crossAxisAlignment;
|
||||
|
||||
/// Determines the order to lay children out horizontally and how to interpret
|
||||
/// `start` and `end` in the horizontal direction.
|
||||
final TextDirection? textDirection;
|
||||
|
||||
/// Determines the order to lay children out vertically and how to interpret
|
||||
/// `start` and `end` in the vertical direction.
|
||||
///
|
||||
/// Defaults to [VerticalDirection.down].
|
||||
final VerticalDirection verticalDirection;
|
||||
|
||||
/// {@macro flutter.material.Material.clipBehavior}
|
||||
///
|
||||
/// Defaults to [Clip.none].
|
||||
final Clip clipBehavior;
|
||||
}
|
||||
400
lib/widget/firebase_pagination/src/realtime_db_pagination.dart
Normal file
400
lib/widget/firebase_pagination/src/realtime_db_pagination.dart
Normal file
@@ -0,0 +1,400 @@
|
||||
// Dart Packages
|
||||
import 'dart:async';
|
||||
|
||||
// Firebase Packages
|
||||
import 'package:firebase_database/firebase_database.dart';
|
||||
// Flutter Packages
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
// Functions
|
||||
import 'functions/separator_builder.dart';
|
||||
// Data Models
|
||||
import 'models/page_options.dart';
|
||||
import 'models/view_type.dart';
|
||||
import 'models/wrap_options.dart';
|
||||
// Widgets
|
||||
import 'widgets/defaults/bottom_loader.dart';
|
||||
import 'widgets/defaults/empty_screen.dart';
|
||||
import 'widgets/defaults/initial_loader.dart';
|
||||
import 'widgets/views/build_pagination.dart';
|
||||
|
||||
/// A [StreamBuilder] that automatically loads more data when the user scrolls
|
||||
/// to the bottom.
|
||||
///
|
||||
/// Optimized for [FirebaseDatabase] with fields like `createdAt` and
|
||||
/// `timestamp` to sort the data.
|
||||
///
|
||||
/// Supports live updates and realtime updates to loaded data.
|
||||
///
|
||||
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
|
||||
class RealtimeDBPagination extends StatefulWidget {
|
||||
/// Creates a [StreamBuilder] widget that automatically loads more data when
|
||||
/// the user scrolls to the bottom.
|
||||
///
|
||||
/// Optimized for [FirebaseDatabase] with fields like `createdAt` and
|
||||
/// `timestamp` to sort the data.
|
||||
///
|
||||
/// Supports live updates and realtime updates to loaded data.
|
||||
///
|
||||
/// Data can be represented in a [ListView], [GridView] or scollable [Wrap].
|
||||
const RealtimeDBPagination({
|
||||
required this.query,
|
||||
required this.itemBuilder,
|
||||
required this.orderBy,
|
||||
super.key,
|
||||
this.descending = false,
|
||||
this.separatorBuilder,
|
||||
this.limit = 10,
|
||||
this.viewType = ViewType.list,
|
||||
this.isLive = false,
|
||||
this.gridDelegate = const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
this.wrapOptions = const WrapOptions(),
|
||||
this.pageOptions = const PageOptions(),
|
||||
this.onEmpty = const EmptyScreen(),
|
||||
this.bottomLoader = const BottomLoader(),
|
||||
this.initialLoader = const InitialLoader(),
|
||||
this.scrollDirection = Axis.vertical,
|
||||
this.reverse = false,
|
||||
this.shrinkWrap = false,
|
||||
this.physics,
|
||||
this.padding,
|
||||
this.controller,
|
||||
this.pageController,
|
||||
});
|
||||
|
||||
/// The query to use to fetch data from Firebase Realtime Database.
|
||||
///
|
||||
/// ### Note:
|
||||
/// - The query must **NOT** contain a `limitToFirst` or `limitToLast` itself.
|
||||
/// - The `limit` must be set using the [limit] property of this widget.
|
||||
final Query query;
|
||||
|
||||
/// The builder to use to build the items in the list.
|
||||
///
|
||||
/// The builder is passed the build context, snapshot of data and index of
|
||||
/// the item in the list.
|
||||
final Widget Function(BuildContext, List<DataSnapshot>, int) itemBuilder;
|
||||
|
||||
/// The field to use to sort the data. Give the same value as the field
|
||||
/// used to order the data in the query.
|
||||
///
|
||||
/// ## Example
|
||||
/// If the query is:
|
||||
/// ```dart
|
||||
/// FirebaseDatabase.instance.ref('messages').orderByChild('createdAt')
|
||||
/// ```
|
||||
/// Then the value of [orderBy] should be `createdAt`.
|
||||
///
|
||||
/// If null, the data will be sorted by the key.
|
||||
final String? orderBy;
|
||||
|
||||
/// Fetches data is decending order for the given [orderBy] field.
|
||||
///
|
||||
/// Default value is `false`.
|
||||
final bool descending;
|
||||
|
||||
/// The builder to use to render the separator.
|
||||
///
|
||||
/// Only used if [viewType] is [ViewType.list].
|
||||
///
|
||||
/// Default [Widget] is [SizedBox.shrink].
|
||||
final Widget Function(BuildContext, int)? separatorBuilder;
|
||||
|
||||
/// The number of items to fetch from Firebase Realtime Database at once.
|
||||
///
|
||||
/// Defaults to `10`.
|
||||
final int limit;
|
||||
|
||||
/// The type of view to use for the list.
|
||||
///
|
||||
/// Defaults to [ViewType.list].
|
||||
final ViewType viewType;
|
||||
|
||||
/// Whether to fetch newly added items as they are added to
|
||||
/// Firebase Realtime Database.
|
||||
///
|
||||
/// Defaults to `false`.
|
||||
final bool isLive;
|
||||
|
||||
/// The delegate to use for the [GridView].
|
||||
///
|
||||
/// Defaults to [SliverGridDelegateWithFixedCrossAxisCount].
|
||||
final SliverGridDelegate gridDelegate;
|
||||
|
||||
/// The [Wrap] widget properties to use.
|
||||
///
|
||||
/// Defaults to [WrapOptions].
|
||||
final WrapOptions wrapOptions;
|
||||
|
||||
/// The [PageView] properties to use.
|
||||
///
|
||||
/// Defaults to [PageOptions].
|
||||
final PageOptions pageOptions;
|
||||
|
||||
/// The widget to use when data is empty.
|
||||
///
|
||||
/// Defaults to [EmptyScreen].
|
||||
final Widget onEmpty;
|
||||
|
||||
/// The widget to use when more data is loading.
|
||||
///
|
||||
/// Defaults to [BottomLoader].
|
||||
final Widget bottomLoader;
|
||||
|
||||
/// The widget to use when data is loading initially.
|
||||
///
|
||||
/// Defaults to [InitialLoader].
|
||||
final Widget initialLoader;
|
||||
|
||||
/// The scrolling direction of the [ScrollView].
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// Whether the [ScrollView] scrolls in the reading direction.
|
||||
final bool reverse;
|
||||
|
||||
/// Should the [ScrollView] be shrink-wrapped.
|
||||
final bool shrinkWrap;
|
||||
|
||||
/// The scroll behavior to use for the [ScrollView].
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// The padding to use for the [ScrollView].
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// The scroll controller to use for the [ScrollView].
|
||||
///
|
||||
/// Defaults to [ScrollController].
|
||||
final ScrollController? controller;
|
||||
|
||||
/// The page controller to use for the [PageView].
|
||||
///
|
||||
/// Defaults to [PageController].
|
||||
final PageController? pageController;
|
||||
|
||||
@override
|
||||
State<RealtimeDBPagination> createState() => _RealtimeDBPaginationState();
|
||||
}
|
||||
|
||||
/// The state of the [RealtimeDBPagination] widget.
|
||||
class _RealtimeDBPaginationState extends State<RealtimeDBPagination> {
|
||||
/// All the data that has been loaded from Firebase Realtime Database.
|
||||
final List<DataSnapshot> _data = [];
|
||||
|
||||
/// Snapshot subscription for the query.
|
||||
///
|
||||
/// Also handles updates to loaded data.
|
||||
StreamSubscription<DatabaseEvent>? _streamSub;
|
||||
|
||||
/// Snapshot subscription for the query to handle newly added data.
|
||||
StreamSubscription<DatabaseEvent>? _liveStreamSub;
|
||||
|
||||
/// [ScrollController] to listen to scroll end and load more data.
|
||||
late final ScrollController _controller = widget.controller ?? ScrollController();
|
||||
|
||||
/// [PageController] to listen to page changes and load more data.
|
||||
late final PageController _pageController = widget.pageController ?? PageController();
|
||||
|
||||
/// Whether initial data is loading.
|
||||
bool _isInitialLoading = true;
|
||||
|
||||
/// Whether more data is loading.
|
||||
bool _isFetching = false;
|
||||
|
||||
/// Whether the end for given query has been reached.
|
||||
///
|
||||
/// This is used to determine if more data should be loaded when the user
|
||||
/// scrolls to the bottom.
|
||||
bool _isEnded = false;
|
||||
|
||||
/// Loads more data from Firebase Realtime Database and handles
|
||||
/// updates to loaded data.
|
||||
///
|
||||
/// Setting [getMore] to `false` will only set listener for the
|
||||
/// currently loaded data.
|
||||
Future<void> _loadData({bool getMore = true}) async {
|
||||
// To cancel previous updates listener when new one is set.
|
||||
final tempSub = _streamSub;
|
||||
|
||||
if (getMore) setState(() => _isFetching = true);
|
||||
|
||||
// Sets limit of nodes to fetch.
|
||||
// If currently 15 items are loaded, and limit is 5 then total 20 items
|
||||
// will be fetched including the ones already present.
|
||||
final docsLimit = _data.length + (getMore ? widget.limit : 0);
|
||||
var docsQuery = widget.descending ? widget.query.limitToLast(docsLimit) : widget.query.limitToFirst(docsLimit);
|
||||
|
||||
if (_data.isNotEmpty) {
|
||||
if (widget.descending) {
|
||||
// Sets ending point from where before data should be fetched.
|
||||
// If currently 15 items are loaded, and limit is 5 then total 20 items
|
||||
// will be fetched where below mentioned value will be the largest and
|
||||
// last in the fetched array (But first in callback array as using
|
||||
// reversed in build method)
|
||||
docsQuery = docsQuery.endAt(
|
||||
Map<String, dynamic>.from(
|
||||
_data.last.value! as Map<Object?, Object?>,
|
||||
)[widget.orderBy],
|
||||
);
|
||||
} else {
|
||||
// Sets starting point from where after data should be fetched.
|
||||
// If currently 15 items are loaded, and limit is 5 then total 20 items
|
||||
// will be fetched where below mentioned value will be the smallest and
|
||||
// first in array
|
||||
docsQuery = docsQuery.startAt(
|
||||
Map<String, dynamic>.from(
|
||||
_data.first.value! as Map<Object?, Object?>,
|
||||
)[widget.orderBy],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_streamSub = docsQuery.onValue.listen((DatabaseEvent snapshot) async {
|
||||
await tempSub?.cancel();
|
||||
|
||||
_data
|
||||
..clear()
|
||||
..addAll(snapshot.snapshot.children);
|
||||
|
||||
// To set new updates listener for the existing data
|
||||
// or to set new live listener if the first data node is removed.
|
||||
final isDataRemoved = snapshot.type == DatabaseEventType.childRemoved;
|
||||
|
||||
_isFetching = false;
|
||||
if (!isDataRemoved) {
|
||||
_isEnded = snapshot.snapshot.children.length < docsLimit;
|
||||
}
|
||||
|
||||
if (isDataRemoved || _isInitialLoading) {
|
||||
_isInitialLoading = false;
|
||||
if (snapshot.snapshot.children.isNotEmpty) {
|
||||
// Set updates listener for the existing data starting from the
|
||||
// first data node only.
|
||||
await _loadData(getMore: false);
|
||||
} else {
|
||||
_streamSub?.cancel();
|
||||
}
|
||||
if (widget.isLive) _setLiveListener();
|
||||
}
|
||||
|
||||
if (mounted) setState(() {});
|
||||
|
||||
// Add data till the view is scrollable. This ensures that the user can
|
||||
// scroll to the bottom and load more data.
|
||||
if (_isInitialLoading || _isFetching || _isEnded) return;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (_controller.hasClients && _controller.position.maxScrollExtent <= 0) {
|
||||
_loadData();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the live listener for the query.
|
||||
///
|
||||
/// Fires when new data is added to the query.
|
||||
Future<void> _setLiveListener() async {
|
||||
// To cancel previous live listener when new one is set.
|
||||
final tempSub = _liveStreamSub;
|
||||
|
||||
var latestDocQuery = widget.descending ? widget.query.limitToLast(1) : widget.query.limitToFirst(1);
|
||||
|
||||
if (_data.isNotEmpty) {
|
||||
if (widget.descending) {
|
||||
// Sets query to fetch data after the last element in the array,
|
||||
// which is the largest value.
|
||||
latestDocQuery = latestDocQuery.startAfter(
|
||||
Map<String, dynamic>.from(
|
||||
_data.last.value! as Map<Object?, Object?>,
|
||||
)[widget.orderBy],
|
||||
);
|
||||
} else {
|
||||
// Sets query to fetch data before the first element in the array,
|
||||
// whch is the smallest value
|
||||
latestDocQuery = latestDocQuery.endBefore(
|
||||
Map<String, dynamic>.from(
|
||||
_data.first.value! as Map<Object?, Object?>,
|
||||
)[widget.orderBy],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_liveStreamSub = latestDocQuery.onValue.listen(
|
||||
(DatabaseEvent snapshot) async {
|
||||
await tempSub?.cancel();
|
||||
if (snapshot.snapshot.children.isEmpty) return;
|
||||
|
||||
_data.insert(
|
||||
widget.descending ? _data.length : 0,
|
||||
snapshot.snapshot.children.first,
|
||||
);
|
||||
|
||||
// To handle newly added data after this curently loaded data.
|
||||
await _setLiveListener();
|
||||
|
||||
// Set updates listener for the newly added data.
|
||||
_loadData(getMore: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// To handle scroll end event and load more data.
|
||||
void _scrollListener() {
|
||||
if (_isInitialLoading || _isFetching || _isEnded) return;
|
||||
if (!_controller.hasClients) return;
|
||||
|
||||
final position = _controller.position;
|
||||
if (position.pixels >= (position.maxScrollExtent - 50)) {
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
_controller.addListener(_scrollListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub?.cancel();
|
||||
_liveStreamSub?.cancel();
|
||||
_controller
|
||||
..removeListener(_scrollListener)
|
||||
..dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isInitialLoading
|
||||
? widget.initialLoader
|
||||
: _data.isEmpty
|
||||
? widget.onEmpty
|
||||
: BuildPagination(
|
||||
items: widget.descending ? _data.reversed.toList() : _data,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
separatorBuilder: widget.separatorBuilder ?? separatorBuilder,
|
||||
isLoading: _isFetching,
|
||||
viewType: widget.viewType,
|
||||
bottomLoader: widget.bottomLoader,
|
||||
gridDelegate: widget.gridDelegate,
|
||||
wrapOptions: widget.wrapOptions,
|
||||
pageOptions: widget.pageOptions,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
reverse: widget.reverse,
|
||||
controller: _controller,
|
||||
pageController: _pageController,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
physics: widget.physics,
|
||||
padding: widget.padding,
|
||||
onPageChanged: (index) {
|
||||
if (index >= _data.length - 1) _loadData();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Flutter Packages
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A circular progress indicator that spins when the [Stream] is loading.
|
||||
///
|
||||
/// Used at the bottom of a [ScrollView] to indicate that more data is loading.
|
||||
class BottomLoader extends StatelessWidget {
|
||||
/// Creates a circular progress indicator that spins when the [Stream] is
|
||||
/// loading.
|
||||
///
|
||||
/// Used at the bottom of a [ScrollView] to indicate that more data is
|
||||
/// loading.
|
||||
const BottomLoader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 25,
|
||||
height: 25,
|
||||
margin: const EdgeInsets.all(10),
|
||||
child: const CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user