diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index 3f33318..993d64a 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -14,6 +14,10 @@ class LegsPage extends StatefulWidget { } class _LegsPageState extends State { + static const int _pageSize = 100; + static const double _loadMoreTriggerExtent = 400; + static double _persistedScrollOffset = 0; + int _sortDirection = 0; DateTime? _startDate; DateTime? _endDate; @@ -23,6 +27,18 @@ class _LegsPageState extends State { bool _loadingNetworks = false; List _availableNetworks = []; List _selectedNetworks = []; + String? _lastQuerySignature; + late final ScrollController _scrollController; + bool _showBackToTop = false; + bool _isAutoLoadingMore = false; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController( + initialScrollOffset: _persistedScrollOffset, + )..addListener(_onScroll); + } @override void didChangeDependencies() { @@ -34,6 +50,49 @@ class _LegsPageState extends State { } } + @override + void dispose() { + _persistedScrollOffset = + _scrollController.hasClients ? _scrollController.offset : 0; + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + _persistedScrollOffset = _scrollController.offset; + final shouldShow = _scrollController.offset > 500; + if (shouldShow != _showBackToTop) { + setState(() => _showBackToTop = shouldShow); + } + _maybeAutoLoadMore(); + } + + Future _maybeAutoLoadMore() async { + if (!mounted || !_scrollController.hasClients || _isAutoLoadingMore) return; + final position = _scrollController.position; + if (position.extentAfter > _loadMoreTriggerExtent) return; + final data = context.read(); + if (data.isLegsLoading || !data.legsHasMore) return; + + _isAutoLoadingMore = true; + try { + await _loadMore(); + } finally { + _isAutoLoadingMore = false; + } + } + + Future _scrollToTop() async { + if (!_scrollController.hasClients) return; + await _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + Future _loadNetworks() async { setState(() => _loadingNetworks = true); final data = context.read(); @@ -47,7 +106,16 @@ class _LegsPageState extends State { Future _refreshLegs() async { final data = context.read(); + final signature = _legsQuerySignature(); + final queryChanged = + _lastQuerySignature != null && signature != _lastQuerySignature; + _lastQuerySignature = signature; + + final currentCount = data.legs.length; + final shouldPreserveLoadedCount = !queryChanged && currentCount > _pageSize; + final limit = shouldPreserveLoadedCount ? currentCount : _pageSize; await data.fetchLegs( + limit: limit, sortDirection: _sortDirection, dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), @@ -58,17 +126,30 @@ class _LegsPageState extends State { Future _loadMore() async { final data = context.read(); + final offset = data.legs.length; await data.fetchLegs( sortDirection: _sortDirection, dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), - offset: data.legs.length, + offset: offset, + limit: _pageSize, append: true, unallocatedOnly: _unallocatedOnly, networkFilter: _selectedNetworks, ); } + String _legsQuerySignature() { + final networks = [..._selectedNetworks]..sort(); + return [ + 'sort=$_sortDirection', + 'start=${_formatDate(_startDate) ?? ''}', + 'end=${_formatDate(_endDate) ?? ''}', + 'unallocated=$_unallocatedOnly', + 'networks=${networks.join(',')}', + ].join(';'); + } + double _pageMileage(List legs) { return legs.fold( 0, @@ -120,12 +201,15 @@ class _LegsPageState extends State { final legs = data.legs; final pageMileage = _pageMileage(legs); - return RefreshIndicator( - onRefresh: _refreshLegs, - child: ListView( - padding: const EdgeInsets.all(16), - physics: const AlwaysScrollableScrollPhysics(), - children: [ + return Stack( + children: [ + RefreshIndicator( + onRefresh: _refreshLegs, + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -298,28 +382,62 @@ class _LegsPageState extends State { children: [ ..._buildLegsWithDividers(context, legs, distanceUnits), const SizedBox(height: 8), - if (data.legsHasMore || data.isLegsLoading) - Align( + if (data.isLegsLoading) + const Align( alignment: Alignment.center, - child: OutlinedButton.icon( - onPressed: - data.isLegsLoading ? null : () => _loadMore(), - icon: data.isLegsLoading - ? const SizedBox( - height: 14, - width: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.expand_more), - label: Text( - data.isLegsLoading ? 'Loading...' : 'Load more', + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), ), ), ), + if (data.legsHasMore && !data.isLegsLoading) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text( + 'Loading', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), ], ), - ], - ), + ], + ), + ), + Positioned( + right: 16, + bottom: 16, + child: AnimatedSlide( + offset: _showBackToTop ? Offset.zero : const Offset(0, 1.2), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: _showBackToTop ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: FloatingActionButton.small( + heroTag: 'legsBackToTop', + tooltip: 'Back to top', + onPressed: _scrollToTop, + child: const Icon(Icons.arrow_upward), + ), + ), + ), + ), + ], ); } diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 0624f49..c4fbc61 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -18,6 +18,26 @@ class _LegFetchOptions { this.unallocatedOnly = false, this.networkFilter = const [], }); + + _LegFetchOptions copyWith({ + int? limit, + String? sortBy, + int? sortDirection, + String? dateRangeStart, + String? dateRangeEnd, + bool? unallocatedOnly, + List? networkFilter, + }) { + return _LegFetchOptions( + limit: limit ?? this.limit, + sortBy: sortBy ?? this.sortBy, + sortDirection: sortDirection ?? this.sortDirection, + dateRangeStart: dateRangeStart ?? this.dateRangeStart, + dateRangeEnd: dateRangeEnd ?? this.dateRangeEnd, + unallocatedOnly: unallocatedOnly ?? this.unallocatedOnly, + networkFilter: networkFilter ?? this.networkFilter, + ); + } } class DataService extends ChangeNotifier { @@ -397,11 +417,11 @@ class DataService extends ChangeNotifier { List networkFilter = const [], }) async { _isLegsLoading = true; + final normalizedNetworks = networkFilter + .map((network) => network.trim()) + .where((network) => network.isNotEmpty) + .toList(); if (!append) { - final normalizedNetworks = networkFilter - .map((network) => network.trim()) - .where((network) => network.isNotEmpty) - .toList(); _lastLegsFetch = _LegFetchOptions( limit: limit, sortBy: sortBy, @@ -437,6 +457,17 @@ class DataService extends ChangeNotifier { if (json is List) { final newLegs = json.map((e) => Leg.fromJson(e)).toList(); _legs = append ? [..._legs, ...newLegs] : newLegs; + if (append) { + _lastLegsFetch = _lastLegsFetch.copyWith( + limit: _legs.length, + sortBy: sortBy, + sortDirection: sortDirection, + dateRangeStart: dateRangeStart, + dateRangeEnd: dateRangeEnd, + unallocatedOnly: unallocatedOnly, + networkFilter: normalizedNetworks, + ); + } // Keep "load more" available as long as the server returns items; hide only on empty. _legsHasMore = newLegs.isNotEmpty; } else {