Files
mileograph_flutter/lib/components/legs/leg_card.dart

216 lines
6.7 KiB
Dart

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,
this.showDate = true,
});
final Leg leg;
final bool showEditButton;
final bool showDate;
@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: [
if (showDate) 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);
final textTheme = theme.textTheme;
return leg.locos
.map(
(loco) {
final powering = loco.powering == true;
final iconColor =
powering ? theme.colorScheme.primary : theme.disabledColor;
final labelStyle = powering
? null
: textTheme.bodyMedium?.copyWith(color: theme.disabledColor);
final background = powering
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceVariant;
return Chip(
label: Text(
'${loco.locoClass} ${loco.number}',
style: labelStyle,
),
avatar: Icon(
Icons.directions_railway,
size: 16,
color: iconColor,
),
backgroundColor: background,
);
},
)
.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];
}
}