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:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class LegCard extends StatefulWidget { const LegCard({ super.key, required this.leg, this.showEditButton = true, this.showDate = true, }); final Leg leg; final bool showEditButton; final bool showDate; @override State createState() => _LegCardState(); } class _LegCardState extends State { bool _expanded = false; @override Widget build(BuildContext context) { final leg = widget.leg; final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty; final sharedFrom = leg.sharedFrom; final sharedTo = leg.sharedTo; final distanceUnits = context.watch(); final routeSegments = _parseRouteSegments(leg.route); final textTheme = Theme.of(context).textTheme; return Card( clipBehavior: Clip.antiAlias, elevation: 1, child: Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( onExpansionChanged: (v) => setState(() => _expanded = v), tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), leading: const Icon(Icons.train), title: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; final beginTimeWidget = _timeWithDelay( context, leg.beginTime, leg.beginDelayMinutes, includeDate: widget.showDate, ); final endTimeWidget = leg.endTime == null ? null : _timeWithDelay( context, leg.endTime!, leg.endDelayMinutes, includeDate: widget.showDate, ); final routeText = Text( '${leg.start} → ${leg.end}', softWrap: true, ); if (!isWide) { final timeStyle = Theme.of(context).textTheme.labelSmall; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ routeText, const SizedBox(height: 2), Wrap( spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ _timeWithDelay( context, leg.beginTime, leg.beginDelayMinutes, includeDate: widget.showDate, style: timeStyle, ), if (endTimeWidget != null) ...[ const Text('·'), _timeWithDelay( context, leg.endTime!, leg.endDelayMinutes, includeDate: widget.showDate, style: timeStyle, ), ], ], ), ], ); } return Wrap( spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ beginTimeWidget, const Text('·'), routeText, if (endTimeWidget != null) ...[ const Text('·'), endTimeWidget, ], ], ); }, ), subtitle: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; final tractionWrap = !_expanded && leg.locos.isNotEmpty ? Wrap( spacing: 8, runSpacing: 4, children: leg.locos.map((loco) { final iconColor = loco.powering ? Theme.of(context).colorScheme.primary : Theme.of(context).hintColor; final label = '${loco.locoClass} ${loco.number}'.trim(); return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.train, size: 14, color: iconColor), const SizedBox(width: 4), Text( label.isEmpty ? 'Loco ${loco.id}' : label, style: textTheme.labelSmall, ), ], ); }).toList(), ) : null; final children = []; if (isWide) { if (tractionWrap != null) { children.add(tractionWrap); } } else { if (tractionWrap != null) { children.add(tractionWrap); } } if (leg.headcode.isNotEmpty) { children.add( Text( 'Headcode: ${leg.headcode}', style: textTheme.labelSmall, ), ); } if (leg.network.isNotEmpty) { children.add( Text( leg.network, style: textTheme.labelSmall, ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: children, ); }, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( distanceUnits.format(leg.mileage, decimals: 1), 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 (isShared || sharedFrom != null || (sharedTo.isNotEmpty)) ...[ const SizedBox(width: 8), _SharedIcons(sharedFrom: sharedFrom, sharedTo: sharedTo, isShared: isShared), ], if (widget.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}'), ), if (_expanded) ...[ const SizedBox(width: 4), IconButton( tooltip: 'Delete entry', icon: const Icon(Icons.delete_outline), color: Theme.of(context).colorScheme.error, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: () => _confirmDelete(context, 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 (_hasTrainDetails(leg)) ...[ Text('Train', style: textTheme.titleSmall), const SizedBox(height: 6), ..._buildTrainDetails(leg, textTheme), const SizedBox(height: 12), ], if (routeSegments.isNotEmpty) ...[ Text('Route', style: textTheme.titleSmall), const SizedBox(height: 6), _buildRouteList(routeSegments), ], ], ), ), ], ), ), ); } Future _confirmDelete(BuildContext context, int legId) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Delete entry?'), content: const Text('Are you sure you want to delete this entry?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; if (!context.mounted) return; final data = context.read(); final messenger = ScaffoldMessenger.of(context); try { await data.api.delete('/legs/delete?leg_id=$legId'); await data.refreshLegs(); messenger.showSnackBar(const SnackBar(content: Text('Entry deleted'))); } catch (e) { messenger.showSnackBar( SnackBar(content: Text('Failed to delete entry: $e')), ); } } Widget _timeWithDelay( BuildContext context, DateTime time, int? delay, { bool includeDate = true, TextStyle? style, }) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; final delayMinutes = delay ?? 0; final delayText = delayMinutes == 0 ? null : '${delayMinutes > 0 ? '+' : ''}$delayMinutes'; final delayColor = delayMinutes == 0 ? null : (delayMinutes < 0 ? Colors.green : colorScheme.error); return Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatDateTime(time, includeDate: includeDate), style: style, ), if (delayText != null) ...[ const SizedBox(width: 4), Text( '$delayText m', style: (style ?? textTheme.labelSmall)?.copyWith(color: delayColor), ), ], ], ); } 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 _formatTime(DateTime date) { return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; } String _formatDateTime(DateTime date, {bool includeDate = true}) { final timeStr = _formatTime(date); if (!includeDate) return timeStr; final dateStr = _formatDate(date); return '$dateStr · $timeStr'; } List _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.surfaceContainerLow; return Chip( label: Text( '${loco.locoClass} ${loco.number}', style: labelStyle, ), avatar: Icon( Icons.directions_railway, size: 16, color: iconColor, ), backgroundColor: background, ); }, ) .toList(); } bool _hasTrainDetails(Leg leg) { return leg.headcode.isNotEmpty || leg.origin.isNotEmpty || leg.destination.isNotEmpty || leg.originTime != null || leg.destinationTime != null; } List _buildTrainDetails(Leg leg, TextTheme textTheme) { final widgets = []; if (leg.headcode.isNotEmpty) { widgets.add( Text( 'Headcode: ${leg.headcode}', style: textTheme.bodyMedium, ), ); } final originLine = _locationLine( 'Origin', leg.origin, leg.originTime, ); if (originLine != null) { widgets.add(Text(originLine, style: textTheme.bodyMedium)); } final destinationLine = _locationLine( 'Destination', leg.destination, leg.destinationTime, ); if (destinationLine != null) { widgets.add(Text(destinationLine, style: textTheme.bodyMedium)); } return widgets; } String? _locationLine(String label, String location, DateTime? time) { final parts = []; if (location.trim().isNotEmpty) parts.add(location.trim()); if (time != null) parts.add(_formatDateTime(time)); if (parts.isEmpty) return null; return '$label: ${parts.join(' · ')}'; } 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(List route) { return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList(); } } class _SharedIcons extends StatelessWidget { const _SharedIcons({ required this.sharedFrom, required this.sharedTo, required this.isShared, }); final LegShareMeta? sharedFrom; final List sharedTo; final bool isShared; @override Widget build(BuildContext context) { final icons = []; if (isShared || sharedFrom != null) { final fromName = sharedFrom?.sharedFromDisplay ?? ''; icons.add( Tooltip( message: fromName.isNotEmpty ? 'Shared from $fromName' : 'Shared entry', child: Icon( Icons.share, size: 18, color: Theme.of(context).colorScheme.primary, ), ), ); } if (sharedTo.isNotEmpty) { final names = sharedTo .map((s) => s.sharedToDisplay) .where((name) => name.isNotEmpty) .toList(); final tooltip = names.isEmpty ? 'Shared to others' : 'Shared to: ${names.join(', ')}'; icons.add( Tooltip( message: tooltip, child: Icon( Icons.group, size: 18, color: Theme.of(context).colorScheme.tertiary, ), ), ); } return Row( mainAxisSize: MainAxisSize.min, children: [ for (int i = 0; i < icons.length; i++) ...[ if (i > 0) const SizedBox(width: 6), icons[i], ], ], ); } }