diff --git a/example/lib/repositories/posts_repository.dart b/example/lib/repositories/posts_repository.dart index baeece2..cb35039 100644 --- a/example/lib/repositories/posts_repository.dart +++ b/example/lib/repositories/posts_repository.dart @@ -11,12 +11,9 @@ class PostsRepository { // call api and get the response, and pass the contents // in the constructor for [PaginatedItemsResponse]. - // Don't forget to pass the idGetter parameter here. final res = await Dio().get('https://jsonplaceholder.typicode.com/posts'); - return PaginatedItemsResponse( - listItems: res.data?.map((e) => Post.fromJson(e)).cast(), - // no support for pagination for current api - paginationKey: null, + return PaginatedItemsResponse.fromListWithNoPaginationSupport( + data: res.data.map((e) => Post.fromJson(e)).cast().toList(), idGetter: (post) => post.id.toString(), ); } diff --git a/lib/src/models/paginated_items_response.dart b/lib/src/models/paginated_items_response.dart index 65e8798..ca42581 100644 --- a/lib/src/models/paginated_items_response.dart +++ b/lib/src/models/paginated_items_response.dart @@ -1,106 +1,248 @@ import 'dart:developer' as dev; -/// The response object that carries the list of items and handles pagination -/// internally. The [paginationKey] is optional and can be of any type. -/// If not passed, it is assumed that the API does not support pagination. -/// -/// The [idGetter] should be passed when receiving the response from the API, it -/// is required for functions like `updateItem`, `findByUid` -/// and avoiding duplication of items in list (compares id). +extension PaginationModelExtension on PaginatedItemsResponse? { + // do not call this like response?.update, call it like response.update... + PaginatedItemsResponse update( + PaginatedItemsResponse newResponse, { + bool reset = false, + }) { + return reset || this == null ? newResponse : this!._update(newResponse); + } +} + class PaginatedItemsResponse { - /// constructor + dynamic paginationKey; + List data; + int itemsPerPage; + final String Function(T) idGetter; + final dynamic Function(T) _paginationKeyGetter; + + List get items => data; + + dynamic getPaginationKeyForItem(T item) => _paginationKeyGetter(item); + + dynamic getPaginationKeyForItemAtIndex(int index) => + getPaginationKeyForItem(data[index]); + + static bool _calculateHasMore({ + required int dataLength, + required int itemsPerPage, + }) { + return dataLength >= itemsPerPage; + } + + late bool _hasMore; + + PaginatedItemsResponse.empty({ + required this.idGetter, + }) : paginationKey = null, + _hasMore = false, + itemsPerPage = 0, + data = const [], + _paginationKeyGetter = ((_) => null); + + PaginatedItemsResponse.fromListWithNoPaginationSupport({ + required this.data, + required this.idGetter, + }) : paginationKey = null, + _hasMore = false, + itemsPerPage = 0, + _paginationKeyGetter = ((_) => null); + PaginatedItemsResponse({ - String Function(T)? idGetter, - Iterable? listItems, - dynamic paginationKey, + required this.data, + required this.itemsPerPage, + required dynamic Function(T) paginationKeyGetter, + required this.idGetter, + dynamic defaultPaginationKey, + }) : _hasMore = _calculateHasMore( + dataLength: data.length, + itemsPerPage: itemsPerPage, + ), + _paginationKeyGetter = paginationKeyGetter, + paginationKey = defaultPaginationKey ?? + _getPaginationKey( + data, + paginationKeyGetter: paginationKeyGetter, + ); + + bool get hasMore => _hasMore; + + bool get isEmpty => data.isEmpty; + + bool get isNotEmpty => data.isNotEmpty; + + int get length => data.length; + + PaginatedItemsResponse updateListWithNewType( + N Function(T) mapper, { + required dynamic Function(N) paginationKeyGetter, + required String Function(N) idGetter, }) { - _idGetterFn ??= idGetter; - _update(listItems, paginationKey); + return PaginatedItemsResponse( + data: data.map(mapper).toList().cast(), + itemsPerPage: itemsPerPage, + idGetter: idGetter, + paginationKeyGetter: paginationKeyGetter, + defaultPaginationKey: paginationKey, + ).._hasMore = _hasMore; } - /// List of items of type [T] - List? items; + void addAll( + Iterable newData, { + bool shouldUpdatePaginationKey = true, + }) { + data = [...data]; - /// The pagination key. Can be null. - dynamic paginationKey; + final existingIds = data.map(idGetter).toSet(); - /// ID getter for the object of type [T]. - String Function(T)? _idGetterFn; + data.addAll( + newData.where((e) => !existingIds.contains(idGetter(e))), + ); - /// If pagination supported, check if there is more data that can be loaded. - bool get hasMoreData => paginationKey != null; + if (shouldUpdatePaginationKey) updatePaginationKey(); + } - /// True if [items] is not null. - bool get hasData => items != null; + PaginatedItemsResponse _update(PaginatedItemsResponse newResponse) { + itemsPerPage = newResponse.itemsPerPage; - /// Find an object by [id]. - // ignore: body_might_complete_normally_nullable - T? findByUid(String id) { - final idx = items?.indexWhere((e) => id == _idGetterFn!(e)); - if (idx != null && idx != -1) return items?[idx]; + _hasMore = _calculateHasMore( + dataLength: newResponse.length, + itemsPerPage: itemsPerPage, + ); + + if (newResponse.isNotEmpty) { + addAll(newResponse.data, shouldUpdatePaginationKey: false); + paginationKey = newResponse.paginationKey; + } + + return this; } - T? operator [](int index) => items?[index]; + void insert( + T item, { + int insertionIdx = 0, + bool removeLastIfExceedsItemsPerPage = true, + bool shouldUpdatePaginationKey = true, + }) { + data = [...data]; + data.insert(insertionIdx, item); - void operator []=(int index, T value) => items?[index] = value; + if (removeLastIfExceedsItemsPerPage && data.length > itemsPerPage) { + data.removeLast(); + } - /// update a specific item with uid, or add if does not exists according to - /// [addIfDoesNotExist]. - /// - /// If [item] is null, then the item is removed. - void updateItem( - String itemUid, - T? item, { - bool addIfDoesNotExist = false, + if (shouldUpdatePaginationKey) updatePaginationKey(); + } + + void updatePaginationKey() { + paginationKey = _getPaginationKey( + data, + paginationKeyGetter: _paginationKeyGetter, + ); + } + + static dynamic _getPaginationKey( + List data, { + required dynamic Function(T) paginationKeyGetter, }) { - final idx = items!.indexWhere((e) => itemUid == _idGetterFn!(e)); - if (idx != -1) { - if (item == null) { - items!.removeAt(idx); - } else { - items![idx] = item; - } + return data.isEmpty ? null : paginationKeyGetter(data.last); + } + + PaginatedItemsResponse map( + T Function(T) mapper, { + bool inPlace = false, + bool shouldUpdatePaginationKey = true, + }) { + if (inPlace) { + data = data.map(mapper).toList().cast(); + if (shouldUpdatePaginationKey) updatePaginationKey(); + return this; } else { - if (item != null && addIfDoesNotExist) items!.add(item); + return PaginatedItemsResponse( + data: data.map(mapper).toList().cast(), + itemsPerPage: itemsPerPage, + idGetter: idGetter, + defaultPaginationKey: paginationKey, + paginationKeyGetter: _paginationKeyGetter, + ).._hasMore = _hasMore; } } - /// Updates the response - void update(PaginatedItemsResponse res) { - _update(res.items, res.paginationKey); + PaginatedItemsResponse filter( + bool Function(T) predicate, { + bool inPlace = false, + bool shouldUpdatePaginationKey = true, + }) { + if (inPlace) { + data = data.where(predicate).toList().cast(); + if (shouldUpdatePaginationKey) updatePaginationKey(); + return this; + } else { + return PaginatedItemsResponse( + data: data.where(predicate).toList().cast(), + itemsPerPage: itemsPerPage, + idGetter: idGetter, + defaultPaginationKey: paginationKey, + paginationKeyGetter: _paginationKeyGetter, + ).._hasMore = _hasMore; + } } - /// Append items to the list, after a successful fetch from the API. - void _update(Iterable? listItems, dynamic key) { - items ??= []; - if (listItems != null) { - for (final item in listItems) { - updateItem(_idGetterFn!(item), item, addIfDoesNotExist: true); - } + bool updateItemWithId( + String id, { + required T Function(T?) updater, + bool addIfDoesNotExist = false, + bool shouldUpdatePaginationKey = true, + }) { + bool didUpdate = false; + + final index = data.indexWhere((e) => idGetter(e) == id); + if (addIfDoesNotExist && index == -1) { + data.add(updater(null)); + didUpdate = true; + } else { + data[index] = updater(data[index]); + didUpdate = true; } - paginationKey = key; + + if (didUpdate && shouldUpdatePaginationKey) updatePaginationKey(); + + return didUpdate; + } + + T? findById(String id) { + final idx = data.indexWhere((e) => id == idGetter(e)); + if (idx != -1) return data[idx]; + + return null; } - /// Logs the response. void log() => dev.log('\n${toString()}', name: 'PaginatedItemsResponse<$T>'); @override String toString() { - final itemsArrString = items - ?.map((item) => item.toString()) + final itemsArrString = data + .map((item) => item.toString()) .map((itemName) => '\t\t$itemName,') .join('\n'); return """ PaginatedItemsResponse<$T>({ - items: ${items == null ? 'null' : '[\n$itemsArrString\n\t],'} + items: [\n$itemsArrString\n\t], paginationKey: $paginationKey, });"""; } /// Clear the contents. void clear() { - items = null; + data = []; + itemsPerPage = 0; + _hasMore = false; paginationKey = null; } + + void operator []=(int index, T value) => data[index] = value; + + T operator [](int index) => data[index]; } diff --git a/lib/src/paginated_items_builder.dart b/lib/src/paginated_items_builder.dart index 4000d8f..941f559 100644 --- a/lib/src/paginated_items_builder.dart +++ b/lib/src/paginated_items_builder.dart @@ -69,6 +69,8 @@ class PaginatedItemsBuilder extends StatefulWidget { this.addSemanticIndexes = true, this.dragStartBehavior = DragStartBehavior.start, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.hitTestBehavior = HitTestBehavior.opaque, + this.findChildIndexCallback, }); /// This is the controller function that should handle fetching the list @@ -436,6 +438,13 @@ class PaginatedItemsBuilder extends StatefulWidget { /// an (implicit) scroll action. final double? cacheExtent; + /// Defines the behavior of gesture detector used in this [Scrollable]. + /// This defaults to [HitTestBehavior.opaque] which means it prevents targets behind this [Scrollable] from receiving events. + /// Defaults to [HitTestBehavior.opaque]. + final HitTestBehavior hitTestBehavior; + + final ChildIndexGetter? findChildIndexCallback; + /// The content will be clipped (or not) according to this option. /// /// See the enum Clip for details of all possible options and @@ -603,9 +612,9 @@ class _PaginatedItemsBuilderState extends State> { if (!showMainLoader && widget.response?.items != null) { // bottom loader // passing index only for bottom loader, to update [_lastLoaderBuiltIndex] - if (widget.response!.items!.length <= index) return _loaderBuilder(index); + if (widget.response!.items.length <= index) return _loaderBuilder(index); - final item = widget.response!.items![index]; + final item = widget.response!.items[index]; return widget.itemBuilder(context, index, item); } else { // initial loader @@ -758,15 +767,14 @@ class _PaginatedItemsBuilderState extends State> { // bottom loader is always built, as when rendered in view, // calls _fetchData to fetch more data.. - showBottomLoader = - widget.paginate && (widget.response?.hasMoreData ?? false); + showBottomLoader = widget.paginate && (widget.response?.hasMore ?? false); // set: itemCount (() { int itemsLen = widget.loaderItemsCount; if (!showMainLoader) { - if (widget.response?.items?.length != null) { - itemsLen = widget.response!.items!.length; + if (widget.response?.items.length != null) { + itemsLen = widget.response!.items.length; } itemsLen += showBottomLoader ? 1 : 0; } @@ -783,7 +791,7 @@ class _PaginatedItemsBuilderState extends State> { } } else if (hasError) { return _errorWidget(); - } else if (widget.response?.items?.isEmpty ?? false) { + } else if (widget.response?.items.isEmpty ?? false) { return _noItemsWidget(); } else if (widget.disableRefreshIndicator || widget.shrinkWrap || @@ -826,6 +834,8 @@ class _PaginatedItemsBuilderState extends State> { reverse: widget.reverse, clipBehavior: widget.clipBehaviour, cacheExtent: widget.cacheExtent, + hitTestBehavior: widget.hitTestBehavior, + findChildIndexCallback: widget.findChildIndexCallback, itemBuilder: _itemBuilder, padding: widget.padding ?? config.padding, separatorBuilder: (_, __) => @@ -855,6 +865,8 @@ class _PaginatedItemsBuilderState extends State> { reverse: widget.reverse, clipBehavior: widget.clipBehaviour, cacheExtent: widget.cacheExtent, + hitTestBehavior: widget.hitTestBehavior, + findChildIndexCallback: widget.findChildIndexCallback, itemBuilder: _itemBuilder, gridDelegate: widget.gridDelegate ?? SliverGridDelegateWithFixedCrossAxisCount(