import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; class LegsPage extends StatefulWidget { const LegsPage({super.key}); @override State createState() => _LegsPageState(); } class _LegsPageState extends State { int _sortDirection = 0; DateTime? _startDate; DateTime? _endDate; bool _initialised = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _refreshLegs(); } } Future _refreshLegs() async { final data = context.read(); await data.fetchLegs( sortDirection: _sortDirection, dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), ); } Future _loadMore() async { final data = context.read(); await data.fetchLegs( sortDirection: _sortDirection, dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), offset: data.legs.length, append: true, ); } 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; }); _refreshLegs(); } @override Widget build(BuildContext context) { final data = context.watch(); final legs = data.legs; final pageMileage = _pageMileage(legs); return RefreshIndicator( onRefresh: _refreshLegs, child: ListView( 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('${pageMileage.toStringAsFixed(1)} mi', 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!)!, ), ), ], ), ], ), ), ), 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: [ ...legs.map((leg) => _buildLegCard(context, leg)), const SizedBox(height: 8), if (data.legsHasMore || data.isLegsLoading) 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', ), ), ), ], ), ], ), ); } 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')}'; } String _formatDateTime(DateTime date) { final dateStr = _formatDate(date) ?? ''; final timeStr = '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; return '$dateStr · $timeStr'; } Widget _buildLegCard(BuildContext context, Leg leg) { final routeSegments = _parseRouteSegments(leg.route); final textTheme = Theme.of(context).textTheme; return Card( child: ExpansionTile( tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), leading: const Icon(Icons.train), title: Text('${leg.start} → ${leg.end}'), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_formatDateTime(leg.beginTime)), if (leg.headcode.isNotEmpty) Text( 'Headcode: ${leg.headcode}', style: textTheme.labelSmall, ), if (leg.network.isNotEmpty) Text( leg.network, style: textTheme.labelSmall, ), ], ), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( tooltip: 'Edit entry', icon: const Icon(Icons.edit), onPressed: () => context.push('/legs/edit/${leg.id}'), ), Text( '${leg.mileage.toStringAsFixed(1)} mi', style: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), ), if (leg.tripId != 0) Text( 'Trip #${leg.tripId}', style: textTheme.labelSmall, ), ], ), children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (leg.notes.isNotEmpty) ...[ Text('Notes', style: textTheme.titleSmall), const SizedBox(height: 4), Text(leg.notes), const SizedBox(height: 12), ], if (leg.locos.isNotEmpty) ...[ Text('Locos', style: textTheme.titleSmall), const SizedBox(height: 6), Wrap( spacing: 8, runSpacing: 8, children: _buildLocoChips(context, leg), ), const SizedBox(height: 12), ], if (routeSegments.isNotEmpty) ...[ Text('Route', style: textTheme.titleSmall), const SizedBox(height: 6), _buildRouteList(routeSegments), ], ], ), ), ], ), ); } List _buildLocoChips(BuildContext context, Leg leg) { final theme = Theme.of(context); return leg.locos .map( (loco) => Chip( label: Text('${loco.locoClass} ${loco.number}'), avatar: const Icon(Icons.directions_railway, size: 16), backgroundColor: theme.colorScheme.surfaceContainerHighest, ), ) .toList(); } Widget _buildRouteList(List segments) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: segments .map( (segment) => Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( children: [ const Icon(Icons.circle, size: 10), const SizedBox(width: 8), Expanded(child: Text(segment)), ], ), ), ) .toList(), ); } List _parseRouteSegments(String route) { final trimmed = route.trim(); if (trimmed.isEmpty) return []; try { final decoded = jsonDecode(trimmed); if (decoded is List) { return decoded.map((e) => e.toString()).toList(); } } catch (_) { // ignore and try alternative parsing } if (trimmed.startsWith('[') && trimmed.endsWith(']')) { try { final replaced = trimmed.replaceAll("'", '"'); final decoded = jsonDecode(replaced); if (decoded is List) { return decoded.map((e) => e.toString()).toList(); } } catch (_) {} } if (trimmed.contains('->')) { return trimmed .split('->') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } if (trimmed.contains(',')) { return trimmed .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } return [trimmed]; } }