diff --git a/packages/patapata_core/lib/patapata_widgets.dart b/packages/patapata_core/lib/patapata_widgets.dart index 01e8eb2..c3e19c4 100644 --- a/packages/patapata_core/lib/patapata_widgets.dart +++ b/packages/patapata_core/lib/patapata_widgets.dart @@ -8,3 +8,4 @@ library patapata_widgets; export 'src/widgets/standard_app.dart'; export 'src/widgets/screen_layout.dart'; export 'src/widgets/platform_dialog.dart'; +export 'src/widgets/infinite_scroll_list_view.dart'; diff --git a/packages/patapata_core/lib/src/log.dart b/packages/patapata_core/lib/src/log.dart index 3c8ae3d..29b29e9 100644 --- a/packages/patapata_core/lib/src/log.dart +++ b/packages/patapata_core/lib/src/log.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -976,9 +977,10 @@ class NativeThrowable { final tLine = tMatch.group(tLineGroup); return Frame( Uri.file( - '${tMatch.group(tPackageGroup) ?? ''}${(tFileGroup != null ? '/${tMatch.group(tFileGroup)}' : null) ?? '/'}' - .replaceAll(' ', ''), - windows: false), + '${tMatch.group(tPackageGroup) ?? ''}${(tFileGroup != null ? '/${tMatch.group(tFileGroup)}' : null) ?? '/'}' + .replaceAll(' ', ''), + windows: false, + ), tLine != null ? int.tryParse(tLine) : null, null, tMatch.group(tMethodGroup), @@ -1025,9 +1027,10 @@ class NativeThrowable { final tPratformStackTrace = List.generate(tFrames?.length ?? 0, (index) { final tFrame = tFrames![index]; try { + final tPathSeparator = (kIsWeb) ? '/' : Platform.pathSeparator; switch (defaultTargetPlatform) { case TargetPlatform.android: - final tLibrary = tFrame.library.split('/'); + final tLibrary = tFrame.library.split(tPathSeparator); final tLine = tFrame.line != null ? ':${tFrame.line}' : ''; final tPackageGroup = tLibrary[0].replaceAll('', ''); final tFileGroup = tLibrary[1].replaceAll('', ''); @@ -1036,7 +1039,7 @@ class NativeThrowable { return ('$tPackageGroup${tPackageGroup.isNotEmpty ? '.' : ''}$tMethodGroup($tFileGroup$tLine)') .replaceAll('', ' '); case TargetPlatform.iOS: - final tLibrary = tFrame.library.split('/'); + final tLibrary = tFrame.library.split(tPathSeparator); final tLine = tFrame.line != null ? ' + ${tFrame.line}' : ''; final tPackageGroup = tLibrary[0].replaceAll('', ''); final tMethodGroup = tFrame.member ?? ''; diff --git a/packages/patapata_core/lib/src/widgets/infinite_scroll_list_view.dart b/packages/patapata_core/lib/src/widgets/infinite_scroll_list_view.dart new file mode 100644 index 0000000..8aa6310 --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/infinite_scroll_list_view.dart @@ -0,0 +1,1502 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:provider/provider.dart'; + +/// A widget that provides infinite scrolling functionality to [ListView] or [GridView]. +/// +/// This widget fetches data of type [T] using either of the [fetchNext] or [fetchPrevious] methods +/// when the user scrolls the list or grid and calls the [itemBuilder]. +/// +/// Each item's Widget has a Provider in its parent, and you can access each item's information +/// using methods like `context.read()` or `context.read()`, etc. +/// +/// If the item implements [Listenable], the parent uses [InheritedProvider] to register listeners. +/// This enables the detection of changes to the item and the rebuilding of the item accordingly. +/// +/// It also supports pull-to-refresh functionality. +/// +/// For example: +/// ```dart +/// const int kMaxFetchCount = 20; +/// const int kItemMaxLength = 100; +/// +/// InfiniteScrollListView.list( +/// initialIndex: () => 0, +/// fetchPrev: (index, crossAxisCount) async { +/// final tStartIndex = max(0, index - kMaxFetchCount + 1); +/// final tFetchCount = index + 1 - tStartIndex; +/// +/// return List.generate(tFetchCount, (i) => 'Item ${tStartIndex + i}'); +/// }, +/// fetchNext: (index, crossAxisCount) async { +/// final tFetchCount = min(kMaxFetchCount, kItemMaxLength - index); +/// +/// return List.generate(tFetchCount, (i) => 'Item ${index + i}'); +/// }, +/// canFetchPrev: (index) => index >= 0, +/// canFetchNext: (index) => index < kItemMaxLength, +/// itemBuilder: (context, item, index) { +/// return ListTile( +/// title: Text(item), +/// ); +/// }, +/// ); +/// ``` +class InfiniteScrollListView extends StatefulWidget { + /// Changing this key resets the data and fetches data again starting from [initialIndex]. + final Key? dataKey; + + /// Function to fetch the next set of items when scrolling forward. + /// + /// [index] is the starting index of the data to be fetched next. + /// For example, if data 0-19 is currently displayed, the next data will be fetched starting from 20. + /// + /// [crossAxisCount] is the number of children along the horizontal axis in a GridView. + /// For a ListView, it is 1. + /// + /// If this method returns an empty List, it is determined that data fetching has ended, + /// and no further data will be fetched. + final Future> Function(int index, int crossAxisCount) fetchNext; + + /// Function to fetch the previous set of items when scrolling backward. + /// + /// [index] is the last index of the data to be fetched next. + /// For example, if data 20-39 is currently displayed, the previous data will be fetched starting from 19. + /// + /// [crossAxisCount] is the number of children along the horizontal axis in a GridView. + /// For a ListView, it is 1. + /// + /// If this method returns an empty List, it is determined that data fetching has ended, + /// and no further data will be fetched. + final Future> Function(int index, int crossAxisCount)? fetchPrev; + + /// Determines whether more data can be fetched when scrolling forward. + /// + /// If this method returns false, it is determined that data fetching has ended, + /// [fetchNext] is not executed, and no further data will be fetched. + /// + /// [index] is the starting index of the data to be fetched next. + /// For example, if data 0-19 is currently displayed, the next data will be fetched starting from 20. + final bool Function(int index)? canFetchNext; + + /// Determines whether more data can be fetched when scrolling backward. + /// + /// If this method returns false, it is determined that data fetching has ended, + /// [fetchPrev] is not executed, and no further data will be fetched. + /// + /// [index] is the last index of the data to be fetched next. + /// For example, if data 20-39 is currently displayed, the previous data will be fetched starting from 19. + final bool Function(int index)? canFetchPrev; + + /// Called when the list scroll ends and returns the topmost index of the list. + /// + /// If [overlaySlivers] or [prefix] are included, the bottom of those areas at a + /// scroll offset of 0 is considered the top of the list. + /// + /// [crossAxisCount] is the number of children along the horizontal axis in a GridView. + /// For a ListView, it is 1. + final void Function(int index, int crossAxisCount)? onIndexChanged; + + /// Function that returns the initial index to scroll to. + /// + /// [fetchNext] is called with the index specified by [initialIndex] during initialization. + /// + /// If a value less than 0 is returned, it is set to 0. + /// + /// In the case of a GridView, it is rounded to a multiple of crossAxisCount. + final int Function()? initialIndex; + + /// Callback invoked when the index specified by [initialIndex] cannot be fetched by [fetchNext] + /// during initialization (e.g., when [fetchNext] returns an empty List). + /// + /// [index] is the index used when calling [fetchNext]. This value is the same as [initialIndex]. + /// + /// Returning true will re-initialize, causing [initialIndex] to be called again. + /// Returning false will abort the initialization and display an empty list. + /// + /// If the number of retry attempts exceeds 10, [giveup] becomes true, + /// and initialization is forcibly aborted even if this method returns true. + final bool Function(int index, bool giveup)? initialIndexNotFoundCallback; + + /// A list of slivers to overlay on top of the scroll view. + final List? overlaySlivers; + + /// The amount of space by which to inset the children. + final EdgeInsets? padding; + + /// Whether pull-to-refresh is enabled. + final bool canRefresh; + + /// Callback when a refresh is triggered. + final void Function()? onRefresh; + + /// A widget to display before the list of items. + final Widget? prefix; + + /// A widget to display after the list of items. + final Widget? suffix; + + /// Widget to display when the list is empty. + final Widget? empty; + + /// Widget to display while the initial data is loading. + final Widget? loading; + + /// Widget to display while loading more data. + final Widget? loadingMore; + + /// Builder function to render each item in the list or grid. + final Widget Function(BuildContext context, T item, int index) itemBuilder; + + /// Builder to display an error message when data loading fails. + /// + /// This is displayed when an exception occurs during the calls to [fetchNext] or [fetchPrev]. + final Widget Function(BuildContext context, Object error)? errorBuilder; + + /// Builder for the refresh indicator widget. + final Widget Function( + BuildContext context, Widget child, Future Function() refresh)? + refreshIndicatorBuilder; + + /// The delegate that controls the layout of the grid tiles. + final SliverGridDelegate? gridDelegate; + + /// A controller for an infinite scroll list view. + final ScrollController? controller; + + /// The axis along which the scroll view scrolls. + final Axis scrollDirection; + + /// How to clip overflowing content. + final Clip clipBehavior; + + /// The extent of the area in which content is cached. + final double? cacheExtent; + + /// How the scroll view should respond to user input. + final ScrollPhysics? physics; + + /// Whether this is the primary scroll view associated with the parent PrimaryScrollController. + final bool? primary; + + final Widget Function( + BuildContext context, + Widget? Function(BuildContext context, int index) itemBuilder, + int count) _listBuilder; + + /// Creates an infinite scrolling list view. + InfiniteScrollListView.list({ + super.key, + required this.fetchNext, + this.fetchPrev, + required this.itemBuilder, + this.onIndexChanged, + this.dataKey, + this.physics, + this.scrollDirection = Axis.vertical, + this.primary, + this.initialIndex, + this.initialIndexNotFoundCallback, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.cacheExtent, + this.overlaySlivers, + this.padding, + this.canRefresh = true, + this.refreshIndicatorBuilder, + this.prefix, + this.suffix, + this.empty, + this.loading, + this.loadingMore, + this.errorBuilder, + this.onRefresh, + this.canFetchNext, + this.canFetchPrev, + }) : gridDelegate = null, + _listBuilder = ((context, itemBuilder, count) => SliverList( + delegate: SliverChildBuilderDelegate( + childCount: count, + itemBuilder, + ), + )); + + /// Creates an infinite scrolling grid view. + InfiniteScrollListView.grid({ + super.key, + required this.fetchNext, + this.fetchPrev, + required this.itemBuilder, + required this.gridDelegate, + this.onIndexChanged, + this.dataKey, + this.physics, + this.scrollDirection = Axis.vertical, + this.primary, + this.initialIndex, + this.initialIndexNotFoundCallback, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.cacheExtent, + this.overlaySlivers, + this.padding, + this.canRefresh = true, + this.refreshIndicatorBuilder, + this.prefix, + this.suffix, + this.empty, + this.loading, + this.loadingMore, + this.errorBuilder, + this.onRefresh, + this.canFetchNext, + this.canFetchPrev, + }) : _listBuilder = ((context, itemBuilder, count) => SliverGrid( + delegate: SliverChildBuilderDelegate( + childCount: count, + itemBuilder, + ), + gridDelegate: gridDelegate!, + )); + + @override + State> createState() => + _InfiniteScrollListViewState(); +} + +class _InfiniteScrollListViewState extends State> { + Object _key = Object(); + bool _initialized = false; + bool _readyViewed = false; + bool _backwardLoadTriggerFirstLayout = true; + int _initialIndex = 0; + int _topIndex = 0; + int _crossAxisCount = 0; + + final Map> _sliverListKeys = {}; + final _shouldLoadDataIndex = [0, 0]; + final _data = {}; + final List _backedItemSizeList = []; + final _dataSourceExhausted = [false, false]; + + double _prefixScrollExtent = 0.0; + double _backedLoadTriggerSize = 0.0; + + Object? _error; + Object? _fetchNextKey; + Object? _fetchPrevKey; + + bool _canFetchNext(int index) => + !_dataSourceExhausted.last && (widget.canFetchNext?.call(index) ?? true); + bool _canFetchPrev(int index) => + (widget.fetchPrev != null && !_dataSourceExhausted.first) && + (widget.canFetchPrev?.call(index) ?? true); + + @override + void initState() { + super.initState(); + _reset(); + + _initialize(); + } + + @override + void didUpdateWidget(covariant InfiniteScrollListView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.dataKey != widget.dataKey) { + _reset(); + _initialize(); + } + } + + Future _initialize([int retryCount = 0]) async { + void fSetState() { + if (mounted) { + setState(() { + if (_data.isEmpty) { + _readyViewed = true; + } + + _initialized = true; + }); + } + } + + try { + if (widget.gridDelegate != null) { + if (_crossAxisCount < 1) { + return; + } + _initialIndex = _initialIndex - (_initialIndex % _crossAxisCount); + assert(_initialIndex >= 0); + + _topIndex = _initialIndex; + _shouldLoadDataIndex.first = _initialIndex - 1; + _shouldLoadDataIndex.last = _initialIndex; + } else { + _crossAxisCount = 1; + } + + if (_canFetchNext(_initialIndex)) { + bool tResult = false; + tResult = await _fetch( + _initialIndex, + backward: false, + doSetter: false, + ); + if (!tResult || !mounted) { + return; + } + } + + if (widget.initialIndex != null) { + if (!_data.containsKey(_initialIndex)) { + final tCanRetry = (retryCount < 10); + if (widget.initialIndexNotFoundCallback + ?.call(_initialIndex, !tCanRetry) == + true && + tCanRetry) { + final tCrossAxisCount = _crossAxisCount; + _reset(); + _crossAxisCount = tCrossAxisCount; + await _initialize(retryCount + 1); + return; + } + + _data.clear(); + _dataSourceExhausted.first = true; + _dataSourceExhausted.last = true; + } + } + + fSetState(); + } catch (e) { + _error = e; + + fSetState(); + rethrow; + } + } + + void _reset() { + _key = Object(); + _initialized = false; + _readyViewed = false; + _backwardLoadTriggerFirstLayout = true; + _initialIndex = widget.initialIndex?.call() ?? 0; + if (_initialIndex < 0) { + _initialIndex = 0; + } + _topIndex = _initialIndex; + _crossAxisCount = 0; + _sliverListKeys.clear(); + _shouldLoadDataIndex.first = _initialIndex - 1; + _shouldLoadDataIndex.last = _initialIndex; + _data.clear(); + _backedItemSizeList.clear(); + _dataSourceExhausted.first = false; + _dataSourceExhausted.last = false; + + _prefixScrollExtent = 0.0; + _backedLoadTriggerSize = 0.0; + + _error = null; + _fetchNextKey = null; + _fetchPrevKey = null; + } + + Future _fetch( + int index, { + required bool backward, + bool doSetter = true, + bool layoutInProgress = false, + }) async { + if (backward) { + if (!_canFetchPrev(index) || _fetchPrevKey != null) { + return false; + } + } else { + if (!_canFetchNext(index) || _fetchNextKey != null) { + return false; + } + } + + final tFetchKey = Object(); + if (backward) { + _fetchPrevKey = tFetchKey; + } else { + _fetchNextKey = tFetchKey; + } + + bool fCanContinue() { + if (!mounted) { + return false; + } + if (backward) { + if (tFetchKey != _fetchPrevKey) { + return false; + } + } else if (tFetchKey != _fetchNextKey) { + return false; + } + + return true; + } + + try { + final tItems = await (backward + ? widget.fetchPrev?.call(index, _crossAxisCount) + : widget.fetchNext.call(index, _crossAxisCount)); + if (tItems == null || !fCanContinue()) { + return false; + } + + fFinally() { + if (backward) { + _fetchPrevKey = null; + _shouldLoadDataIndex.first -= tItems.length; + for (int i = 0; i < tItems.length; i++) { + _data[index + 1 - tItems.length + i] = tItems[i]; + } + if (tItems.isNotEmpty) { + _backedItemSizeList.add(tItems.length); + } + _dataSourceExhausted.first = tItems.isEmpty; + } else { + _fetchNextKey = null; + _shouldLoadDataIndex.last += tItems.length; + for (int i = 0; i < tItems.length; i++) { + _data[index + i] = tItems[i]; + } + _dataSourceExhausted.last = tItems.isEmpty; + } + } + + if (doSetter) { + // This method may be called while the widget is being built, laid out, or painted. + // If fetchNext or fetchPrev completes synchronously, calling setState will trigger an assert. + // Therefore, to avoid asserts, call it asynchronously using Future.microtask. + if (layoutInProgress) { + Future.microtask(() { + if (fCanContinue()) { + setState(fFinally); + } + }); + } else { + // There is no pattern going in here in the current implementation. + setState(fFinally); // coverage:ignore-line + } + } else { + fFinally(); + } + + return true; + } catch (e) { + fSetState() { + Future.microtask(() { + if (mounted) { + setState(() {}); + } + }); + } + + if (backward && (tFetchKey == _fetchPrevKey)) { + _fetchPrevKey = null; + _error = e; + fSetState(); + } else if (tFetchKey == _fetchNextKey) { + _fetchNextKey = null; + _error = e; + fSetState(); + } + + rethrow; + } + } + + bool _onShortage( + bool backward, + VoidCallback onProgressive, + ) { + Future? tFuture; + + if (backward) { + if (_canFetchPrev(_shouldLoadDataIndex.first)) { + tFuture = _fetch( + _shouldLoadDataIndex.first, + backward: true, + doSetter: false, + ); + } + } else if (_canFetchNext(_shouldLoadDataIndex.last)) { + tFuture = _fetch( + _shouldLoadDataIndex.last, + backward: false, + doSetter: false, + ); + } + + tFuture?.whenComplete(() { + if (mounted) { + setState(onProgressive); + } + }); + + return tFuture != null; + } + + @override + Widget build(BuildContext context) { + late Widget tWidget; + + final tLoading = widget.loading ?? + const Center( + child: CircularProgressIndicator(), + ); + + tWidget = _CustomScrollView( + key: ObjectKey(_key), + physics: widget.physics, + scrollDirection: widget.scrollDirection, + primary: widget.primary, + controller: widget.controller, + clipBehavior: widget.clipBehavior, + cacheExtent: widget.cacheExtent, + onShortage: _onShortage, + initializing: !_initialized, + onReady: () { + scheduleFunction(() { + if (!mounted) { + return; + } + + setState(() { + _readyViewed = true; + }); + }); + }, + onUpdatePrefixExtent: (prefixScrollExtent) { + _prefixScrollExtent = prefixScrollExtent; + if (_readyViewed) { + scheduleFunction(() { + if (!mounted) { + return; + } + + setState(() {}); + }); + } + }, + slivers: [ + if (_initialized) ...[ + for (int i = 0; i < (widget.overlaySlivers?.length ?? -1); i++) + _PrefixSliverProxy( + key: ValueKey('Overlay:$i'), + child: widget.overlaySlivers![i], + ), + if (widget.prefix != null) + _PrefixSliverProxy( + key: const ValueKey('Prefix'), + child: SliverToBoxAdapter( + child: widget.prefix!, + ), + ), + ..._buildList(), + if (widget.suffix != null && _readyViewed) + SliverToBoxAdapter( + child: widget.suffix!, + ), + ] else if (widget.gridDelegate != null && _crossAxisCount < 1) ...[ + _SliverListIndexListener( + onNotification: (index, crossAxisCount) { + _crossAxisCount = crossAxisCount; + scheduleFunction(() { + if (!mounted) { + return; + } + _initialize(); + }); + }, + backward: false, + offsetCorrection: false, + child: SliverGrid( + gridDelegate: widget.gridDelegate!, + delegate: SliverChildBuilderDelegate( + childCount: 1, + (context, index) => const SizedBox.shrink(), + ), + ), + ) + ], + ], + ); + + tWidget = Stack( + fit: StackFit.expand, + children: [ + Visibility.maintain( + visible: _readyViewed || + (widget.gridDelegate != null && _crossAxisCount < 1), + child: tWidget, + ), + if (!_readyViewed) + SizedBox.expand( + child: tLoading, + ), + ], + ); + + if (widget.canRefresh) { + if (widget.refreshIndicatorBuilder != null) { + tWidget = widget.refreshIndicatorBuilder!(context, tWidget, _onRefresh); + } else { + tWidget = RefreshIndicator( + onRefresh: _onRefresh, + child: tWidget, + ); + } + } + + return NotificationListener( + onNotification: (notification) { + if (_readyViewed && _data.isNotEmpty) { + widget.onIndexChanged?.call(_topIndex, _crossAxisCount); + } + return false; + }, + child: tWidget, + ); + } + + List _buildList() { + if (_error != null) { + return [ + SliverFillRemaining( + child: widget.errorBuilder?.call(context, _error!) ?? + const SizedBox.shrink(), + ), + ]; + } + if (_data.isEmpty) { + return [ + SliverFillRemaining( + child: widget.empty ?? const SizedBox.shrink(), + ), + ]; + } + + Widget fBuildSliverList( + int startIndex, + int length, + bool backward, + double backedLoadTriggerSize, + ) { + final tKeyValue = 'List:$startIndex'; + ValueKey? tKey = _sliverListKeys[tKeyValue]; + final tFirstLayout = tKey == null; + + tKey ??= ValueKey(tKeyValue); + _sliverListKeys[tKeyValue] = tKey; + + // Do not wrap _SliverListIndexListener in another widget at this point. + return _SliverListIndexListener( + key: tKey, + backward: backward, + backedLoadTriggerSize: backedLoadTriggerSize, + notificationOffset: () => _prefixScrollExtent, + offsetCorrection: tFirstLayout && backward, + onNotification: (index, crossAxisCount) { + if (_readyViewed) { + _topIndex = index + startIndex; + _crossAxisCount = crossAxisCount; + } + }, + child: widget._listBuilder( + context, + (context, index) => _builder(context, startIndex + index), + length, + ), + ); + } + + fBuildLoadTrigger(bool backward) { + final tKeyValue = (backward) + ? 'Load:${_shouldLoadDataIndex.first}' + : 'Load:${_shouldLoadDataIndex.last}'; + ValueKey? tKey = _sliverListKeys[tKeyValue]; + final tFirstLayout = tKey == null; + + tKey ??= ValueKey(tKeyValue); + _sliverListKeys[tKeyValue] = tKey; + + final Widget tWidget; + if (backward) { + final tBackwardLoadTriggerFirstLayout = _backwardLoadTriggerFirstLayout; + _backwardLoadTriggerFirstLayout = false; + + tWidget = _LoadTrigger( + key: tKey, + backward: true, + offsetCorrection: tBackwardLoadTriggerFirstLayout && tFirstLayout, + load: (triggerWidgetSize) { + _backedLoadTriggerSize = triggerWidgetSize; + _fetch( + _shouldLoadDataIndex.first, + backward: true, + doSetter: true, + layoutInProgress: true, + ); + }, + child: Builder(builder: (_) { + return _buildLoading(); + }), + ); + } else { + tWidget = _LoadTrigger( + key: tKey, + backward: false, + offsetCorrection: false, + load: (_) { + _fetch( + _shouldLoadDataIndex.last, + backward: false, + doSetter: true, + layoutInProgress: true, + ); + }, + child: Builder(builder: (_) { + return _buildLoading(); + }), + ); + } + + return tWidget; + } + + final tBackwardWidgetList = []; + + if (_backedItemSizeList.isNotEmpty) { + int tListFirstIndex = _shouldLoadDataIndex.first + 1; + for (int i = _backedItemSizeList.length - 1; i >= 0; i--) { + final tLength = _backedItemSizeList[i]; + tBackwardWidgetList.add(fBuildSliverList( + tListFirstIndex, + tLength, + true, + (i == _backedItemSizeList.length - 1 ? _backedLoadTriggerSize : 0.0), + )); + + tListFirstIndex += tLength; + } + } + + final Widget tForwardWidget = fBuildSliverList( + _initialIndex, + _shouldLoadDataIndex.last - _initialIndex, + false, + 0.0, + ); + + // Widgets within tWidgetList are not intended to be wrapped in a widget other than SliverPadding. + final tWidgetList = [ + if (_readyViewed && _canFetchPrev(_shouldLoadDataIndex.first)) + fBuildLoadTrigger(true), + ...tBackwardWidgetList, + tForwardWidget, + if (_readyViewed && _canFetchNext(_shouldLoadDataIndex.last)) + fBuildLoadTrigger(false), + ]; + if (widget.padding != null) { + Widget fWrapPadding(Widget widget, EdgeInsets padding) { + return SliverPadding( + key: widget.key, + padding: padding, + sliver: widget, + ); + } + + if (tWidgetList.length == 1) { + tWidgetList[0] = fWrapPadding(tWidgetList[0], widget.padding!); + } else { + for (int i = 0; i < tWidgetList.length; i++) { + final tPadding = (tWidgetList[i] == tWidgetList.first) + ? widget.padding!.copyWith(bottom: 0) + : (tWidgetList[i] == tWidgetList.last) + ? widget.padding!.copyWith(top: 0) + : widget.padding!.copyWith(top: 0, bottom: 0); + + tWidgetList[i] = fWrapPadding(tWidgetList[i], tPadding); + } + } + } + + return tWidgetList; + } + + Future _onRefresh() async { + setState(() { + widget.onRefresh?.call(); + _reset(); + _initialize(); + }); + } + + VoidCallback _startListening( + InheritedContext e, + T value, + ) { + (value as Listenable).addListener(e.markNeedsNotifyDependents); + return () => value.removeListener(e.markNeedsNotifyDependents); + } + + Widget _buildLoading() { + return widget.loadingMore ?? + const SizedBox( + height: 104, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + Widget _builder(BuildContext context, int index) { + final tItem = _data[index]; + if (tItem != null) { + return MultiProvider( + key: ValueKey(index), + providers: [ + Provider( + create: (context) => InfiniteScrollItemInformation(index), + ), + if (tItem is Listenable) ...[ + InheritedProvider.value( + value: tItem, + startListening: _startListening, + ), + ] else ...[ + Provider.value( + value: tItem, + ), + ] + ], + builder: (context, _) => widget.itemBuilder(context, tItem, index), + ); + } + + // coverage:ignore-start + assert(false, 'Invalid index'); + return const SizedBox.shrink(); + // coverage:ignore-end + } +} + +/// Provides information about an item in the infinite scroll list. +/// +/// Registered as a Provider in each item's parent Widget, and can be accessed +/// using [context.read], etc. +class InfiniteScrollItemInformation { + /// The index of the item. + final int index; + + /// Creates an [InfiniteScrollItemInformation] with the given index. + const InfiniteScrollItemInformation( + this.index, + ); +} + +class _PrefixSliverProxy extends SingleChildRenderObjectWidget { + const _PrefixSliverProxy({ + super.key, + required super.child, + }); + + @override + _RenderPrefixSliverProxy createRenderObject(BuildContext context) { + return _RenderPrefixSliverProxy(); + } +} + +class _RenderPrefixSliverProxy extends RenderProxySliver {} + +typedef _OnShortage = bool Function( + bool backward, + VoidCallback onProgressive, +); +typedef _OnReady = void Function(); +typedef _OnUpdatePrefixExtent = void Function(double prefixScrollExtent); + +class _CustomScrollView extends CustomScrollView { + const _CustomScrollView({ + super.key, + super.scrollDirection, + super.controller, + super.primary, + super.physics, + super.cacheExtent, + super.slivers, + super.clipBehavior, + required this.onShortage, + required this.onReady, + required this.onUpdatePrefixExtent, + this.initializing = false, + }); + + final _OnShortage onShortage; + final _OnReady onReady; + final _OnUpdatePrefixExtent onUpdatePrefixExtent; + final bool initializing; + + @override + Widget buildViewport( + BuildContext context, + ViewportOffset offset, + AxisDirection axisDirection, + List slivers, + ) { + return _Viewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + cacheExtent: cacheExtent, + center: center, + anchor: anchor, + clipBehavior: clipBehavior, + onShortage: onShortage, + onReady: onReady, + onUpdatePrefixExtent: onUpdatePrefixExtent, + initializing: initializing, + ); + } +} + +class _Viewport extends Viewport { + _Viewport({ + super.axisDirection, + super.anchor, + required super.offset, + super.center, + super.cacheExtent, + super.clipBehavior, + super.slivers, + required this.onShortage, + required this.onReady, + required this.onUpdatePrefixExtent, + this.initializing = false, + }); + + final _OnShortage onShortage; + final _OnReady onReady; + final _OnUpdatePrefixExtent onUpdatePrefixExtent; + final bool initializing; + + @override + _RenderViewport createRenderObject(BuildContext context) { + return _RenderViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + cacheExtentStyle: cacheExtentStyle, + clipBehavior: clipBehavior, + onShortage: onShortage, + onReady: onReady, + onUpdatePrefixExtent: onUpdatePrefixExtent, + initializing: initializing, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderViewport renderObject) { + super.updateRenderObject(context, renderObject); + renderObject.initializing = initializing; + } +} + +class _RenderViewport extends RenderViewport { + _RenderViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + super.anchor, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + required this.onShortage, + required this.onReady, + required this.onUpdatePrefixExtent, + this.initializing = false, + }); + + final _OnShortage onShortage; + final _OnReady onReady; + final _OnUpdatePrefixExtent onUpdatePrefixExtent; + bool initializing; + + bool _forWait = false; + bool _isReady = false; + + double _prefixScrollExtent = 0.0; + + @override + void performLayout() { + super.performLayout(); + + if (firstChild == null || lastChild == null || initializing) { + return; + } + + double tPrefixScrollExtent = 0.0; + for (RenderSliver? tChild = firstChild; + tChild != null; + (tChild = childAfter(tChild))) { + if (tChild is! _RenderPrefixSliverProxy) { + break; + } + tPrefixScrollExtent += tChild.geometry!.scrollExtent; + } + if (tPrefixScrollExtent != _prefixScrollExtent) { + _prefixScrollExtent = tPrefixScrollExtent; + + onUpdatePrefixExtent(_prefixScrollExtent); + } + + if (_isReady || _forWait) { + return; + } + + double tForwardListScrollExtent = lastChild!.geometry!.scrollExtent; + double tBackwardListScrollExtent = 0.0; + for (RenderSliver? tChild = childBefore(lastChild!); + tChild != null; + (tChild = childBefore(tChild))) { + if (tChild is _RenderPrefixSliverProxy) { + break; + } + tBackwardListScrollExtent += tChild.geometry!.scrollExtent; + } + + final tExtent = switch (axis) { + Axis.vertical => size.height, + Axis.horizontal => size.width, + }; + + final tForwardAbundance = + tForwardListScrollExtent >= (tExtent - _prefixScrollExtent); + final tBackwardAbundance = tBackwardListScrollExtent >= _prefixScrollExtent; + + if (tForwardAbundance && tBackwardAbundance) { + _isReady = true; + onReady(); + return; + } + + _forWait = true; + + bool tShortageResult; + if (!tForwardAbundance && + (tForwardListScrollExtent + tBackwardListScrollExtent) < tExtent) { + tShortageResult = onShortage.call(false, _onProgressive); + if (!tShortageResult) { + tShortageResult = onShortage.call(true, _onProgressive); + } + } else if (!tBackwardAbundance) { + tShortageResult = onShortage.call(true, _onProgressive); + } else { + tShortageResult = false; + } + + if (!tShortageResult) { + _isReady = true; + onReady(); + return; + } + } + + void _onProgressive() { + _forWait = false; + markNeedsLayout(); + } +} + +mixin _ScrollSliversBaseMixin on RenderSliver { + double _endScrollOffsetCorrection = 0.0; + double get endScrollOffsetCorrection => _endScrollOffsetCorrection; + double _scrollExtent = 0.0; + + void calcEndScrollOffsetCorrection(SliverGeometry geometry) { + _endScrollOffsetCorrection = geometry.scrollExtent - _scrollExtent; + _scrollExtent = geometry.scrollExtent; + } + + SliverPhysicalContainerParentData getViewportParentData(RenderObject sliver) { + RenderObject? tRenderObject = sliver; + while (tRenderObject != null && + tRenderObject.parentData is! SliverPhysicalContainerParentData) { + tRenderObject = tRenderObject.parent; + } + + return tRenderObject!.parentData as SliverPhysicalContainerParentData; + } + + RenderObject? getViewportNextSibling(RenderObject sliver) { + final tNextSibling = getViewportParentData(sliver).nextSibling; + return (tNextSibling is RenderSliverPadding) + ? tNextSibling.child + : tNextSibling; + } + + RenderObject? getViewportPreviousSibling(RenderObject sliver) { + final tPreviousSibling = getViewportParentData(sliver).previousSibling; + return (tPreviousSibling is RenderSliverPadding) + ? tPreviousSibling.child + : tPreviousSibling; + } + + double getPaintOffset(RenderObject sliver) { + final tParentData = getViewportParentData(sliver); + return tParentData.paintOffset.distance; + } +} + +typedef _OnTriggerCallback = void Function(double triggerWidgetSize); + +class _LoadTrigger extends SliverToBoxAdapter { + const _LoadTrigger({ + super.key, + super.child, + required this.backward, + required this.load, + this.offsetCorrection = true, + }); + + final bool backward; + final _OnTriggerCallback load; + final bool offsetCorrection; + + @override + RenderSliverToBoxAdapter createRenderObject(BuildContext context) { + return _LoadingAnchorRender( + backward: backward, + load: load, + offsetCorrection: offsetCorrection, + ); + } +} + +class _LoadingAnchorRender extends RenderSliverToBoxAdapter + with _ScrollSliversBaseMixin { + _LoadingAnchorRender({ + required this.backward, + required this.load, + required this.offsetCorrection, + }); + + final bool backward; + final _OnTriggerCallback load; + final bool offsetCorrection; + bool _isReady = false; + bool _isNotified = false; + + _RenderSliverListIndexListener? _sliverList; + + @override + void performLayout() { + super.performLayout(); + + if (!_isReady) { + if (offsetCorrection) { + calcEndScrollOffsetCorrection(geometry!); + if (backward && endScrollOffsetCorrection != 0.0) { + geometry = SliverGeometry( + scrollOffsetCorrection: endScrollOffsetCorrection, + ); + return; + } + } + final RenderObject? tRenderObject; + if (backward) { + tRenderObject = getViewportNextSibling(this); + } else { + tRenderObject = getViewportPreviousSibling(this); + } + if (tRenderObject is _RenderSliverListIndexListener) { + _sliverList = tRenderObject; + } + } + _isReady = true; + + if (_sliverList?.isReady != true) { + return; + } + + bool tVisible = false; + double tChildExtent = 0.0; + if (backward) { + final double tScrollOffset = constraints.scrollOffset; + + switch (constraints.axis) { + case Axis.horizontal: + tChildExtent = child!.size.width; + case Axis.vertical: + tChildExtent = child!.size.height; + } + + tVisible = (tScrollOffset - tChildExtent) < 0.0; + } else { + final tRemainingExtent = constraints.remainingCacheExtent; + tVisible = tRemainingExtent > 0; + } + + if (tVisible) { + _trigger(tChildExtent); + } + } + + void _trigger(double triggerWidgetSize) { + if (!_isNotified) { + _isNotified = true; + load(triggerWidgetSize); + } + } +} + +typedef _SliverListIndexNotifyCallback = void Function( + int index, int crossAxisCount); +typedef _NotificationOffset = double Function(); + +class _SliverListIndexListener extends SingleChildRenderObjectWidget { + const _SliverListIndexListener({ + super.key, + required super.child, + required this.onNotification, + required this.backward, + this.notificationOffset, + this.backedLoadTriggerSize = 0.0, + this.offsetCorrection = true, + }); + + final _SliverListIndexNotifyCallback onNotification; + final bool backward; + final _NotificationOffset? notificationOffset; + final double backedLoadTriggerSize; + final bool offsetCorrection; + + @override + _RenderSliverListIndexListener createRenderObject(BuildContext context) { + return _RenderSliverListIndexListener( + onNotification: onNotification, + backward: backward, + notificationOffset: notificationOffset, + backedLoadTriggerSize: backedLoadTriggerSize, + offsetCorrection: offsetCorrection, + ); + } +} + +class _RenderSliverListIndexListener extends RenderProxySliver + with _ScrollSliversBaseMixin { + _RenderSliverListIndexListener({ + required this.onNotification, + required this.backward, + required this.notificationOffset, + required this.backedLoadTriggerSize, + required this.offsetCorrection, + }); + + final _SliverListIndexNotifyCallback onNotification; + final bool backward; + final _NotificationOffset? notificationOffset; + final double backedLoadTriggerSize; + final bool offsetCorrection; + + RenderSliverList? _sliverList; + RenderSliverGrid? _sliverGrid; + int _corssAxisCount = 0; + + bool _isReady = false; + bool get isReady => _isReady; + + bool _notify = false; + + @override + void paint(context, offset) { + super.paint(context, offset); + + if (_notify) { + _notifyListTopIndex(); + _notify = false; + } + } + + @override + void performLayout() { + _notify = false; + _sliverList = null; + _sliverGrid = null; + + super.performLayout(); + + if ((child is RenderSliverList)) { + _sliverList = child as RenderSliverList; + } else if (child is RenderSliverGrid) { + _sliverGrid = child as RenderSliverGrid; + } + + if (_sliverGrid != null) { + final tLayout = _sliverGrid!.gridDelegate.getLayout(constraints); + // Any value for scrollOffset is fine, but specifying 0.0 will result in an + // internal mainAxisCount of 0, so 0.1 is specified. + _corssAxisCount = (tLayout.getMaxChildIndexForScrollOffset(0.1) - + tLayout.getMinChildIndexForScrollOffset(0.1)) + + 1; + + if (backward || getViewportNextSibling(this) is _LoadingAnchorRender) { + final tCrossGeometry = + tLayout.getGeometryForChildIndex(_corssAxisCount); + final tSpacing = + tCrossGeometry.scrollOffset % tCrossGeometry.mainAxisExtent; + + double fExtentWithSpacing( + double extent, [ + double maxExtent = double.infinity, + ]) { + double tAddExtent = tSpacing; + if (extent <= 0.0) { + tAddExtent -= (constraints.scrollOffset - geometry!.scrollExtent); + } + if (tAddExtent < 0.0) { + tAddExtent = 0.0; + } + + final tResult = extent + tAddExtent; + return (tResult > maxExtent) ? maxExtent : tResult; + } + + final tPaintExtent = fExtentWithSpacing( + geometry!.paintExtent, + constraints.remainingPaintExtent, + ); + final tLayoutExtent = fExtentWithSpacing( + geometry!.layoutExtent, + tPaintExtent, + ); + final tCacheExtent = fExtentWithSpacing( + geometry!.cacheExtent, + constraints.remainingCacheExtent, + ); + final tMaxPaintExtent = fExtentWithSpacing( + geometry!.maxPaintExtent, + ); + final tScrollExtent = fExtentWithSpacing( + geometry!.scrollExtent, + ); + + geometry = geometry!.copyWith( + paintExtent: tPaintExtent, + layoutExtent: tLayoutExtent, + cacheExtent: tCacheExtent, + maxPaintExtent: tMaxPaintExtent, + scrollExtent: tScrollExtent, + ); + } + } else { + _corssAxisCount = 1; + } + + if (!_isReady) { + if (offsetCorrection) { + calcEndScrollOffsetCorrection(geometry!); + if (backward) { + if (endScrollOffsetCorrection != 0.0) { + geometry = SliverGeometry( + scrollOffsetCorrection: endScrollOffsetCorrection, + ); + return; + } + + final tPreviousSibling = getViewportPreviousSibling(this); + if (tPreviousSibling is! _LoadingAnchorRender && + backedLoadTriggerSize != 0.0) { + geometry = SliverGeometry( + scrollOffsetCorrection: -backedLoadTriggerSize, + ); + _isReady = true; + return; + } + } + } + } + _isReady = true; + + // Do not notify if there is no drawing area. + // The reason for specifying 0.01 instead of 0 is that floating-point errors + // can result in a value very close to 0 being set. + if (constraints.remainingPaintExtent < 0.01 || + constraints.remainingCacheExtent < 0.01) { + return; + } + + if (_sliverList == null && _sliverGrid == null) { + return; + } + + if (geometry!.paintExtent <= 0.0) { + return; + } + + _notify = true; + } + + void _notifyListTopIndex() { + final tNotifyOffset = notificationOffset?.call() ?? 0.0; + final tScrollOffset = constraints.scrollOffset - getPaintOffset(this); + if ((tScrollOffset >= geometry!.scrollExtent - tNotifyOffset) || + (tScrollOffset < -tNotifyOffset)) { + return; + } + + int tIndex = -1; + if (_sliverGrid != null) { + final tGrid = _sliverGrid!; + + RenderBox? tItem = tGrid.firstChild; + do { + final tItemScrollOffset = tGrid.childScrollOffset(tItem!)!; + if ((tItemScrollOffset - tNotifyOffset) > tScrollOffset) { + break; + } + tIndex = tGrid.indexOf(tItem); + for (int i = 0; i < _corssAxisCount; i++) { + if (tItem == null) { + break; + } + tItem = tGrid.childAfter(tItem); + } + } while (tItem != null); + } else if (_sliverList != null) { + final tList = _sliverList!; + + RenderBox? tItem = tList.firstChild; + do { + final tItemScrollOffset = tList.childScrollOffset(tItem!)!; + if ((tItemScrollOffset - tNotifyOffset) > tScrollOffset) { + break; + } + tIndex = tList.indexOf(tItem); + } while ((tItem = tList.childAfter(tItem)) != null); + } + + if (tIndex >= 0) { + onNotification(tIndex, _corssAxisCount); + } + } +} diff --git a/packages/patapata_core/test/infinite_scroll_list_view_test.dart b/packages/patapata_core/test/infinite_scroll_list_view_test.dart new file mode 100644 index 0000000..ba7848c --- /dev/null +++ b/packages/patapata_core/test/infinite_scroll_list_view_test.dart @@ -0,0 +1,2001 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +final List _data = List.generate(210, (index) => 'Item $index'); +Future> _fetch(int offset, int count) { + assert(offset >= 0); + + return Future.delayed( + const Duration(milliseconds: 100), + () => _data.skip(offset).take(count).toList(), + ); +} + +// Fake fetchNext function to successfully fetch data +Future> fakeFetchNext(int index, int crossAxisCount) async { + return await _fetch(index, 10); +} + +// Fake fetchPrev function to successfully fetch data +Future> fakeFetchPrev(int index, int crossAxisCount) async { + if (index < 0) { + return []; + } + final tNewBackOffset = max(0, index - 10 + 1); + final tFetchCount = index + 1 - tNewBackOffset; + return await _fetch(tNewBackOffset, tFetchCount); +} + +// Fake fetchNext function that throws an error +Future> fakeFetchNextError(int index, int crossAxisCount) async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('FetchNext Error'); +} + +// Fake fetchPrev function to throw an error +Future> fakeFetchPrevError(int index, int crossAxisCount) async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('FetchPrev Error'); +} + +// Item Builder +Widget itemBuilder(BuildContext context, String item, int index) { + return SizedBox( + width: 100, + height: 100, + child: Text(item), + ); +} + +// Helper function to build the widget for testing +Widget buildTestWidget({ + required Widget child, +}) { + return MaterialApp( + home: Scaffold( + body: child, + ), + ); +} + +Future _setTestDeviceSize(WidgetTester tester) async { + await setTestDeviceSize(tester, const Size(300, 600)); +} + +void main() { + testWidgets('Initialization test in list mode', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // CircularProgressIndicator is present in the listing as a load trigger. + // CircularProgressIndicator cannot use pumpAndSettle because of infinite animation. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + }); + + testWidgets('Initialization test in grid mode', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.grid( + fetchNext: fakeFetchNext, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 200, + ), + itemBuilder: itemBuilder, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + }); + + testWidgets('Test if fetchNext throws an error', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await runZonedGuarded(() async { + Object? tExceptionA; + Object? tExceptionB; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + try { + return await fakeFetchNextError(index, crossAxisCount); + } catch (e) { + tExceptionA = e; + rethrow; + } + }, + itemBuilder: itemBuilder, + errorBuilder: (context, error) { + tExceptionB = error; + return Center(child: Text(error.toString())); + }, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + expect(tExceptionA, equals(tExceptionB)); + expect(find.text(tExceptionA.toString()), findsOneWidget); + }, (error, stackTrace) { + if (error.toString() == 'Exception: FetchNext Error') { + return; + } + throw error; + }); + }); + + testWidgets('Test for empty data', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + Future> fetchNextEmpty(int index, int crossAxisCount) async { + return []; + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNextEmpty, + itemBuilder: itemBuilder, + empty: const Text('empty'), + ), + )); + + await tester.pumpAndSettle(); + + expect(find.text('empty'), findsOneWidget); + }); + + testWidgets('Test if canFetchNext works', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + int tLastFetchIndex = 0; + final tShouldCallFetch = expectAsync0(() => null, count: 3); + Future> fetchNext(int index, int crossAxisCount) async { + tLastFetchIndex = index; + tShouldCallFetch(); + return await fakeFetchNext(index, crossAxisCount); + } + + int tLastCanFetchIndex = 0; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNext, + itemBuilder: itemBuilder, + canFetchNext: (index) { + tLastCanFetchIndex = index; + return index < 30; + }, + // Disable cache area for easier testing. + cacheExtent: 0.0, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tLastCanFetchIndex, equals(0)); + expect(tLastFetchIndex, equals(0)); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(tLastFetchIndex, equals(0)); + expect(find.text('Item 6'), findsNothing); + expect(tLastCanFetchIndex, equals(10)); + + const tDragType = InfiniteScrollListView; + + // Scroll to just before load trigger + await tester.drag(find.byType(tDragType), const Offset(0, -400)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + for (var i = 6; i < 10; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 10'), findsNothing); + + // Scroll to load trigger and call fetchNext + // 10 to 19 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 10'), findsOneWidget); + expect(find.text('Item 11'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -600)); + await tester.pump(); + for (var i = 11; i < 17; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 17'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -300)); + await tester.pump(); + for (var i = 17; i < 20; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 20'), findsNothing); + expect(tLastFetchIndex, equals(10)); + expect(tLastCanFetchIndex, equals(20)); + // 20 to 29 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + expect(find.text('Item 21'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -600)); + await tester.pump(); + for (var i = 21; i < 27; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 27'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -300)); + await tester.pump(); + for (var i = 27; i < 30; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 30'), findsNothing); + expect(tLastFetchIndex, equals(20)); + expect(tLastCanFetchIndex, equals(30)); + + // Confirm that fetchNext is not called + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('Item 29'), findsOneWidget); + expect(find.text('Item 30'), findsNothing); + + expect(tLastFetchIndex, equals(20)); + expect(tLastCanFetchIndex, equals(30)); + }); + + testWidgets('Test to keep calling fetchNext until there is no more data', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 4); + int tLastFetchIndex = 0; + Future> fetchNext(int index, int crossAxisCount) async { + tShouldCallFetch(); + tLastFetchIndex = index; + if (index < 30) { + return await fakeFetchNext(index, crossAxisCount); + } + return []; + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNext, + itemBuilder: itemBuilder, + // Disable cache area for easier testing. + cacheExtent: 0.0, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tLastFetchIndex, equals(0)); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + expect(tLastFetchIndex, equals(0)); + + const tDragType = InfiniteScrollListView; + + // Scroll to load trigger and call fetchNext. + // 10 to 19 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, -500)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 10'), findsOneWidget); + expect(find.text('Item 11'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -600)); + await tester.pump(); + for (var i = 11; i < 17; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 17'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -300)); + await tester.pump(); + for (var i = 17; i < 20; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 20'), findsNothing); + expect(tLastFetchIndex, equals(10)); + // 20 to 29 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + expect(find.text('Item 21'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -600)); + await tester.pump(); + for (var i = 21; i < 27; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 27'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -300)); + await tester.pump(); + for (var i = 27; i < 30; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 30'), findsNothing); + expect(tLastFetchIndex, equals(20)); + + // Confirm that empty data is returned and that fetchNext will not be called next time. + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 29'), findsOneWidget); + expect(find.text('Item 30'), findsNothing); + expect(tLastFetchIndex, equals(30)); + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('Item 29'), findsOneWidget); + expect(find.text('Item 30'), findsNothing); + expect(tLastFetchIndex, equals(30)); + }); + + testWidgets( + 'Even if canFetchNext returns true, if fetchNext returns an empty list, no further data will be loaded.', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 3); + int tLastFetchIndex = 0; + Future> fetchNext(int index, int crossAxisCount) async { + tShouldCallFetch(); + tLastFetchIndex = index; + if (index < 20) { + return await fakeFetchNext(index, crossAxisCount); + } + return []; + } + + int tLastCanFetchIndex = 0; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNext, + itemBuilder: itemBuilder, + canFetchNext: (index) { + tLastCanFetchIndex = index; + return true; + }, + // Disable cache area for easier testing. + cacheExtent: 0.0, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tLastFetchIndex, equals(0)); + expect(tLastCanFetchIndex, equals(0)); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + expect(tLastFetchIndex, equals(0)); + expect(tLastCanFetchIndex, equals(10)); + + const tDragType = InfiniteScrollListView; + + // 10 to 19 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, -500)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 10'), findsOneWidget); + expect(find.text('Item 11'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -600)); + await tester.pump(); + for (var i = 11; i < 17; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 17'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, -300)); + await tester.pump(); + for (var i = 17; i < 20; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 20'), findsNothing); + expect(tLastFetchIndex, equals(10)); + expect(tLastCanFetchIndex, equals(20)); + + // Confirm that empty data is returned and that fetchNext will not be called next time. + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 20'), findsNothing); + expect(tLastFetchIndex, equals(20)); + expect(tLastCanFetchIndex, equals(20)); + await tester.drag(find.byType(tDragType), const Offset(0, -100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 20'), findsNothing); + + expect(tLastFetchIndex, equals(20)); + expect(tLastCanFetchIndex, equals(20)); + }); + + testWidgets('Test reset when data key is changed', + (WidgetTester tester) async { + final tKey1 = UniqueKey(); + final tKey2 = UniqueKey(); + + final tShouldCall = expectAsync0(() => null, count: 2); + + int fInitialIndex() { + tShouldCall(); + return 0; + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + dataKey: tKey1, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + initialIndex: fInitialIndex, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + expect(find.text('Item 0'), findsOneWidget); + + // Change data key + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + dataKey: tKey2, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + initialIndex: fInitialIndex, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + expect(find.text('Item 0'), findsOneWidget); + }); + + testWidgets('Test when initialIndex is present in the data', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + initialIndex: () => 6, + canFetchNext: (index) { + return index < 15; + }, + canFetchPrev: (index) => false, + ), + )); + + await tester.pumpAndSettle(); + + // Verify that the initial indexed item is displayed. + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsNothing); + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsNothing); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Item 7'), findsOneWidget); + expect(find.text('Item 8'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + expect(find.text('Item 11'), findsOneWidget); + expect(find.text('Item 12'), findsNothing); + }); + + testWidgets( + 'Test that retry succeeds when initialIndex is not present in the data', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + int tNotFoundIndex = 0; + bool tGiveup = false; + int tInitialIndex = 15; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + initialIndex: () => tInitialIndex, + canFetchNext: (index) { + return index < 10; + }, + initialIndexNotFoundCallback: (index, giveup) { + tNotFoundIndex = index; + tGiveup = giveup; + tInitialIndex = 0; + return true; + }), + )); + + await tester.pumpAndSettle(); + + expect(tGiveup, isFalse); + expect(tNotFoundIndex, equals(15)); + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + }); + + testWidgets('Test to abort retry if initialIndex is not present in the data', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + int tNotFoundIndex = 0; + bool tGiveup = false; + Future> fetchNextFail(int index, int crossAxisCount) async { + return []; + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNextFail, + itemBuilder: itemBuilder, + initialIndex: () => 15, + initialIndexNotFoundCallback: (index, giveup) { + tNotFoundIndex = index; + tGiveup = giveup; + return false; + }, + empty: const Text('empty'), + ), + )); + + await tester.pumpAndSettle(); + + expect(tGiveup, isFalse); + expect(tNotFoundIndex, equals(15)); + expect(find.text('empty'), findsOneWidget); + }); + + testWidgets( + 'Test where retry fails if initialIndex is not present in the data', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + Future> fetchNextFail(int index, int crossAxisCount) async { + return []; + } + + bool tGiveup = false; + int tRetryCount = 0; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNextFail, + itemBuilder: itemBuilder, + initialIndex: () => 15, + initialIndexNotFoundCallback: (index, giveup) { + if (giveup) { + tGiveup = giveup; + } else { + tRetryCount++; + } + return true; + }, + empty: const Text('empty'), + ), + )); + + await tester.pumpAndSettle(); + + expect(tGiveup, isTrue); + expect(tRetryCount, equals(10)); + expect(find.text('empty'), findsOneWidget); + }); + + testWidgets('Test treated as 0 if initialIndex is less than 0', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 1); + int? tIndex; + Future> fetchNextFail(int index, int crossAxisCount) async { + tShouldCallFetch(); + tIndex = index; + return await fakeFetchNext(index, crossAxisCount); + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNextFail, + itemBuilder: itemBuilder, + initialIndex: () => -1, + empty: const Text('empty'), + canFetchNext: (index) => index < 10, + ), + )); + + await tester.pumpAndSettle(); + + expect(tIndex, equals(0)); + }); + + testWidgets( + 'Test if crossAxisCount is correctly passed to fetch function in grid mode', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.grid( + fetchNext: (index, crossAxisCount) async { + expect(crossAxisCount, equals(3)); + return await fakeFetchNext(index, crossAxisCount); + }, + fetchPrev: (index, crossAxisCount) async { + expect(crossAxisCount, equals(3)); + return await fakeFetchPrev(index, crossAxisCount); + }, + canFetchPrev: (index) => index >= 0, + initialIndex: () => 3, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 100, + ), + itemBuilder: itemBuilder, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Because 18 elements are assumed to be displayed on the screen, fetchNext is called twice. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + for (var i = 3; i < 21; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 21'), findsNothing); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + for (var i = 0; i < 18; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 18'), findsNothing); + }); + + testWidgets('Test if onIndexChanged is working in grid mode', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + int? tIndex; + int? tCrossAxisCount; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.grid( + fetchNext: (index, crossAxisCount) async { + return await _fetch(index, 100); + }, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 100, + ), + onIndexChanged: (index, crossAxisCount) { + tIndex = index; + tCrossAxisCount = crossAxisCount; + }, + itemBuilder: itemBuilder, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, -100)); + await tester.pump(); + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 0)); + await tester.pump(); + expect(tIndex, equals(3)); + expect(tCrossAxisCount, equals(3)); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, -100)); + await tester.pump(); + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 0)); + await tester.pump(); + expect(tIndex, equals(6)); + expect(tCrossAxisCount, equals(3)); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, -100)); + await tester.pump(); + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 0)); + await tester.pump(); + expect(tIndex, equals(9)); + expect(tCrossAxisCount, equals(3)); + }); + + testWidgets( + 'In grid mode, test that initialIndex is recalculated to a multiple of crossAxisCount', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + int? tIndex; + + Object tDataKey = Object(); + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.grid( + dataKey: ObjectKey(tDataKey), + fetchNext: (index, crossAxisCount) async { + tIndex = index; + return await _fetch(index, 100); + }, + initialIndex: () => 5, + canFetchNext: (index) => index < 100, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 300, + ), + itemBuilder: itemBuilder, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + expect(tIndex, equals(3)); + + for (var i = 3; i < 9; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 9'), findsNothing); + + tIndex = null; + tDataKey = Object(); + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.grid( + dataKey: ObjectKey(tDataKey), + fetchNext: (index, crossAxisCount) async { + tIndex = index; + return await _fetch(index, 100); + }, + initialIndex: () => 1, + canFetchNext: (index) => index < 100, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 300, + ), + itemBuilder: itemBuilder, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + expect(tIndex, equals(0)); + + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + }); + + testWidgets('Test if fetchPrev,canFetchPrev works', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 2); + int tLastFetchIndex = 0; + Future> fetchPrev(int index, int crossAxisCount) async { + tShouldCallFetch(); + tLastFetchIndex = index; + return await fakeFetchPrev(index, crossAxisCount); + } + + int tLastCanFetchIndex = 0; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + initialIndex: () => 20, + fetchPrev: fetchPrev, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + canFetchPrev: (index) { + tLastCanFetchIndex = index; + return index >= 0; + }, + canFetchNext: (index) { + return index < 30; + }, + // Disable cache area for easier testing. + cacheExtent: 0.0, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tLastCanFetchIndex, equals(0)); + expect(tLastFetchIndex, equals(0)); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 20; i < 26; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(tLastFetchIndex, equals(0)); + expect(find.text('Item 19'), findsNothing); + expect(tLastCanFetchIndex, equals(19)); + + const tDragType = InfiniteScrollListView; + + // 19 to 10 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 18'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 600)); + await tester.pump(); + for (var i = 13; i < 18; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 12'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 300)); + await tester.pump(); + for (var i = 10; i < 12; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 9'), findsNothing); + expect(tLastFetchIndex, equals(19)); + expect(tLastCanFetchIndex, equals(9)); + // 9 to 0 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 8'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 600)); + await tester.pump(); + for (var i = 3; i < 8; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 2'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 300)); + await tester.pump(); + for (var i = 0; i < 2; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item -1'), findsNothing); + expect(tLastFetchIndex, equals(9)); + expect(tLastCanFetchIndex, equals(-1)); + + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item -1'), findsNothing); + + expect(tLastFetchIndex, equals(9)); + expect(tLastCanFetchIndex, equals(-1)); + }); + + testWidgets('Test to keep calling fetchPrev until no more data is available.', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 3); + int tLastFetchIndex = 0; + Future> fetchPrev(int index, int crossAxisCount) async { + tShouldCallFetch(); + tLastFetchIndex = index; + return await fakeFetchPrev(index, crossAxisCount); + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + initialIndex: () => 20, + fetchPrev: fetchPrev, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + canFetchNext: (index) { + return index < 30; + }, + // Disable cache area for easier testing. + cacheExtent: 0.0, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tLastFetchIndex, equals(0)); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 20; i < 26; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(tLastFetchIndex, equals(0)); + expect(find.text('Item 19'), findsNothing); + + const tDragType = InfiniteScrollListView; + + // 19 to 10 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 18'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 600)); + await tester.pump(); + for (var i = 13; i < 18; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 12'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 300)); + await tester.pump(); + for (var i = 10; i < 12; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 9'), findsNothing); + expect(tLastFetchIndex, equals(19)); + // 9 to 0 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 8'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 600)); + await tester.pump(); + for (var i = 3; i < 8; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 2'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 300)); + await tester.pump(); + for (var i = 0; i < 2; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item -1'), findsNothing); + expect(tLastFetchIndex, equals(9)); + + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item -1'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item -1'), findsNothing); + + expect(tLastFetchIndex, equals(-1)); + }); + + testWidgets( + 'Even if canFetchPrev returns true, if fetchPrev returns an empty list, no further data will be loaded.', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 3); + int tLastFetchIndex = 0; + Future> fetchPrev(int index, int crossAxisCount) async { + tShouldCallFetch(); + tLastFetchIndex = index; + return await fakeFetchPrev(index, crossAxisCount); + } + + int tLastCanFetchIndex = 0; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + initialIndex: () => 20, + fetchPrev: fetchPrev, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + canFetchPrev: (index) { + tLastCanFetchIndex = index; + return true; + }, + canFetchNext: (index) { + return index < 30; + }, + // Disable cache area for easier testing. + cacheExtent: 0.0, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tLastCanFetchIndex, equals(0)); + expect(tLastFetchIndex, equals(0)); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 20; i < 26; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(tLastFetchIndex, equals(0)); + expect(find.text('Item 19'), findsNothing); + expect(tLastCanFetchIndex, equals(19)); + + const tDragType = InfiniteScrollListView; + + // 19 to 10 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 18'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 600)); + await tester.pump(); + for (var i = 13; i < 18; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 12'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 300)); + await tester.pump(); + for (var i = 10; i < 12; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 9'), findsNothing); + expect(tLastFetchIndex, equals(19)); + expect(tLastCanFetchIndex, equals(9)); + // 9 to 0 will be loaded. + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 8'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 600)); + await tester.pump(); + for (var i = 3; i < 8; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 2'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 300)); + await tester.pump(); + for (var i = 0; i < 2; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item -1'), findsNothing); + expect(tLastFetchIndex, equals(9)); + expect(tLastCanFetchIndex, equals(-1)); + + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item -1'), findsNothing); + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item -1'), findsNothing); + + expect(tLastFetchIndex, equals(-1)); + expect(tLastCanFetchIndex, equals(-1)); + }); + + testWidgets('Test if fetchPrev throws an error', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await runZonedGuarded(() async { + Object? tExceptionA; + Object? tExceptionB; + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchPrev: (index, crossAxisCount) async { + try { + return await fakeFetchPrevError(index, crossAxisCount); + } catch (e) { + tExceptionA = e; + rethrow; + } + }, + initialIndex: () => 10, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + errorBuilder: (context, error) { + tExceptionB = error; + return Center(child: Text(error.toString())); + }, + ), + )); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 100)); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(tExceptionA, equals(tExceptionB)); + expect(find.text(tExceptionA.toString()), findsOneWidget); + }, (error, stackTrace) { + if (error.toString() == 'Exception: FetchPrev Error') { + return; + } + throw error; + }); + }); + + testWidgets('Test that loading,loadingMore is displayed correctly', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + fetchPrev: fakeFetchPrev, + itemBuilder: itemBuilder, + loading: const Center( + child: Text('loading'), + ), + loadingMore: const SizedBox( + height: 100, + child: Center( + child: Text('loadingMore'), + ), + ), + initialIndex: () => 10, + ), + )); + + expect(find.text('loading'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('loading'), findsNothing); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, -500)); + await tester.pump(); + + expect(find.text('loadingMore'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('loadingMore'), findsNothing); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 600)); + await tester.pump(); + + expect(find.text('loadingMore'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('loadingMore'), findsNothing); + }); + + testWidgets('Test that the refresh function works', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + bool tRefreshed = false; + Future onRefresh() async { + tRefreshed = true; + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + final tResult = await fakeFetchNext(index, crossAxisCount); + return tResult.map((e) => '$e:$tRefreshed').toList(); + }, + itemBuilder: itemBuilder, + canRefresh: true, + onRefresh: onRefresh, + canFetchNext: (index) => index < 10, + ), + )); + + await tester.pumpAndSettle(); + + expect(find.text('Item 0:false'), findsOneWidget); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + + expect(tRefreshed, isTrue); + expect(find.text('Item 0:true'), findsOneWidget); + expect(find.text('Item 0:false'), findsNothing); + }); + + testWidgets('Test that refreshIndicatorBuilder works', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + bool tRefreshed = false; + Future onRefresh() async { + tRefreshed = true; + } + + final tShouldCallRefresh = expectAsync0(() => null, count: 1); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + final tResult = await fakeFetchNext(index, crossAxisCount); + return tResult.map((e) => '$e:$tRefreshed').toList(); + }, + itemBuilder: itemBuilder, + canRefresh: true, + refreshIndicatorBuilder: (context, child, refresh) { + return RefreshIndicator( + onRefresh: () { + tShouldCallRefresh(); + return refresh(); + }, + child: child, + ); + }, + onRefresh: onRefresh, + canFetchNext: (index) => index < 10, + ), + )); + + await tester.pumpAndSettle(); + + expect(find.text('Item 0:false'), findsOneWidget); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + + expect(tRefreshed, isTrue); + expect(find.text('Item 0:true'), findsOneWidget); + expect(find.text('Item 0:false'), findsNothing); + }); + + testWidgets('Test that refresh does not work if canRefresh is false', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + bool tRefreshed = false; + Future onRefresh() async { + tRefreshed = true; + } + + final tShouldCallRefresh = expectAsync0(() => null, count: 0); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + final tResult = await fakeFetchNext(index, crossAxisCount); + return tResult.map((e) => '$e:$tRefreshed').toList(); + }, + itemBuilder: itemBuilder, + canRefresh: false, + refreshIndicatorBuilder: (context, child, refresh) { + return RefreshIndicator( + onRefresh: () { + tShouldCallRefresh(); + return refresh(); + }, + child: child, + ); + }, + onRefresh: onRefresh, + canFetchNext: (index) => index < 10, + ), + )); + + await tester.pumpAndSettle(); + + expect(find.text('Item 0:false'), findsOneWidget); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + + expect(tRefreshed, isFalse); + expect(find.text('Item 0:false'), findsOneWidget); + expect(find.text('Item 0:true'), findsNothing); + }); + + testWidgets('Test when scroll direction is horizontal', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + scrollDirection: Axis.horizontal, + fetchNext: fakeFetchNext, + fetchPrev: fakeFetchPrev, + itemBuilder: itemBuilder, + initialIndex: () => 10, + loading: const Center( + child: Text('loading'), + ), + loadingMore: const SizedBox( + height: 100, + width: 100, + child: Center( + child: Text('loadingMore'), + ), + ), + ), + )); + + expect(find.text('loading'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('loading'), findsNothing); + + expect(find.text('Item 10'), findsOneWidget); + expect(find.text('Item 9'), findsNothing); + + // fetchNext + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(-700, 0)); + await tester.pump(); + expect(find.text('Item 10'), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 20'), findsNothing); + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(-100, 0)); + await tester.pump(); + expect(find.text('loadingMore'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('loadingMore'), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + + // fetchPrev + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(900, 0)); + await tester.pump(); + expect(find.text('loadingMore'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text('loadingMore'), findsNothing); + expect(find.text('Item 9'), findsOneWidget); + }); + + testWidgets('Test that overlaySlivers are displayed correctly', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + overlaySlivers: const [ + SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Text('overlay'), + ), + ), + ], + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.text('overlay'), findsOneWidget); + }); + + testWidgets('Test that prefixes are displayed correctly', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + prefix: const SizedBox( + height: 100, + child: Text('prefix'), + ), + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.text('prefix'), findsOneWidget); + }); + + testWidgets('Test for correct display of suffixes', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + return ['Item 0']; + }, + itemBuilder: itemBuilder, + canFetchNext: (index) => index < 1, + suffix: const SizedBox( + height: 100, + child: Text('suffix'), + ), + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.text('suffix'), findsOneWidget); + }); + + testWidgets('Test that the onIndexChanged callback is called', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + int? tChangedIndex; + int? tCrossAxisCount; + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + onIndexChanged: (index, crossAxisCount) { + tChangedIndex = index; + tCrossAxisCount = crossAxisCount; + }, + canFetchNext: (index) => index < 10, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + // onIndexChanged is called upon ScrollEndNotification, but index calculation is invoked during performLayout. + // When a drag occurs, ScrollEndNotification is triggered, but the index calculation has not yet been performed. + // Since index calculation is performed during pump, by performing drag -> pump -> drag, the calculated index can be obtained. + // If running on an actual device, performLayout is always called during a drag, so there is no problem. + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, -100)); + await tester.pump(); + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 0)); + await tester.pump(); + + expect(tChangedIndex, equals(1)); + expect(tCrossAxisCount, equals(1)); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, -100)); + await tester.pump(); + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 0)); + await tester.pump(); + + expect(tChangedIndex, equals(2)); + expect(tCrossAxisCount, equals(1)); + }); + + testWidgets( + 'Test that fetchNext continues to be called during initialization until the screen is filled with elements.', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 6); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + tShouldCallFetch(); + await Future.delayed(const Duration(milliseconds: 100)); + return List.generate(1, (i) => 'Item ${index + i}'); + }, + itemBuilder: itemBuilder, + canFetchNext: (index) => index < 10, + cacheExtent: 0.0, + ), + )); + + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + } + + for (var i = 0; i < 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 6'), findsNothing); + }); + + testWidgets( + 'At initialization, if there are overlaySlivers or prefixes with initialIndex specified, test that fetchPrev will continue to be called until the area is filled.', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetch = expectAsync0(() => null, count: 2); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + overlaySlivers: const [ + SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Text('overlay'), + ), + ), + ], + prefix: const SizedBox( + height: 100, + child: Text('prefix'), + ), + initialIndex: () => 3, + fetchPrev: (index, crossAxisCount) async { + tShouldCallFetch(); + + await Future.delayed(const Duration(milliseconds: 100)); + if (index < 0) return []; + final tNewBackOffset = max(0, index - 1 + 1); + final tFetchCount = index + 1 - tNewBackOffset; + return List.generate( + tFetchCount, (i) => 'Item ${tNewBackOffset + i}'); + }, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + canFetchNext: (index) => index < 10, + cacheExtent: 0.0, + ), + )); + + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + } + + expect(find.text('overlay'), findsNothing); + expect(find.text('prefix'), findsNothing); + + for (var i = 1; i < 7; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets( + 'Test that fetchPrev and fetchNext continue to be called until the screen area is filled during initialization.', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + final tShouldCallFetchPrev = expectAsync0(() => null, count: 2); + final tShouldCallFetchNext = expectAsync0(() => null, count: 4); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + overlaySlivers: const [ + SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Text('overlay'), + ), + ), + ], + prefix: const SizedBox( + height: 100, + child: Text('prefix'), + ), + initialIndex: () => 3, + fetchPrev: (index, crossAxisCount) async { + tShouldCallFetchPrev(); + + await Future.delayed(const Duration(milliseconds: 100)); + if (index < 0) return []; + final tNewBackOffset = max(0, index - 1 + 1); + final tFetchCount = index + 1 - tNewBackOffset; + return List.generate( + tFetchCount, (i) => 'Item ${tNewBackOffset + i}'); + }, + fetchNext: (index, crossAxisCount) async { + tShouldCallFetchNext(); + await Future.delayed(const Duration(milliseconds: 100)); + return List.generate(1, (i) => 'Item ${index + i}'); + }, + itemBuilder: itemBuilder, + canFetchNext: (index) => index < 10, + cacheExtent: 0.0, + ), + )); + + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + } + + expect(find.text('overlay'), findsNothing); + expect(find.text('prefix'), findsNothing); + + for (var i = 1; i < 7; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets('padding test', (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + padding: const EdgeInsets.symmetric(vertical: 100.0), + initialIndex: () => 11, + fetchPrev: fakeFetchPrev, + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + canFetchNext: (index) => index < 21, + canFetchPrev: (index) => index >= 0, + canRefresh: false, + cacheExtent: 0.0, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + for (var i = 10; i < 16; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 16'), findsNothing); + + const tDragType = InfiniteScrollListView; + + await tester.drag(find.byType(tDragType), const Offset(0, 100)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.drag(find.byType(tDragType), const Offset(0, 1000)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.drag(find.byType(tDragType), const Offset(0, 1000)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 0; i < 5; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 5'), findsNothing); + + await tester.drag(find.byType(tDragType), const Offset(0, -3000)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.text('Item 15'), findsNothing); + for (var i = 16; i < 21; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 21'), findsNothing); + }); + + testWidgets( + "Test whether data and index information can be retrieved from Item's Widget via Provider", + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + return ['Item $index']; + }, + canFetchNext: (index) => index < 1, + itemBuilder: (context, item, index) { + return SizedBox( + height: 100, + child: Builder(builder: (context) { + final tItem = context.read(); + final tInfo = context.read(); + return Text('$tItem:${tInfo.index}'); + }), + ); + }, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(find.text('Item 0:0'), findsOneWidget); + }); + + testWidgets('Test for correct operation when Item is Listenable', + (WidgetTester tester) async { + final tListenable = ChangeNotifier(); + bool tNotified = false; + fListener() { + tNotified = true; + } + + // itemBuilder is called twice in the initialization phase. + // then changeNotifier's notifyListeners is called twice. + final tShouldCallBuild = expectAsync0(() => null, count: 4); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: (index, crossAxisCount) async { + return [tListenable]; + }, + canFetchNext: (index) => index < 1, + itemBuilder: (context, item, index) { + tShouldCallBuild(); + + context.watch(); + return SizedBox( + height: 100, + child: Text('Item $index'), + ); + }, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + tListenable.addListener(fListener); + tListenable.notifyListeners(); + await tester.pumpAndSettle(); + + expect(tNotified, isTrue); + + tListenable.removeListener(fListener); + tNotified = false; + tListenable.notifyListeners(); + await tester.pumpAndSettle(); + + expect(tNotified, isFalse); + }); + + testWidgets('Test that overlaySlivers and prefixes are resized', + (WidgetTester tester) async { + await _setTestDeviceSize(tester); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + overlaySlivers: const [ + SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Text('overlay'), + ), + ), + ], + prefix: const SizedBox( + height: 100, + child: Text('prefix'), + ), + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.text('overlay'), findsOneWidget); + expect(find.text('prefix'), findsOneWidget); + + for (var i = 0; i < 4; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 4'), findsNothing); + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + overlaySlivers: const [ + SliverToBoxAdapter( + child: SizedBox( + height: 50, + child: Text('overlay'), + ), + ), + ], + prefix: const SizedBox( + height: 50, + child: Text('prefix'), + ), + fetchNext: fakeFetchNext, + itemBuilder: itemBuilder, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(find.text('overlay'), findsOneWidget); + expect(find.text('prefix'), findsOneWidget); + + for (var i = 0; i < 5; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + expect(find.text('Item 5'), findsNothing); + }); + + testWidgets('Test for correct recovery from error states', + (WidgetTester tester) async { + bool tShouldFail = true; + + Object? tExceptionA; + Object? tExceptionB; + await runZonedGuarded(() async { + Future> fetchNextToggle( + int index, int crossAxisCount) async { + await Future.delayed(const Duration(milliseconds: 100)); + if (tShouldFail) { + tExceptionA = Exception('Error'); + throw tExceptionA!; + } + return await fakeFetchNext(index, crossAxisCount); + } + + await tester.pumpWidget(buildTestWidget( + child: InfiniteScrollListView.list( + fetchNext: fetchNextToggle, + itemBuilder: itemBuilder, + errorBuilder: (context, error) { + tExceptionB = error; + return Center(child: Text(error.toString())); + }, + canRefresh: true, + onRefresh: () { + tShouldFail = false; + }, + canFetchNext: (index) => index < 10, + ), + )); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(tExceptionA, equals(tExceptionB)); + expect(find.text(tExceptionA.toString()), findsOneWidget); + + await tester.drag( + find.byType(InfiniteScrollListView), const Offset(0, 500)); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text(tExceptionA.toString()), findsNothing); + }, (error, stackTrace) { + if (error.toString() == 'Exception: Error') { + return; + } + throw error; + }); + }); +} diff --git a/packages/patapata_core/test/log_test.dart b/packages/patapata_core/test/log_test.dart index 5c7551d..dd0d719 100644 --- a/packages/patapata_core/test/log_test.dart +++ b/packages/patapata_core/test/log_test.dart @@ -842,103 +842,154 @@ void main() { Trace( [ Frame( - Uri.file('java.lang/Integer.java'), + Uri.file( + 'java.lang/Integer.java', + windows: false, + ), 797, null, 'Integer.parseInt', ), Frame( - Uri.file('java.lang/Integer.java'), + Uri.file( + 'java.lang/Integer.java', + windows: false, + ), 915, null, 'Integer.parseInt', ), Frame( - Uri.file('dev.patapata.patapata_core_example/MainActivity.kt'), + Uri.file( + 'dev.patapata.patapata_core_example/MainActivity.kt', + windows: false, + ), 27, null, 'MainActivity.configureFlutterEngine\$lambda-1\$lambda-0', ), Frame( - Uri.file('dev.patapata.patapata_core_example/UnknownSource'), + Uri.file( + 'dev.patapata.patapata_core_example/UnknownSource', + windows: false, + ), 0, null, 'MainActivity.\$r8\$lambda\$mgziiATvBKRngKgviCJADp8PLSA', ), Frame( - Uri.file('dev.patapata.patapata_core_example/UnknownSource'), + Uri.file( + 'dev.patapata.patapata_core_example/UnknownSource', + windows: false, + ), 2, null, 'MainActivity\$\$ExternalSyntheticLambda0.onMethodCall', ), Frame( - Uri.file('io.flutter.plugin.common/MethodChannel.java'), + Uri.file( + 'io.flutter.plugin.common/MethodChannel.java', + windows: false, + ), 258, null, 'MethodChannel\$IncomingMethodCallHandler.onMessage', ), Frame( - Uri.file('io.flutter.embedding.engine.dart/DartMessenger.java'), + Uri.file( + 'io.flutter.embedding.engine.dart/DartMessenger.java', + windows: false, + ), 295, null, 'DartMessenger.invokeHandler', ), Frame( - Uri.file('io.flutter.embedding.engine.dart/DartMessenger.java'), + Uri.file( + 'io.flutter.embedding.engine.dart/DartMessenger.java', + windows: false, + ), 322, null, 'DartMessenger.lambda\$dispatchMessageToQueue\$0\$io-flutter-embedding-engine-dart-DartMessenger', ), Frame( - Uri.file('io.flutter.embedding.engine.dart/UnknownSource'), + Uri.file( + 'io.flutter.embedding.engine.dart/UnknownSource', + windows: false, + ), 12, null, 'DartMessenger\$\$ExternalSyntheticLambda0.run', ), Frame( - Uri.file('android.os/Handler.java'), + Uri.file( + 'android.os/Handler.java', + windows: false, + ), 942, null, 'Handler.handleCallback', ), Frame( - Uri.file('android.os/Handler.java'), + Uri.file( + 'android.os/Handler.java', + windows: false, + ), 99, null, 'Handler.dispatchMessage', ), Frame( - Uri.file('android.os/Looper.java'), + Uri.file( + 'android.os/Looper.java', + windows: false, + ), 346, null, 'Looper.loopOnce', ), Frame( - Uri.file('android.os/Looper.java'), + Uri.file( + 'android.os/Looper.java', + windows: false, + ), 475, null, 'Looper.loop', ), Frame( - Uri.file('android.app/ActivityThread.java'), + Uri.file( + 'android.app/ActivityThread.java', + windows: false, + ), 7950, null, 'ActivityThread.main', ), Frame( - Uri.file('java.lang.reflect/NativeMethod'), + Uri.file( + 'java.lang.reflect/NativeMethod', + windows: false, + ), null, null, 'Method.invoke', ), Frame( - Uri.file('com.android.internal.os/RuntimeInit.java'), + Uri.file( + 'com.android.internal.os/RuntimeInit.java', + windows: false, + ), 548, null, 'RuntimeInit\$MethodAndArgsCaller.run', ), Frame( - Uri.file('com.android.internal.os/ZygoteInit.java'), + Uri.file( + 'com.android.internal.os/ZygoteInit.java', + windows: false, + ), 942, null, 'ZygoteInit.main', @@ -948,19 +999,28 @@ void main() { Trace( [ Frame( - Uri.file('java.lang/Integer.java'), + Uri.file( + 'java.lang/Integer.java', + windows: false, + ), 4, null, 'Integer.parseInt', ), Frame( - Uri.file('java.lang/Integer.java'), + Uri.file( + 'java.lang/Integer.java', + windows: false, + ), 5, null, 'Integer.parseInt', ), Frame( - Uri.file('dev.patapata.patapata_core_example/MainActivity.kt'), + Uri.file( + 'dev.patapata.patapata_core_example/MainActivity.kt', + windows: false, + ), 6, null, 'MainActivity.test', @@ -1210,91 +1270,136 @@ void main() { final tExpectTrace = Trace( [ Frame( - Uri.file('Runner/'), + Uri.file( + 'Runner/', + windows: false, + ), 72, null, '\$s6Runner12NativeLoggerC14viewControllerACSo011FlutterViewE0C_tcfcySo0F10MethodCallC_yypSgctcACcfu_yAH_yAIctcfu0_', ), Frame( - Uri.file('Runner/'), + Uri.file( + 'Runner/', + windows: false, + ), 136, null, '\$sSo17FlutterMethodCallCypSgIegn_Ieggg_AByXlSgIeyBy_IeyByy_TR', ), Frame( - Uri.file('Flutter/'), + Uri.file( + 'Flutter/', + windows: false, + ), 172, null, '__45-[FlutterMethodChannel setMethodCallHandler:]_block_invoke', ), Frame( - Uri.file('Flutter/'), + Uri.file( + 'Flutter/', + windows: false, + ), 116, null, '___ZN7flutter25PlatformMessageHandlerIos21HandlePlatformMessageENSt21_LIBCPP_ABI_NAMESPACE10unique_ptrINS_15PlatformMessageENS1_14default_deleteIS3_EEEE_block_invoke', ), Frame( - Uri.file('libdispatch.dylib/'), + Uri.file( + 'libdispatch.dylib/', + windows: false, + ), 7172, null, '959CD6E4-0CE7-3022-B73C-8B36F79F4745', ), Frame( - Uri.file('libdispatch.dylib/'), + Uri.file( + 'libdispatch.dylib/', + windows: false, + ), 14672, null, '959CD6E4-0CE7-3022-B73C-8B36F79F4745', ), Frame( - Uri.file('libdispatch.dylib/'), + Uri.file( + 'libdispatch.dylib/', + windows: false, + ), 940, null, '_dispatch_main_queue_callback_4CF', ), Frame( - Uri.file('CoreFoundation/'), + Uri.file( + 'CoreFoundation/', + windows: false, + ), 335076, null, '6174789A-E88C-3F5C-BA39-DE2E9EDC0750', ), Frame( - Uri.file('CoreFoundation/'), + Uri.file( + 'CoreFoundation/', + windows: false, + ), 48828, null, '6174789A-E88C-3F5C-BA39-DE2E9EDC0750', ), Frame( - Uri.file('CoreFoundation/'), + Uri.file( + 'CoreFoundation/', + windows: false, + ), 600, null, 'CFRunLoopRunSpecific', ), Frame( - Uri.file('GraphicsServices/'), + Uri.file( + 'GraphicsServices/', + windows: false, + ), 164, null, 'GSEventRunModal', ), Frame( - Uri.file('UIKitCore/'), + Uri.file( + 'UIKitCore/', + windows: false, + ), 5353660, null, '0E2D8679-D5F1-3C03-9010-7F6CE3662789', ), Frame( - Uri.file('UIKitCore/'), + Uri.file( + 'UIKitCore/', + windows: false, + ), 2124, null, 'UIApplicationMain', ), Frame( - Uri.file('Runner/'), + Uri.file( + 'Runner/', + windows: false, + ), 64, null, 'main', ), Frame( - Uri.file('dyld/'), + Uri.file( + 'dyld/', + windows: false, + ), 520, null, 'start',