From e9a9e66e39c17419ca26583c130ff1ac56205d90 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Wed, 17 Dec 2025 14:42:31 +0000 Subject: [PATCH] add loco legs panel --- lib/components/legs/leg_card.dart | 195 +++++++++++++++++++++ lib/components/pages/legs.dart | 162 +---------------- lib/components/pages/loco_legs.dart | 119 +++++++++++++ lib/components/pages/traction.dart | 9 + lib/components/traction/traction_card.dart | 10 ++ lib/main.dart | 22 +++ lib/services/data_service.dart | 32 ++++ 7 files changed, 389 insertions(+), 160 deletions(-) create mode 100644 lib/components/legs/leg_card.dart create mode 100644 lib/components/pages/loco_legs.dart diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart new file mode 100644 index 0000000..f4894d4 --- /dev/null +++ b/lib/components/legs/leg_card.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; + +class LegCard extends StatelessWidget { + const LegCard({ + super.key, + required this.leg, + this.showEditButton = true, + }); + + final Leg leg; + final bool showEditButton; + + @override + Widget build(BuildContext context) { + 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: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${leg.mileage.toStringAsFixed(1)} mi', + style: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (leg.tripId != 0) ...[ + const SizedBox(height: 2), + Text( + 'Trip #${leg.tripId}', + style: textTheme.labelSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + if (showEditButton) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: 'Edit entry', + icon: const Icon(Icons.edit), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () => context.push('/legs/edit/${leg.id}'), + ), + ], + ], + ), + 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), + ], + ], + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime? date) { + if (date == null) return ''; + 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'; + } + + 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 (_) {} + 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]; + } +} + diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index e61c3a5..a49bebb 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -1,8 +1,5 @@ -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/components/legs/leg_card.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; @@ -214,7 +211,7 @@ class _LegsPageState extends State { else Column( children: [ - ...legs.map((leg) => _buildLegCard(context, leg)), + ...legs.map((leg) => LegCard(leg: leg)), const SizedBox(height: 8), if (data.legsHasMore || data.isLegsLoading) Align( @@ -246,159 +243,4 @@ class _LegsPageState extends State { 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]; - } } diff --git a/lib/components/pages/loco_legs.dart b/lib/components/pages/loco_legs.dart new file mode 100644 index 0000000..6a6504a --- /dev/null +++ b/lib/components/pages/loco_legs.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/components/legs/leg_card.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class LocoLegsPage extends StatefulWidget { + const LocoLegsPage({ + super.key, + required this.locoId, + required this.locoLabel, + }); + + final int locoId; + final String locoLabel; + + @override + State createState() => _LocoLegsPageState(); +} + +class _LocoLegsPageState extends State { + bool _includeNonPowering = false; + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _fetch(); + } + + Future> _fetch() { + return context.read().fetchLegsForLoco( + widget.locoId, + includeNonPowering: _includeNonPowering, + ); + } + + Future _refresh() async { + final items = await _fetch(); + if (!mounted) return; + setState(() { + _future = Future.value(items); + }); + } + + @override + Widget build(BuildContext context) { + final titleLabel = + widget.locoLabel.trim().isEmpty ? 'Loco ${widget.locoId}' : widget.locoLabel; + return Scaffold( + appBar: AppBar( + title: Text('Legs · $titleLabel'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: _refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Card( + child: SwitchListTile( + title: const Text('Include non-powering (dead-in-tow)'), + subtitle: const Text('Off by default'), + value: _includeNonPowering, + onChanged: (val) { + setState(() { + _includeNonPowering = val; + _future = _fetch(); + }); + }, + ), + ), + ), + Expanded( + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + final items = snapshot.data ?? const []; + final isLoading = + snapshot.connectionState == ConnectionState.waiting; + + if (isLoading && items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (!isLoading && items.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('No legs found for this loco.'), + ), + ); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: items.length, + itemBuilder: (context, index) => LegCard(leg: items[index]), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 840a4d6..544fce5 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -556,6 +556,7 @@ class _TractionPageState extends State { isSelected: _isSelected(loco), onShowInfo: () => showTractionDetails(context, loco), onOpenTimeline: () => _openTimeline(loco), + onOpenLegs: () => _openLegs(loco), onToggleSelect: widget.selectionMode ? () => _toggleSelection(loco) : null, ), @@ -653,6 +654,14 @@ class _TractionPageState extends State { await _refreshTraction(); } + Future _openLegs(LocoSummary loco) async { + final label = '${loco.locoClass} ${loco.number}'.trim(); + await context.push( + '/traction/${loco.id}/legs', + extra: {'label': label}, + ); + } + Widget _buildFilterInput( BuildContext context, EventField field, diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index 915390f..f165236 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -9,6 +9,7 @@ class TractionCard extends StatelessWidget { required this.isSelected, required this.onShowInfo, required this.onOpenTimeline, + this.onOpenLegs, this.onToggleSelect, }); @@ -17,6 +18,7 @@ class TractionCard extends StatelessWidget { final bool isSelected; final VoidCallback onShowInfo; final VoidCallback onOpenTimeline; + final VoidCallback? onOpenLegs; final VoidCallback? onToggleSelect; @override @@ -93,6 +95,14 @@ class TractionCard extends StatelessWidget { icon: const Icon(Icons.timeline), label: const Text('Timeline'), ), + if (hasMileageOrTrips && onOpenLegs != null) ...[ + const SizedBox(width: 8), + TextButton.icon( + onPressed: onOpenLegs, + icon: const Icon(Icons.view_list), + label: const Text('Legs'), + ), + ], const Spacer(), if (selectionMode && onToggleSelect != null) TextButton.icon( diff --git a/lib/main.dart b/lib/main.dart index 8502994..cbc1ac0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator_details.dart'; +import 'package:mileograph_flutter/components/pages/loco_legs.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart'; @@ -123,6 +124,27 @@ class MyApp extends StatelessWidget { ); }, ), + GoRoute( + path: '/traction/:id/legs', + builder: (_, state) { + final idParam = state.pathParameters['id']; + final locoId = int.tryParse(idParam ?? '') ?? 0; + final extra = state.extra; + String label = state.uri.queryParameters['label'] ?? ''; + if (extra is Map && extra['label'] is String) { + label = extra['label'] as String; + } else if (extra is String && extra.isNotEmpty) { + label = extra; + } + if (label.trim().isEmpty) { + label = 'Loco $locoId'; + } + return LocoLegsPage( + locoId: locoId, + locoLabel: label, + ); + }, + ), GoRoute( path: '/traction/new', builder: (context, state) => const NewTractionPage(), diff --git a/lib/services/data_service.dart b/lib/services/data_service.dart index 080c2f8..2d1fd7a 100644 --- a/lib/services/data_service.dart +++ b/lib/services/data_service.dart @@ -177,6 +177,38 @@ class DataService extends ChangeNotifier { ); } + Future> fetchLegsForLoco( + int locoId, { + bool includeNonPowering = false, + }) async { + if (locoId <= 0) return []; + final params = + includeNonPowering ? '?include_non_powering=true' : ''; + try { + final json = await api.get('/legs/$locoId$params'); + dynamic list = json; + if (json is Map) { + for (final key in ['legs', 'data', 'results']) { + if (json[key] is List) { + list = json[key]; + break; + } + } + } + if (list is List) { + return list + .whereType() + .map((e) => Leg.fromJson(Map.from(e))) + .toList(); + } + debugPrint('Unexpected loco legs response: $json'); + return []; + } catch (e) { + debugPrint('Failed to fetch loco legs for $locoId: $e'); + return []; + } + } + Future fetchHadTraction({int offset = 0, int limit = 100}) async { await fetchTraction( hadOnly: true,