import 'package:flutter/material.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart'; import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class LegsPage extends StatefulWidget { const LegsPage({super.key}); @override State createState() => _LegsPageState(); } 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; bool _initialised = false; bool _unallocatedOnly = false; bool _showMoreFilters = false; 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() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _refreshLegs(); _loadNetworks(); } } @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(); await data.fetchStationNetworks(); if (!mounted) return; setState(() { _availableNetworks = data.stationNetworks; _loadingNetworks = false; }); } 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), unallocatedOnly: _unallocatedOnly, networkFilter: _selectedNetworks, ); } Future _loadMore() async { final data = context.read(); final offset = data.legs.length; await data.fetchLegs( sortDirection: _sortDirection, dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), 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, (prev, leg) => prev + (leg.mileage as double? ?? 0), ); } Future _pickDate({required bool start}) async { final initial = start ? _startDate ?? DateTime.now() : _endDate ?? _startDate ?? DateTime.now(); final picked = await showDatePicker( context: context, initialDate: initial, firstDate: DateTime(1970), lastDate: DateTime.now().add(const Duration(days: 365)), ); if (picked != null) { setState(() { if (start) { _startDate = picked; if (_endDate != null && _endDate!.isBefore(picked)) { _endDate = picked; } } else { _endDate = picked; } }); await _refreshLegs(); } } void _clearFilters() { setState(() { _startDate = null; _endDate = null; _sortDirection = 0; _unallocatedOnly = false; _showMoreFilters = false; _selectedNetworks = []; }); _refreshLegs(); } @override Widget build(BuildContext context) { final data = context.watch(); final distanceUnits = context.watch(); final legs = data.legs; final pageMileage = _pageMileage(legs); return Stack( children: [ RefreshIndicator( onRefresh: _refreshLegs, child: ListView( controller: _scrollController, padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Logbook', style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: 2), Text('Entries', style: Theme.of(context).textTheme.headlineSmall), ], ), Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('Page mileage', style: Theme.of(context).textTheme.labelSmall), Text(distanceUnits.format(pageMileage, decimals: 1), style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.w700)), ], ), ), ), ], ), const SizedBox(height: 12), Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Filters', style: Theme.of(context).textTheme.titleMedium), TextButton.icon( onPressed: _clearFilters, icon: const Icon(Icons.refresh), label: const Text('Clear'), ), ], ), const SizedBox(height: 8), Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton.tonalIcon( onPressed: () => _pickDate(start: true), icon: const Icon(Icons.calendar_month), label: Text( _startDate == null ? 'Start date' : _formatDate(_startDate!)!, ), ), FilledButton.tonalIcon( onPressed: () => _pickDate(start: false), icon: const Icon(Icons.event), label: Text( _endDate == null ? 'End date' : _formatDate(_endDate!)!, ), ), TextButton.icon( onPressed: () => setState( () => _showMoreFilters = !_showMoreFilters, ), icon: Icon( _showMoreFilters ? Icons.expand_less : Icons.expand_more, ), label: Text( _showMoreFilters ? 'Hide filters' : 'More filters', ), ), ], ), AnimatedCrossFade( crossFadeState: _showMoreFilters ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), firstChild: Padding( padding: const EdgeInsets.only(top: 12.0), child: Wrap( spacing: 12, runSpacing: 12, children: [ MultiSelectFilter( label: 'Networks', options: _availableNetworks, selected: _selectedNetworks, onChanged: (vals) async { setState(() => _selectedNetworks = vals); await _refreshLegs(); }, onRefresh: _loadingNetworks ? null : _loadNetworks, ), FilterChip( avatar: const Icon(Icons.flash_off), label: const Text('Unallocated only'), selected: _unallocatedOnly, onSelected: (selected) async { setState(() => _unallocatedOnly = selected); await _refreshLegs(); }, ), if (_loadingNetworks) const Padding( padding: EdgeInsets.only(left: 8.0), child: SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ], ), ), secondChild: const SizedBox.shrink(), ), ], ), ), ), const SizedBox(height: 12), if (data.isLegsLoading && legs.isEmpty) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), ) else if (legs.isEmpty) Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'No entries found', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), const Text('Adjust the filters or add a new leg.'), ], ), ), ) else Column( children: [ ..._buildLegsWithDividers(context, legs, distanceUnits), const SizedBox(height: 8), if (data.isLegsLoading) const Align( alignment: Alignment.center, 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), ), ), ), ), ], ); } List _buildLegsWithDividers( BuildContext context, List legs, DistanceUnitService distanceUnits, ) { final widgets = []; String? currentDate; double dayMileage = 0; final dayLegs = []; void flushDay() { final date = currentDate; if (date == null) return; widgets.add( Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ Expanded( child: Text( date, style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ), Text(distanceUnits.format(dayMileage, decimals: 1), style: Theme.of(context).textTheme.labelMedium), ], ), ), ); widgets.add(const Divider()); widgets.addAll( dayLegs.map((leg) => LegCard(leg: leg, showDate: false)), ); dayLegs.clear(); } for (final leg in legs) { final dateStr = _formatDate(leg.beginTime) ?? ''; if (currentDate != null && dateStr != currentDate) { flushDay(); dayMileage = 0; } currentDate = dateStr; dayLegs.add(leg); dayMileage += leg.mileage; } flushDay(); return widgets; } String? _formatDate(DateTime? date) { if (date == null) return null; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } }