add loco legs panel
This commit is contained in:
195
lib/components/legs/leg_card.dart
Normal file
195
lib/components/legs/leg_card.dart
Normal file
@@ -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<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 (_) {}
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.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:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -214,7 +211,7 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
else
|
else
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
...legs.map((leg) => _buildLegCard(context, leg)),
|
...legs.map((leg) => LegCard(leg: leg)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (data.legsHasMore || data.isLegsLoading)
|
if (data.legsHasMore || data.isLegsLoading)
|
||||||
Align(
|
Align(
|
||||||
@@ -246,159 +243,4 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
119
lib/components/pages/loco_legs.dart
Normal file
119
lib/components/pages/loco_legs.dart
Normal file
@@ -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<LocoLegsPage> createState() => _LocoLegsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocoLegsPageState extends State<LocoLegsPage> {
|
||||||
|
bool _includeNonPowering = false;
|
||||||
|
late Future<List<Leg>> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Leg>> _fetch() {
|
||||||
|
return context.read<DataService>().fetchLegsForLoco(
|
||||||
|
widget.locoId,
|
||||||
|
includeNonPowering: _includeNonPowering,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<List<Leg>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final items = snapshot.data ?? const <Leg>[];
|
||||||
|
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]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -556,6 +556,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
isSelected: _isSelected(loco),
|
isSelected: _isSelected(loco),
|
||||||
onShowInfo: () => showTractionDetails(context, loco),
|
onShowInfo: () => showTractionDetails(context, loco),
|
||||||
onOpenTimeline: () => _openTimeline(loco),
|
onOpenTimeline: () => _openTimeline(loco),
|
||||||
|
onOpenLegs: () => _openLegs(loco),
|
||||||
onToggleSelect:
|
onToggleSelect:
|
||||||
widget.selectionMode ? () => _toggleSelection(loco) : null,
|
widget.selectionMode ? () => _toggleSelection(loco) : null,
|
||||||
),
|
),
|
||||||
@@ -653,6 +654,14 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
await _refreshTraction();
|
await _refreshTraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openLegs(LocoSummary loco) async {
|
||||||
|
final label = '${loco.locoClass} ${loco.number}'.trim();
|
||||||
|
await context.push(
|
||||||
|
'/traction/${loco.id}/legs',
|
||||||
|
extra: {'label': label},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFilterInput(
|
Widget _buildFilterInput(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
EventField field,
|
EventField field,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class TractionCard extends StatelessWidget {
|
|||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
required this.onShowInfo,
|
required this.onShowInfo,
|
||||||
required this.onOpenTimeline,
|
required this.onOpenTimeline,
|
||||||
|
this.onOpenLegs,
|
||||||
this.onToggleSelect,
|
this.onToggleSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ class TractionCard extends StatelessWidget {
|
|||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onShowInfo;
|
final VoidCallback onShowInfo;
|
||||||
final VoidCallback onOpenTimeline;
|
final VoidCallback onOpenTimeline;
|
||||||
|
final VoidCallback? onOpenLegs;
|
||||||
final VoidCallback? onToggleSelect;
|
final VoidCallback? onToggleSelect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -93,6 +95,14 @@ class TractionCard extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.timeline),
|
icon: const Icon(Icons.timeline),
|
||||||
label: const Text('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(),
|
const Spacer(),
|
||||||
if (selectionMode && onToggleSelect != null)
|
if (selectionMode && onToggleSelect != null)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
import 'package:mileograph_flutter/components/pages/calculator.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/calculator_details.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/loco_timeline.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_traction.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(
|
GoRoute(
|
||||||
path: '/traction/new',
|
path: '/traction/new',
|
||||||
builder: (context, state) => const NewTractionPage(),
|
builder: (context, state) => const NewTractionPage(),
|
||||||
|
|||||||
@@ -177,6 +177,38 @@ class DataService extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Leg>> 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>()
|
||||||
|
.map((e) => Leg.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
debugPrint('Unexpected loco legs response: $json');
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch loco legs for $locoId: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {
|
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {
|
||||||
await fetchTraction(
|
await fetchTraction(
|
||||||
hadOnly: true,
|
hadOnly: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user