add infinite scroll to entries

This commit is contained in:
2026-02-23 15:57:58 +00:00
parent d8bcde1312
commit 419e2a8766
2 changed files with 176 additions and 27 deletions

View File

@@ -14,6 +14,10 @@ class LegsPage extends StatefulWidget {
} }
class _LegsPageState extends State<LegsPage> { class _LegsPageState extends State<LegsPage> {
static const int _pageSize = 100;
static const double _loadMoreTriggerExtent = 400;
static double _persistedScrollOffset = 0;
int _sortDirection = 0; int _sortDirection = 0;
DateTime? _startDate; DateTime? _startDate;
DateTime? _endDate; DateTime? _endDate;
@@ -23,6 +27,18 @@ class _LegsPageState extends State<LegsPage> {
bool _loadingNetworks = false; bool _loadingNetworks = false;
List<String> _availableNetworks = []; List<String> _availableNetworks = [];
List<String> _selectedNetworks = []; List<String> _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 @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -34,6 +50,49 @@ class _LegsPageState extends State<LegsPage> {
} }
} }
@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<void> _maybeAutoLoadMore() async {
if (!mounted || !_scrollController.hasClients || _isAutoLoadingMore) return;
final position = _scrollController.position;
if (position.extentAfter > _loadMoreTriggerExtent) return;
final data = context.read<DataService>();
if (data.isLegsLoading || !data.legsHasMore) return;
_isAutoLoadingMore = true;
try {
await _loadMore();
} finally {
_isAutoLoadingMore = false;
}
}
Future<void> _scrollToTop() async {
if (!_scrollController.hasClients) return;
await _scrollController.animateTo(
0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
Future<void> _loadNetworks() async { Future<void> _loadNetworks() async {
setState(() => _loadingNetworks = true); setState(() => _loadingNetworks = true);
final data = context.read<DataService>(); final data = context.read<DataService>();
@@ -47,7 +106,16 @@ class _LegsPageState extends State<LegsPage> {
Future<void> _refreshLegs() async { Future<void> _refreshLegs() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
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( await data.fetchLegs(
limit: limit,
sortDirection: _sortDirection, sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate), dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
@@ -58,17 +126,30 @@ class _LegsPageState extends State<LegsPage> {
Future<void> _loadMore() async { Future<void> _loadMore() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final offset = data.legs.length;
await data.fetchLegs( await data.fetchLegs(
sortDirection: _sortDirection, sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate), dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
offset: data.legs.length, offset: offset,
limit: _pageSize,
append: true, append: true,
unallocatedOnly: _unallocatedOnly, unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks, 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) { double _pageMileage(List legs) {
return legs.fold<double>( return legs.fold<double>(
0, 0,
@@ -120,12 +201,15 @@ class _LegsPageState extends State<LegsPage> {
final legs = data.legs; final legs = data.legs;
final pageMileage = _pageMileage(legs); final pageMileage = _pageMileage(legs);
return RefreshIndicator( return Stack(
onRefresh: _refreshLegs, children: [
child: ListView( RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: _refreshLegs,
physics: const AlwaysScrollableScrollPhysics(), child: ListView(
children: [ controller: _scrollController,
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -298,28 +382,62 @@ class _LegsPageState extends State<LegsPage> {
children: [ children: [
..._buildLegsWithDividers(context, legs, distanceUnits), ..._buildLegsWithDividers(context, legs, distanceUnits),
const SizedBox(height: 8), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.isLegsLoading)
Align( const Align(
alignment: Alignment.center, alignment: Alignment.center,
child: OutlinedButton.icon( child: Padding(
onPressed: padding: EdgeInsets.symmetric(vertical: 12),
data.isLegsLoading ? null : () => _loadMore(), child: SizedBox(
icon: data.isLegsLoading height: 18,
? const SizedBox( width: 18,
height: 14, child: CircularProgressIndicator(strokeWidth: 2),
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isLegsLoading ? 'Loading...' : 'Load more',
), ),
), ),
), ),
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),
),
),
),
),
],
); );
} }

View File

@@ -18,6 +18,26 @@ class _LegFetchOptions {
this.unallocatedOnly = false, this.unallocatedOnly = false,
this.networkFilter = const [], this.networkFilter = const [],
}); });
_LegFetchOptions copyWith({
int? limit,
String? sortBy,
int? sortDirection,
String? dateRangeStart,
String? dateRangeEnd,
bool? unallocatedOnly,
List<String>? 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 { class DataService extends ChangeNotifier {
@@ -397,11 +417,11 @@ class DataService extends ChangeNotifier {
List<String> networkFilter = const [], List<String> networkFilter = const [],
}) async { }) async {
_isLegsLoading = true; _isLegsLoading = true;
final normalizedNetworks = networkFilter
.map((network) => network.trim())
.where((network) => network.isNotEmpty)
.toList();
if (!append) { if (!append) {
final normalizedNetworks = networkFilter
.map((network) => network.trim())
.where((network) => network.isNotEmpty)
.toList();
_lastLegsFetch = _LegFetchOptions( _lastLegsFetch = _LegFetchOptions(
limit: limit, limit: limit,
sortBy: sortBy, sortBy: sortBy,
@@ -437,6 +457,17 @@ class DataService extends ChangeNotifier {
if (json is List) { if (json is List) {
final newLegs = json.map((e) => Leg.fromJson(e)).toList(); final newLegs = json.map((e) => Leg.fromJson(e)).toList();
_legs = append ? [..._legs, ...newLegs] : newLegs; _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. // Keep "load more" available as long as the server returns items; hide only on empty.
_legsHasMore = newLegs.isNotEmpty; _legsHasMore = newLegs.isNotEmpty;
} else { } else {