Files
mileograph_flutter/lib/components/pages/legs.dart
Pete Gregory 80be797322
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 15m50s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
major refactor
2025-12-16 16:49:39 +00:00

405 lines
13 KiB
Dart

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<LegsPage> createState() => _LegsPageState();
}
class _LegsPageState extends State<LegsPage> {
int _sortDirection = 0;
DateTime? _startDate;
DateTime? _endDate;
bool _initialised = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_refreshLegs();
}
}
Future<void> _refreshLegs() async {
final data = context.read<DataService>();
await data.fetchLegs(
sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate),
);
}
Future<void> _loadMore() async {
final data = context.read<DataService>();
await data.fetchLegs(
sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate),
offset: data.legs.length,
append: true,
);
}
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;
});
_refreshLegs();
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
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<Widget> _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<String> 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<String> _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];
}
}