504 lines
16 KiB
Dart
504 lines
16 KiB
Dart
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<LegsPage> createState() => _LegsPageState();
|
|
}
|
|
|
|
class _LegsPageState extends State<LegsPage> {
|
|
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<String> _availableNetworks = [];
|
|
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
|
|
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<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 {
|
|
setState(() => _loadingNetworks = true);
|
|
final data = context.read<DataService>();
|
|
await data.fetchStationNetworks();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_availableNetworks = data.stationNetworks;
|
|
_loadingNetworks = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _refreshLegs() async {
|
|
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(
|
|
limit: limit,
|
|
sortDirection: _sortDirection,
|
|
dateRangeStart: _formatDate(_startDate),
|
|
dateRangeEnd: _formatDate(_endDate),
|
|
unallocatedOnly: _unallocatedOnly,
|
|
networkFilter: _selectedNetworks,
|
|
);
|
|
}
|
|
|
|
Future<void> _loadMore() async {
|
|
final data = context.read<DataService>();
|
|
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<double>(
|
|
0,
|
|
(prev, leg) => prev + (leg.mileage as double? ?? 0),
|
|
);
|
|
}
|
|
|
|
Future<void> _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<DataService>();
|
|
final distanceUnits = context.watch<DistanceUnitService>();
|
|
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<Widget> _buildLegsWithDividers(
|
|
BuildContext context,
|
|
List<Leg> legs,
|
|
DistanceUnitService distanceUnits,
|
|
) {
|
|
final widgets = <Widget>[];
|
|
String? currentDate;
|
|
double dayMileage = 0;
|
|
final dayLegs = <Leg>[];
|
|
|
|
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')}';
|
|
}
|
|
|
|
}
|