import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class TractionCard extends StatelessWidget { const TractionCard({ super.key, required this.loco, required this.selectionMode, required this.isSelected, required this.onShowInfo, required this.onOpenTimeline, this.onOpenLegs, this.onToggleSelect, this.onReplacePending, this.onActionComplete, this.onTransferAllocations, }); final LocoSummary loco; final bool selectionMode; final bool isSelected; final VoidCallback onShowInfo; final VoidCallback onOpenTimeline; final VoidCallback? onOpenLegs; final VoidCallback? onToggleSelect; final VoidCallback? onReplacePending; final Future Function()? onActionComplete; final VoidCallback? onTransferAllocations; @override Widget build(BuildContext context) { final status = loco.status ?? 'Unknown'; final operatorName = loco.operator ?? ''; final domain = loco.domain ?? ''; final isVisibilityPending = (loco.visibility ?? '').toLowerCase().trim() == 'pending'; final isRejected = (loco.visibility ?? '').toLowerCase().contains('reject'); final isElevated = context.read().isElevated; final hasMileageOrTrips = _hasMileageOrTrips(loco); final statusColors = _statusChipColors(context, status); final distanceUnits = context.watch(); return Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _LocoNumberWithHistory( number: loco.number, matchedNumber: loco.matchedNumber, matchedNumberValidTo: loco.matchedNumberValidTo, hasMileageOrTrips: hasMileageOrTrips, largeStyle: Theme.of(context).textTheme.headlineSmall, showPendingChip: isVisibilityPending, showRejectedChip: isRejected && !isVisibilityPending, ), Text( loco.locoClass, style: Theme.of(context).textTheme.labelMedium, ), if ((loco.name ?? '').isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2.0), child: Text( loco.name ?? '', style: Theme.of(context).textTheme.bodyMedium ?.copyWith(fontStyle: FontStyle.italic), ), ), ], ), Row( mainAxisSize: MainAxisSize.min, children: [ if (isElevated && isVisibilityPending) ...[ PopupMenuButton<_PendingLocoAction>( tooltip: 'Pending options', onSelected: (action) => _handlePendingAction( context, action, loco, onActionComplete: onActionComplete, ), itemBuilder: (context) => const [ PopupMenuItem( value: _PendingLocoAction.accept, child: Text('Accept loco'), ), PopupMenuItem( value: _PendingLocoAction.reject, child: Text('Reject loco'), ), PopupMenuItem( value: _PendingLocoAction.replace, child: Text('Replace...'), ), ], icon: const Icon(Icons.more_vert), ), const SizedBox(width: 6), ], Chip( label: Text(status), backgroundColor: statusColors.$1, labelStyle: TextStyle(color: statusColors.$2), ), ], ), ], ), const SizedBox(height: 8), LayoutBuilder( builder: (context, constraints) { final isNarrow = constraints.maxWidth < 520; final buttons = [ TextButton.icon( onPressed: onShowInfo, icon: const Icon(Icons.info_outline), label: const Text('Details'), ), TextButton.icon( onPressed: onOpenTimeline, icon: const Icon(Icons.timeline), label: const Text('Timeline'), ), if (hasMileageOrTrips && onOpenLegs != null) TextButton.icon( onPressed: onOpenLegs, icon: const Icon(Icons.view_list), label: const Text('Legs'), ), ]; // Prefer replace action when picking a replacement loco. final addButton = onTransferAllocations != null ? TextButton.icon( onPressed: onTransferAllocations, icon: const Icon(Icons.swap_horiz), label: const Text('Transfer'), ) : onReplacePending != null ? TextButton.icon( onPressed: onReplacePending, icon: const Icon(Icons.swap_horiz), label: const Text('Replace'), ) : (!isRejected && selectionMode && onToggleSelect != null) ? TextButton.icon( onPressed: onToggleSelect, icon: Icon( isSelected ? Icons.remove_circle_outline : Icons.add_circle_outline, ), label: Text(isSelected ? 'Remove' : 'Add to entry'), ) : null; if (isNarrow) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 4, children: buttons, ), if (addButton != null) ...[ const SizedBox(height: 6), addButton, ], ], ); } return Row( children: [ ...buttons.expand((btn) sync* { yield btn; yield const SizedBox(width: 8); }).take(buttons.length * 2 - 1), const Spacer(), if (addButton != null) addButton, ], ); }, ), Wrap( spacing: 8, runSpacing: 4, children: [ _statPill( context, label: 'Distance', value: distanceUnits.format( loco.mileage ?? 0, decimals: 1, ), ), _statPill( context, label: 'Trips', value: (loco.trips ?? loco.journeys ?? 0).toString(), ), if (operatorName.isNotEmpty) _statPill(context, label: 'Operator', value: operatorName), if (domain.isNotEmpty) _statPill(context, label: 'Domain', value: domain), ], ), ], ), ), ); } Widget _statPill( BuildContext context, { required String label, required String value, }) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('$label: ', style: Theme.of(context).textTheme.labelSmall), Text( value, style: Theme.of( context, ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), ), ], ), ); } } class _LocoNumberWithHistory extends StatelessWidget { const _LocoNumberWithHistory({ required this.number, required this.matchedNumber, required this.matchedNumberValidTo, required this.hasMileageOrTrips, this.largeStyle, this.showPendingChip = false, this.showRejectedChip = false, }); final String number; final String? matchedNumber; final DateTime? matchedNumberValidTo; final bool hasMileageOrTrips; final TextStyle? largeStyle; final bool showPendingChip; final bool showRejectedChip; @override Widget build(BuildContext context) { final theme = Theme.of(context); final historicNumber = matchedNumber?.trim() ?? ''; final hasHistoricDate = matchedNumberValidTo != null; final showHistoric = historicNumber.isNotEmpty && hasHistoricDate; final historicDate = hasHistoricDate ? DateFormat('yyyy-MM-dd').format(matchedNumberValidTo!) : null; return Row( children: [ Text( number, style: (largeStyle ?? theme.textTheme.titleLarge)?.copyWith( fontWeight: FontWeight.w800, ), ), if (hasMileageOrTrips) Padding( padding: const EdgeInsets.only(left: 6.0), child: Icon( Icons.check_circle, size: 18, color: Colors.green.shade600, ), ), if (showPendingChip) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.pending, size: 14), const SizedBox(width: 4), Text( 'Pending', style: theme.textTheme.labelSmall ?.copyWith(fontWeight: FontWeight.w700), ), ], ), ), ], if (showRejectedChip) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.red.shade700, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.close, size: 14, color: Colors.white), const SizedBox(width: 4), Text( 'Rejected', style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w700, color: Colors.white, ), ), ], ), ), ], if (showHistoric) ...[ const SizedBox(width: 8), Text( historicNumber, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w800, color: theme.colorScheme.onSurfaceVariant, ), ), if (historicDate != null) ...[ const SizedBox(width: 6), Text( 'until $historicDate', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ], ); } } enum _PendingLocoAction { accept, reject, replace } Future _handlePendingAction( BuildContext context, _PendingLocoAction action, LocoSummary loco, { Future Function()? onActionComplete, }) async { final navContext = context; final messenger = ScaffoldMessenger.of(navContext); final data = navContext.read(); if (action == _PendingLocoAction.replace) { final path = Uri( path: '/traction', queryParameters: { 'selection': 'single', 'replacementPendingLocoId': loco.id.toString(), }, ).toString(); final selected = await navContext.push( path, extra: { 'selection': 'single', 'replacementPendingLocoId': loco.id, }, ); if (!navContext.mounted) return; if (selected == null) return; String rejectionReason = ''; final confirmed = await showDialog( context: navContext, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { final canSubmit = rejectionReason.trim().isNotEmpty; return AlertDialog( title: const Text('Replace pending loco?'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Replace ${loco.locoClass} ${loco.number} with ${selected.locoClass} ${selected.number}?', ), const SizedBox(height: 12), TextField( autofocus: true, maxLines: 2, decoration: const InputDecoration( labelText: 'Rejection reason', hintText: 'Reason for replacing this loco', ), onChanged: (val) => setState(() { rejectionReason = val; }), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: canSubmit ? () => Navigator.of(dialogContext).pop(true) : null, child: const Text('Replace'), ), ], ); }, ); }, ); if (!navContext.mounted) return; if (confirmed != true) return; try { await data.rejectPendingLoco( locoId: loco.id, replacementLocoId: selected.id, rejectedReason: rejectionReason, ); await data.fetchClassList(force: true); if (navContext.mounted) { messenger.showSnackBar( const SnackBar(content: Text('Pending loco replaced')), ); } await onActionComplete?.call(); } catch (e) { messenger.showSnackBar( SnackBar(content: Text('Failed to replace loco: $e')), ); } return; } if (action == _PendingLocoAction.reject) { String rejectionReason = ''; final confirmed = await showDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { final canSubmit = rejectionReason.trim().isNotEmpty; return AlertDialog( title: const Text('Reject loco?'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( autofocus: true, maxLines: 2, decoration: const InputDecoration( labelText: 'Rejection reason', hintText: 'Why is this loco being rejected?', ), onChanged: (val) => setState(() { rejectionReason = val; }), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: canSubmit ? () => Navigator.of(context).pop(true) : null, child: const Text('Reject'), ), ], ); }, ); }, ); if (confirmed != true) return; if (!navContext.mounted) return; try { await data.rejectPendingLoco( locoId: loco.id, rejectedReason: rejectionReason, ); await data.fetchClassList(force: true); if (navContext.mounted) { messenger.showSnackBar( const SnackBar(content: Text('Pending loco rejected')), ); } await onActionComplete?.call(); } catch (e) { if (navContext.mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to reject loco: $e')), ); } } return; } try { await data.acceptPendingLoco(locoId: loco.id); if (navContext.mounted) { messenger.showSnackBar( const SnackBar(content: Text('Pending loco accepted')), ); } await onActionComplete?.call(); } catch (e) { if (navContext.mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to accept loco: $e')), ); } } } Future showTractionDetails( BuildContext context, LocoSummary loco, { Future Function()? onActionComplete, }) async { final navContext = context; final hasMileageOrTrips = _hasMileageOrTrips(loco); final isVisibilityPending = (loco.visibility ?? '').toLowerCase().trim() == 'pending'; final isRejected = (loco.visibility ?? '').toLowerCase().contains('reject'); final rejectedReason = loco.extra['rejected_reason']?.toString().trim() ?? ''; final distanceUnits = context.read(); final api = context.read(); final data = context.read(); final auth = context.read(); final messenger = ScaffoldMessenger.of(context); final userId = auth.userId; final createdBy = loco.extra['created_by']?.toString(); final isOwnedByUser = userId != null && createdBy != null && createdBy == userId; final canDeleteAsOwner = isOwnedByUser && (isVisibilityPending || isRejected); final leaderboardId = _leaderboardId(loco); final leaderboardFuture = leaderboardId == null ? Future.value(const []) : _fetchLocoLeaderboard(api, leaderboardId); await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) { return DraggableScrollableSheet( expand: false, maxChildSize: 0.9, initialChildSize: 0.65, builder: (_, controller) { return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(ctx).pop(), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _LocoNumberWithHistory( number: loco.number, matchedNumber: loco.matchedNumber, matchedNumberValidTo: loco.matchedNumberValidTo, hasMileageOrTrips: hasMileageOrTrips, showPendingChip: isVisibilityPending, showRejectedChip: isRejected && !isVisibilityPending, ), Text( loco.locoClass, style: Theme.of(context).textTheme.labelMedium, ), ], ), ], ), if ((loco.name ?? '').isNotEmpty) Padding( padding: const EdgeInsets.only(left: 52.0, bottom: 12), child: Text( loco.name ?? '', style: Theme.of(context).textTheme.bodyMedium, ), ), const SizedBox(height: 4), Expanded( child: ListView( controller: controller, children: [ if (isRejected && rejectedReason.isNotEmpty) ...[ _detailRow( context, 'Rejection reason', rejectedReason, ), const Divider(), ], _detailRow(context, 'Status', loco.status ?? 'Unknown'), _detailRow(context, 'Operator', loco.operator ?? ''), _detailRow(context, 'Domain', loco.domain ?? ''), _detailRow(context, 'Owner', loco.owner ?? ''), _detailRow(context, 'Livery', loco.livery ?? ''), _detailRow(context, 'Location', loco.location ?? ''), _detailRow( context, 'Mileage', distanceUnits.format( loco.mileage ?? 0, decimals: 1, ), ), _detailRow( context, 'Trips', (loco.trips ?? loco.journeys ?? 0).toString(), ), _detailRow(context, 'EVN', loco.evn ?? ''), if (loco.notes != null && loco.notes!.isNotEmpty) _detailRow(context, 'Notes', loco.notes!), const SizedBox(height: 16), Text( 'Leaderboard', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), FutureBuilder>( future: leaderboardFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12.0), child: Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } if (snapshot.hasError) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( 'Failed to load leaderboard', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ); } final entries = snapshot.data ?? const []; if (entries.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( 'No mileage leaderboard yet.', style: Theme.of(context).textTheme.bodyMedium, ), ); } return Column( children: entries.asMap().entries.map((entry) { final rank = entry.key + 1; return _leaderboardRow( context, rank, entry.value, distanceUnits, ); }).toList(), ); }, ), const SizedBox(height: 16), FilledButton.icon( onPressed: () { Navigator.of(ctx).pop(); final transferLabel = '${loco.locoClass} ${loco.number}'.trim(); navContext.push( Uri( path: '/traction', queryParameters: { 'selection': 'single', 'transferFromLocoId': loco.id.toString(), 'transferFromLabel': transferLabel, }, ).toString(), extra: { 'selection': 'single', 'transferFromLocoId': loco.id, 'transferFromLabel': transferLabel, }, ); }, icon: const Icon(Icons.swap_horiz), label: const Text('Transfer allocations'), ), if (auth.isElevated || canDeleteAsOwner) ...[ const SizedBox(height: 8), ExpansionTile( tilePadding: EdgeInsets.zero, childrenPadding: EdgeInsets.zero, title: Text( 'More', style: Theme.of(context).textTheme.titleSmall, ), children: [ FilledButton.tonal( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.errorContainer, foregroundColor: Theme.of(context).colorScheme.onErrorContainer, ), onPressed: () async { final confirmed = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Delete loco?'), content: const Text( 'This will permanently delete this loco. Are you sure?', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, ), child: const Text('Delete'), ), ], ); }, ); if (confirmed != true) return; try { await data.adminDeleteLoco(locoId: loco.id); messenger.showSnackBar( const SnackBar(content: Text('Loco deleted')), ); await onActionComplete?.call(); if (!context.mounted) return; Navigator.of(ctx).pop(); } catch (e) { messenger.showSnackBar( SnackBar( content: Text('Failed to delete loco: $e')), ); } }, child: const Text('Delete loco'), ), const SizedBox(height: 8), ], ), ], ], ), ), ], ), ); }, ); }, ); } Future> _fetchLocoLeaderboard( ApiService api, int locoId, ) async { try { final json = await api.get('/loco/leaderboard/id/$locoId'); Iterable? raw; if (json is List) { raw = json; } else if (json is Map) { for (final key in ['data', 'leaderboard', 'results']) { final value = json[key]; if (value is List) { raw = value; break; } } } if (raw == null) return const []; return raw.whereType().map((e) { return LeaderboardEntry.fromJson( e.map((key, value) => MapEntry(key.toString(), value)), ); }).toList(); } catch (e) { debugPrint('Failed to fetch loco leaderboard for $locoId: $e'); rethrow; } } int? _leaderboardId(LocoSummary loco) { int? parse(dynamic value) { if (value == null) return null; if (value is int) return value == 0 ? null : value; if (value is num) return value.toInt() == 0 ? null : value.toInt(); return int.tryParse(value.toString()); } return parse(loco.extra['loco_id']) ?? parse(loco.extra['id']) ?? parse(loco.id); } Widget _leaderboardRow( BuildContext context, int rank, LeaderboardEntry entry, DistanceUnitService distanceUnits, ) { final theme = Theme.of(context); final primaryName = entry.userFullName.isNotEmpty ? entry.userFullName : entry.username; final mileageLabel = distanceUnits.format(entry.mileage, decimals: 1); return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), child: Row( children: [ Container( width: 36, height: 36, alignment: Alignment.center, decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(10), ), child: Text( '#$rank', style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w700, color: theme.colorScheme.onPrimaryContainer, ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( primaryName, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), ), Text( mileageLabel, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), ); } Widget _detailRow(BuildContext context, String label, String value) { if (value.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), child: Row( children: [ SizedBox( width: 110, child: Text( label, style: Theme.of(context) .textTheme .labelMedium ?.copyWith(fontWeight: FontWeight.w600), ), ), Expanded( child: Text(value, style: Theme.of(context).textTheme.bodyMedium), ), ], ), ); } (Color, Color) _statusChipColors(BuildContext context, String status) { final scheme = Theme.of(context).colorScheme; final isDark = scheme.brightness == Brightness.dark; Color blend( Color base, { double bgOpacity = 0.18, double fgOpacity = 0.82, }) { final bg = Color.alphaBlend( base.withValues(alpha: isDark ? bgOpacity + 0.07 : bgOpacity), scheme.surface, ); final fg = Color.alphaBlend( base.withValues(alpha: isDark ? fgOpacity : fgOpacity * 0.8), scheme.onSurface, ); return Color.lerp(bg, fg, 0.0) ?? bg; } Color background; Color foreground; final key = status.toLowerCase(); if (key.contains('scrap')) { background = blend(Colors.red); foreground = Colors.red.shade200.withValues(alpha: isDark ? 0.85 : 0.9); } else if (key.contains('active')) { background = blend(scheme.primary); foreground = scheme.primary.withValues(alpha: isDark ? 0.9 : 0.8); } else if (key.contains('withdrawn')) { background = blend(Colors.amber); foreground = Colors.amber.shade800.withValues(alpha: isDark ? 0.9 : 0.8); } else if (key.contains('stored') || key.contains('unknown')) { background = blend(Colors.grey); foreground = Colors.grey.shade700.withValues(alpha: isDark ? 0.85 : 0.75); } else { background = scheme.surfaceContainerHighest; foreground = scheme.onSurface; } return (background, foreground); } bool _hasMileageOrTrips(LocoSummary loco) { final mileage = loco.mileage ?? 0; final trips = loco.trips ?? loco.journeys ?? 0; return mileage > 0 || trips > 0; }