part of 'traction.dart'; enum _TractionMoreAction { classStats, classLeaderboard, adminPending, adminPendingChanges, } class TractionPage extends StatefulWidget { const TractionPage({ super.key, this.selectionMode = false, this.selectionSingle = false, this.replacementPendingLocoId, this.transferFromLabel, this.transferFromLocoId, this.transferAllAllocations = false, this.onSelect, this.selectedKeys = const {}, }); final bool selectionMode; final bool selectionSingle; final int? replacementPendingLocoId; final String? transferFromLabel; final int? transferFromLocoId; final bool transferAllAllocations; final ValueChanged? onSelect; final Set selectedKeys; @override State createState() => _TractionPageState(); } class _TractionPageState extends State { final _classController = TextEditingController(); final _classFocusNode = FocusNode(); final _numberController = TextEditingController(); final _nameController = TextEditingController(); bool _mileageFirst = true; bool _initialised = false; int? get _transferFromLocoId => widget.transferFromLocoId; bool get _transferAllAllocations { if (widget.transferAllAllocations) return true; final param = GoRouterState.of(context).uri.queryParameters['transferAll']; return param?.toLowerCase() == 'true' || param == '1'; } bool _showAdvancedFilters = false; String? _selectedClass; late Set _selectedKeys; String? _lastEventFieldsSignature; Timer? _classStatsDebounce; bool _showClassStatsPanel = false; bool _classStatsLoading = false; String? _classStatsError; String? _classStatsForClass; Map? _classStats; bool _showClassLeaderboardPanel = false; bool _classLeaderboardLoading = false; String? _classLeaderboardError; String? _classLeaderboardForClass; String? _classFriendsLeaderboardForClass; List _classLeaderboard = []; List _classFriendsLeaderboard = []; _ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global; final Map _dynamicControllers = {}; final Map _enumSelections = {}; bool _restoredFromPrefs = false; static const int _pageSize = 100; int _lastTractionOffset = 0; String? _lastQuerySignature; String? _transferFromLabel; bool _isSearching = false; @override void initState() { super.initState(); _classController.addListener(_onClassTextChanged); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _selectedKeys = {...widget.selectedKeys}; _transferFromLabel = widget.transferFromLabel; WidgetsBinding.instance.addPostFrameCallback((_) { _initialLoad(); }); } } Future _initialLoad() async { final data = context.read(); final auth = context.read(); await _restoreSearchState(); if (_lastTractionOffset == 0 && data.traction.length > _pageSize) { _lastTractionOffset = data.traction.length - _pageSize; } data.fetchClassList(); data.fetchEventFields(); if (auth.isElevated) { unawaited(data.fetchPendingLocoCount()); } await _refreshTraction(); } @override void dispose() { _classController.removeListener(_onClassTextChanged); _persistSearchState(); _classController.dispose(); _classFocusNode.dispose(); _numberController.dispose(); _nameController.dispose(); for (final controller in _dynamicControllers.values) { controller.dispose(); } _classStatsDebounce?.cancel(); super.dispose(); } void _setState(VoidCallback fn) { if (!mounted) return; // ignore: invalid_use_of_protected_member setState(fn); } bool get _hasFilters { final dynamicFieldsUsed = _dynamicControllers.values.any( (controller) => controller.text.trim().isNotEmpty, ) || _enumSelections.values.any( (value) => (value ?? '').toString().trim().isNotEmpty, ); return [ _selectedClass, _classController.text, _numberController.text, _nameController.text, ].any((value) => (value ?? '').toString().trim().isNotEmpty) || dynamicFieldsUsed; } String _tractionQuerySignature( Map filters, bool hadOnly, ) { final sortedKeys = filters.keys.toList()..sort(); final filterSignature = sortedKeys .map((key) => '$key=${filters[key]}') .join('|'); final classQuery = (_selectedClass ?? _classController.text).trim(); return [ 'class=$classQuery', 'number=${_numberController.text.trim()}', 'name=${_nameController.text.trim()}', 'mileageFirst=$_mileageFirst', 'hadOnly=$hadOnly', 'filters=$filterSignature', ].join(';'); } Future _refreshTraction({ bool append = false, bool preservePosition = true, }) async { _setState(() => _isSearching = true); final data = context.read(); final filters = {}; final name = _nameController.text.trim(); if (name.isNotEmpty) filters['name'] = name; _dynamicControllers.forEach((key, controller) { final value = controller.text.trim(); if (value.isNotEmpty) filters[key] = value; }); _enumSelections.forEach((key, value) { if (value != null && value.toString().trim().isNotEmpty) { filters[key] = value; } }); final hadOnly = !_hasFilters; final signature = _tractionQuerySignature(filters, hadOnly); final queryChanged = _lastQuerySignature != null && signature != _lastQuerySignature; _lastQuerySignature = signature; if (queryChanged && !append) { _lastTractionOffset = 0; } final shouldPreservePosition = preservePosition && !append && !queryChanged && _lastTractionOffset > 0; int limit; int offset; if (append) { offset = data.traction.length; limit = _pageSize; _lastTractionOffset = offset; } else if (shouldPreservePosition) { offset = 0; limit = _pageSize + _lastTractionOffset; } else { offset = 0; limit = _pageSize; } await data.fetchTraction( hadOnly: hadOnly, locoClass: _selectedClass ?? _classController.text.trim(), locoNumber: _numberController.text.trim(), offset: offset, limit: limit, append: append, filters: filters, mileageFirst: _mileageFirst, ); if (!append && !shouldPreservePosition) { _lastTractionOffset = 0; } await _persistSearchState(); _setState(() => _isSearching = false); } void _clearFilters() { for (final controller in [ _classController, _numberController, _nameController, ]) { controller.clear(); } for (final controller in _dynamicControllers.values) { controller.clear(); } _enumSelections.clear(); setState(() { _selectedClass = null; _mileageFirst = true; _showClassStatsPanel = false; _classStats = null; _classStatsError = null; _classStatsForClass = null; _showClassLeaderboardPanel = false; _classLeaderboard = []; _classFriendsLeaderboard = []; _classLeaderboardError = null; _classLeaderboardForClass = null; _classFriendsLeaderboardForClass = null; _classLeaderboardScope = _ClassLeaderboardScope.global; }); _refreshTraction(); } void _onClassTextChanged() { if (_selectedClass != null && _classController.text.trim() != (_selectedClass ?? '')) { setState(() { _selectedClass = null; }); } _refreshClassStatsIfOpen(); _refreshClassLeaderboardIfOpen(); } List _activeEventFields(List fields) { return fields .where( (field) => ![ 'class', 'number', 'name', 'build date', 'build_date', ].contains(field.name.toLowerCase()), ) .toList(); } void _syncControllersForFields(List fields) { final signature = _eventFieldsSignature(fields); if (signature == _lastEventFieldsSignature) return; _lastEventFieldsSignature = signature; _ensureControllersForFields(fields); } String _eventFieldsSignature(List fields) { final active = _activeEventFields(fields); return active .map( (field) => [ field.name, field.type ?? '', if (field.enumValues != null) field.enumValues!.join('|'), ].join('::'), ) .join(';'); } void _ensureControllersForFields(List fields) { for (final field in fields) { if (field.enumValues != null) { _enumSelections.putIfAbsent(field.name, () => null); } else { _dynamicControllers.putIfAbsent( field.name, () => TextEditingController(), ); } } } @override Widget build(BuildContext context) { final data = context.watch(); final traction = data.traction; final classOptions = data.locoClasses; final isMobile = MediaQuery.of(context).size.width < 700; _syncControllersForFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields); final transferBanner = _buildTransferBanner(data); final slivers = [ SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), sliver: SliverList( delegate: SliverChildListDelegate( [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Fleet', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 2), Text( 'Traction', style: Theme.of(context).textTheme.headlineSmall, ), ], ), ), _buildHeaderActions(context, isMobile), ], ), if (transferBanner != null) ...[ const SizedBox(height: 8), transferBanner, ], const SizedBox(height: 12), Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Filters', style: Theme.of(context).textTheme.titleMedium, ), TextButton( onPressed: _clearFilters, child: const Text('Clear'), ), ], ), const SizedBox(height: 8), Wrap( spacing: 12, runSpacing: 12, children: [ SizedBox( width: isMobile ? double.infinity : 240, child: RawAutocomplete( textEditingController: _classController, focusNode: _classFocusNode, optionsBuilder: (TextEditingValue textEditingValue) { final query = textEditingValue.text.toLowerCase(); if (query.isEmpty) { return classOptions; } return classOptions.where( (c) => c.toLowerCase().contains(query), ); }, fieldViewBuilder: ( context, controller, focusNode, onFieldSubmitted, ) { return TextField( controller: controller, focusNode: focusNode, decoration: const InputDecoration( labelText: 'Class', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ); }, optionsViewBuilder: (context, onSelected, options) { final optionList = options.toList(); if (optionList.isEmpty) { return const SizedBox.shrink(); } final maxWidth = isMobile ? MediaQuery.of(context).size.width - 64 : 240.0; return Align( alignment: Alignment.topLeft, child: Material( elevation: 4, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: maxWidth, maxHeight: 240, ), child: ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, itemCount: optionList.length, itemBuilder: (context, index) { final option = optionList[index]; return ListTile( title: Text(option), onTap: () => onSelected(option), ); }, ), ), ), ); }, onSelected: (String selection) { setState(() { _selectedClass = selection; _classController.text = selection; }); _refreshTraction(); _refreshClassStatsIfOpen(immediate: true); _refreshClassLeaderboardIfOpen(immediate: true); }, ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _numberController, decoration: const InputDecoration( labelText: 'Number', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _nameController, decoration: const InputDecoration( labelText: 'Name', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), FilterChip( label: Text( _mileageFirst ? 'Mileage first' : 'Number order', ), selected: _mileageFirst, onSelected: (v) { setState(() => _mileageFirst = v); _refreshTraction(); }, ), TextButton.icon( onPressed: () => setState( () => _showAdvancedFilters = !_showAdvancedFilters, ), icon: Icon( _showAdvancedFilters ? Icons.expand_less : Icons.expand_more, ), label: Text( _showAdvancedFilters ? 'Hide filters' : 'More filters', ), ), ElevatedButton.icon( onPressed: _refreshTraction, icon: (_isSearching || data.isTractionLoading) ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Theme.of(context) .colorScheme .onPrimary, ), ), ) : const Icon(Icons.search), label: Text( (_isSearching || data.isTractionLoading) ? 'Searching...' : 'Search', ), ), ], ), AnimatedCrossFade( crossFadeState: _showAdvancedFilters ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), firstChild: Padding( padding: const EdgeInsets.only(top: 12.0), child: data.isEventFieldsLoading ? const Center( child: Padding( padding: EdgeInsets.all(8.0), child: CircularProgressIndicator( strokeWidth: 2, ), ), ) : extraFields.isEmpty ? const Text('No extra filters available right now.') : Wrap( spacing: 12, runSpacing: 12, children: extraFields .map( (field) => _buildFilterInput( context, field, isMobile, ), ) .toList(), ), ), secondChild: const SizedBox.shrink(), ), ], ), ), ), const SizedBox(height: 12), ], ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), sliver: SliverToBoxAdapter( child: AnimatedCrossFade( crossFadeState: (_showClassStatsPanel && _hasClassQuery) ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), firstChild: _buildClassStatsCard(context), secondChild: const SizedBox.shrink(), ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), sliver: SliverToBoxAdapter( child: AnimatedCrossFade( crossFadeState: (_showClassLeaderboardPanel && _hasClassQuery) ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), firstChild: _buildClassLeaderboardCard(context), secondChild: const SizedBox.shrink(), ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), sliver: _buildTractionSliver(context, data, traction), ), ]; final scrollView = RefreshIndicator( onRefresh: _refreshTraction, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: slivers, ), ); final content = Stack( children: [ scrollView, if (data.isTractionLoading) Positioned.fill( child: IgnorePointer( child: Container( color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), child: const Center(child: CircularProgressIndicator()), ), ), ), ], ); if (widget.selectionMode) { return Scaffold( appBar: AppBar( leadingWidth: 56, leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), title: null, ), body: content, ); } return content; } bool get _hasClassQuery { return (_selectedClass ?? _classController.text).trim().isNotEmpty; } Widget _buildHeaderActions(BuildContext context, bool isMobile) { final isElevated = context.read().isElevated; final data = context.watch(); final pendingCount = data.pendingLocoCount; String? pendingLabel; if (pendingCount > 0) { pendingLabel = pendingCount > 999 ? '999+' : pendingCount.toString(); } final refreshButton = IconButton( tooltip: 'Refresh', onPressed: _refreshTraction, icon: const Icon(Icons.refresh), ); final hasClassActions = _hasClassQuery; final newTractionButton = FilledButton.icon( onPressed: () async { final createdClass = await context.push( '/traction/new', ); if (!mounted) return; if (createdClass != null && createdClass.isNotEmpty) { _classController.text = createdClass; _selectedClass = createdClass; _refreshTraction(); } else if (createdClass == '') { _refreshTraction(); } }, icon: const Icon(Icons.add), label: const Text('New Traction'), ); final hasAdminActions = isElevated; final hasMoreMenu = hasClassActions || hasAdminActions; final moreButton = !hasMoreMenu ? null : PopupMenuButton<_TractionMoreAction>( tooltip: 'More options', onSelected: (action) async { switch (action) { case _TractionMoreAction.classStats: _toggleClassStatsPanel(); break; case _TractionMoreAction.classLeaderboard: _toggleClassLeaderboardPanel(); break; case _TractionMoreAction.adminPending: final messenger = ScaffoldMessenger.of(context); try { await context.push('/traction/pending'); if (!mounted) return; await data.fetchPendingLocoCount(); } catch (_) { if (!mounted) return; messenger.showSnackBar( const SnackBar( content: Text('Unable to open pending locos'), ), ); } break; case _TractionMoreAction.adminPendingChanges: final messenger = ScaffoldMessenger.of(context); try { await context.push('/traction/changes'); } catch (_) { if (!mounted) return; messenger.showSnackBar( const SnackBar( content: Text('Unable to open pending changes'), ), ); } break; } }, itemBuilder: (context) { final items = >[]; if (hasClassActions) { items.add( PopupMenuItem( value: _TractionMoreAction.classStats, child: Row( children: [ Icon( _showClassStatsPanel ? Icons.check : Icons.check_box_outline_blank, size: 18, ), const SizedBox(width: 8), const Text('Class stats'), ], ), ), ); } if (hasClassActions) { items.add( PopupMenuItem( value: _TractionMoreAction.classLeaderboard, child: Row( children: [ Icon( _showClassLeaderboardPanel ? Icons.check : Icons.check_box_outline_blank, size: 18, ), const SizedBox(width: 8), const Text('Class leaderboard'), ], ), ), ); } if (items.isNotEmpty && hasAdminActions) { items.add(const PopupMenuDivider()); } if (hasAdminActions) { items.add( PopupMenuItem( value: _TractionMoreAction.adminPending, child: Row( children: [ const Text('Pending locos'), const Spacer(), if (pendingLabel != null) _countChip(context, pendingLabel), ], ), ), ); items.add( const PopupMenuItem( value: _TractionMoreAction.adminPendingChanges, child: Text('Pending changes'), ), ); } return items; }, child: IgnorePointer( child: FilledButton.tonalIcon( onPressed: () {}, icon: const Icon(Icons.more_horiz), label: Row( mainAxisSize: MainAxisSize.min, children: [ const Text('More'), if (pendingLabel != null) ...[ const SizedBox(width: 6), _countChip(context, pendingLabel), ], ], ), ), ), ); final desktopActions = [ refreshButton, newTractionButton, if (moreButton != null) moreButton, ]; final mobileActions = [ if (moreButton != null) moreButton, newTractionButton, refreshButton, ]; if (isMobile) { return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ for (var i = 0; i < mobileActions.length; i++) ...[ if (i > 0) const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: mobileActions[i], ), ], ], ); } return Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: desktopActions, ); } Future _toggleClassStatsPanel() async { if (!_hasClassQuery) return; final targetState = !_showClassStatsPanel; setState(() { _showClassStatsPanel = targetState; }); if (targetState) { await _loadClassStats(); } } void _refreshClassStatsIfOpen({bool immediate = false}) { if (!_showClassStatsPanel || !_hasClassQuery) return; final query = (_selectedClass ?? _classController.text).trim(); if (!immediate && _classStatsForClass == query && _classStats != null) { return; } _classStatsDebounce?.cancel(); if (immediate) { _loadClassStats(); return; } _classStatsDebounce = Timer( const Duration(milliseconds: 400), () { if (mounted) _loadClassStats(); }, ); } Future _loadClassStats() async { final query = (_selectedClass ?? _classController.text).trim(); if (query.isEmpty) return; if (_classStatsForClass == query && _classStats != null) return; setState(() { _classStatsLoading = true; _classStatsError = null; }); try { final data = context.read(); final stats = await data.fetchClassStats(query); if (!mounted) return; setState(() { _classStatsForClass = query; _classStats = stats; _classStatsError = stats == null ? 'No stats returned.' : null; }); } catch (e) { if (!mounted) return; setState(() { _classStatsError = 'Failed to load stats: $e'; }); } finally { if (mounted) { setState(() => _classStatsLoading = false); } } } Widget _buildClassStatsCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; final distanceUnits = context.watch(); if (_classStatsLoading) { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Row( children: const [ SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), Text('Loading class stats...'), ], ), ), ); } if (_classStatsError != null) { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Text( _classStatsError!, style: TextStyle(color: scheme.error), ), ), ); } final stats = _classStats; if (stats == null) { return const SizedBox.shrink(); } final totalMileage = (stats['total_mileage_with_class'] as num?)?.toDouble() ?? 0.0; final avgMileagePerEntry = (stats['avg_mileage_per_entry'] as num?)?.toDouble() ?? 0.0; final avgMileagePerLoco = (stats['avg_mileage_per_loco_had'] as num?)?.toDouble() ?? 0.0; final hadCount = stats['had_count']?.toString() ?? '0'; final entriesWithClass = stats['entries_with_class']?.toString() ?? '0'; final classStats = stats['class_stats'] is Map ? Map.from(stats['class_stats']) : const {}; final totalCount = (classStats['total'] as num?)?.toInt() ?? _sumCounts(classStats['status']) ?? 0; final statusList = _normalizeStatList(classStats['status'], 'status'); final domainList = _normalizeStatList(classStats['domain'], 'domain'); return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( stats['loco_class']?.toString() ?? 'Class stats', style: Theme.of(context).textTheme.titleMedium, ), ), TextButton.icon( onPressed: _loadClassStats, icon: const Icon(Icons.refresh), label: const Text('Refresh'), ), ], ), const SizedBox(height: 12), Wrap( spacing: 16, runSpacing: 8, children: [ _metricTile('Had', hadCount), _metricTile('Entries', entriesWithClass), _metricTile( 'Avg distance / loco had', distanceUnits.format( avgMileagePerLoco, decimals: 2, ), ), _metricTile( 'Avg distance / entry', distanceUnits.format( avgMileagePerEntry, decimals: 2, ), ), _metricTile( 'Total distance', distanceUnits.format( totalMileage, decimals: 2, ), ), ], ), const SizedBox(height: 12), if (statusList.isNotEmpty) _statBar( context, title: 'By status', items: statusList, total: totalCount, colorFor: (label) => _statusColor(label, scheme), ), if (domainList.isNotEmpty) ...[ const SizedBox(height: 10), _statBar( context, title: 'By domain', items: domainList, total: totalCount, colorFor: (label) => _domainColor(label, scheme), ), ], ], ), ), ); } Widget _metricTile(String label, String value) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.grey.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontSize: 12)), const SizedBox(height: 4), Text( value, style: const TextStyle(fontWeight: FontWeight.w700), ), ], ), ); } Widget _statBar( BuildContext context, { required String title, required List> items, required int total, required Color Function(String) colorFor, }) { if (total <= 0) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(8), child: Row( children: items.map((item) { final label = item['label']?.toString() ?? ''; final count = (item['count'] as num?)?.toInt() ?? 0; final pct = total == 0 ? 0.0 : (count / total) * 100; final flex = count == 0 ? 1 : (count * 1000 / total).round(); return Expanded( flex: flex, child: Tooltip( message: '$label: $count (${pct.isNaN ? 0 : pct.toStringAsFixed(1)}%)', child: Container( height: 16, color: colorFor(label), ), ), ); }).toList(), ), ), const SizedBox(height: 6), Wrap( spacing: 12, runSpacing: 6, children: items.map((item) { final label = item['label']?.toString() ?? ''; final count = (item['count'] as num?)?.toInt() ?? 0; final pct = total == 0 ? 0.0 : (count / total) * 100; return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( color: colorFor(label), borderRadius: BorderRadius.circular(2), ), ), Text('$label (${pct.isNaN ? 0 : pct.toStringAsFixed(1)}%, $count)'), ], ); }).toList(), ), ], ); } List> _normalizeStatList(dynamic list, String labelKey) { if (list is! List) return const []; return list .whereType() .map((item) => { 'label': item[labelKey]?.toString() ?? '', 'count': (item['count'] as num?)?.toInt() ?? 0, }) .where((item) => (item['label'] ?? '').toString().isNotEmpty) .toList(); } int? _sumCounts(dynamic list) { if (list is! List) return null; int total = 0; for (final item in list) { final count = (item is Map ? item['count'] : null) as num?; if (count != null) total += count.toInt(); } return total; } Widget _placementBadge(BuildContext context, int index) { const size = 32.0; const iconSize = 18.0; if (index == 0) { return CircleAvatar( radius: size / 2, backgroundColor: Colors.amber.shade400, child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize), ); } if (index == 1) { return CircleAvatar( radius: size / 2, backgroundColor: Colors.blueGrey.shade200, child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize), ); } if (index == 2) { return CircleAvatar( radius: size / 2, backgroundColor: Colors.brown.shade300, child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize), ); } return CircleAvatar( radius: size / 2, backgroundColor: Theme.of(context).colorScheme.secondaryContainer, child: Text( '${index + 1}', style: Theme.of(context).textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w800, ), ), ); } Color _statusColor(String status, ColorScheme scheme) { final key = status.toLowerCase(); if (key.contains('scrap')) return Colors.red.shade600; if (key.contains('active')) return scheme.primary; if (key.contains('overhaul')) return Colors.blueGrey; if (key.contains('withdrawn')) return Colors.amber.shade700; if (key.contains('stored')) return Colors.grey.shade600; return scheme.tertiary; } Color _domainColor(String domain, ColorScheme scheme) { final palette = [ scheme.primary, scheme.secondary, scheme.tertiary, Colors.teal, Colors.indigo, Colors.orange, Colors.pink, Colors.brown, ]; if (domain.isEmpty) return scheme.surfaceContainerHighest; final index = domain.hashCode.abs() % palette.length; return palette[index]; } void _toggleSelection(LocoSummary loco) { final keyVal = '${loco.locoClass}-${loco.number}'; if (widget.onSelect != null) { widget.onSelect!(loco); } if (widget.selectionMode && widget.selectionSingle) { if (mounted) { context.pop(loco); } return; } setState(() { if (_selectedKeys.contains(keyVal)) { _selectedKeys.remove(keyVal); } else { _selectedKeys.add(keyVal); } }); } Future _confirmReplacePending(LocoSummary replacement) async { final pendingId = widget.replacementPendingLocoId; if (pendingId == null) return; final navContext = context; final messenger = ScaffoldMessenger.of(navContext); 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 pending loco with ${replacement.locoClass} ${replacement.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 (confirmed != true) return; if (!navContext.mounted) return; try { final data = navContext.read(); await data.rejectPendingLoco( locoId: pendingId, replacementLocoId: replacement.id, rejectedReason: rejectionReason, ); if (navContext.mounted) { messenger.showSnackBar( const SnackBar(content: Text('Pending loco replaced')), ); navContext.pop(); } } catch (e) { if (navContext.mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to replace loco: $e')), ); } } } Future _confirmTransfer(LocoSummary target) async { final fromId = _transferFromLocoId; if (fromId == null) return; final navContext = context; final messenger = ScaffoldMessenger.of(navContext); final data = navContext.read(); final fromLoco = data.traction.firstWhere( (l) => l.id == fromId, orElse: () => target, ); final fromLabel = '${fromLoco.locoClass} ${fromLoco.number}'.trim(); final toLabel = '${target.locoClass} ${target.number}'.trim(); final confirmed = await showDialog( context: navContext, builder: (dialogContext) { return AlertDialog( title: Text( _transferAllAllocations ? 'Transfer all allocations?' : 'Transfer allocations?', ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _transferAllAllocations ? 'Transfer all user allocations from $fromLabel to $toLabel?' : 'Transfer all allocations from $fromLabel to $toLabel?', ), const SizedBox(height: 12), Text( 'From: $fromLabel', style: Theme.of(dialogContext) .textTheme .bodyMedium ?.copyWith(fontWeight: FontWeight.w700), ), Text('To: $toLabel'), ], ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Transfer'), ), ], ); }, ); if (confirmed != true) return; if (!navContext.mounted) return; try { final data = navContext.read(); if (_transferAllAllocations) { await data.transferAllAllocations( fromLocoId: fromId, toLocoId: target.id, ); } else { await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id); } if (navContext.mounted) { messenger.showSnackBar( SnackBar( content: Text( _transferAllAllocations ? 'All allocations transferred' : 'Allocations transferred', ), ), ); } await _refreshTraction(preservePosition: true); if (navContext.mounted) navContext.pop(); } catch (e) { if (navContext.mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to transfer allocations: $e')), ); } } } bool _isSelected(LocoSummary loco) { final keyVal = '${loco.locoClass}-${loco.number}'; return _selectedKeys.contains(keyVal); } Future _openTimeline(LocoSummary loco) async { final label = '${loco.locoClass} ${loco.number}'.trim(); await context.push( '/traction/${loco.id}/timeline', extra: {'label': label}, ); if (!mounted) return; 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? _buildTransferBanner(DataService data) { final fromId = _transferFromLocoId; if (fromId == null) return null; if (_transferFromLabel == null || _transferFromLabel!.trim().isEmpty) { final from = data.traction.firstWhere( (loco) => loco.id == fromId, orElse: () => LocoSummary( locoId: fromId, locoType: '', locoNumber: '', locoName: '', locoClass: '', locoOperator: '', ), ); final fallbackLabel = '${from.locoClass} ${from.number}'.trim().isEmpty ? 'Loco $fromId' : '${from.locoClass} ${from.number}'.trim(); _transferFromLabel = fallbackLabel; } final label = _transferFromLabel ?? 'Loco $fromId'; return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.orange.withValues(alpha: 0.4)), ), child: Row( children: [ const Icon(Icons.swap_horiz, color: Colors.orange), const SizedBox(width: 8), Expanded( child: Text( _transferAllAllocations ? 'Transferring all allocations from $label. Select a loco to transfer to.' : 'Transferring allocations from $label. Select a loco to transfer to.', style: const TextStyle(fontWeight: FontWeight.w600), ), ), ], ), ); } Widget _countChip(BuildContext context, String label) { final scheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: scheme.primary, borderRadius: BorderRadius.circular(12), ), child: Text( label, style: TextStyle( color: scheme.onPrimary, fontWeight: FontWeight.w700, fontSize: 12, ), ), ); } Widget _buildFilterInput( BuildContext context, EventField field, bool isMobile, ) { final width = isMobile ? double.infinity : 220.0; if (field.enumValues != null && field.enumValues!.isNotEmpty) { final options = field.enumValues! .map((e) => e.toString()) .toSet() .toList(); final currentValue = _enumSelections[field.name]; final safeValue = options.contains(currentValue) ? currentValue : null; return SizedBox( width: width, child: DropdownButtonFormField( value: safeValue, decoration: InputDecoration( labelText: field.display, border: const OutlineInputBorder(), ), items: [ const DropdownMenuItem(value: null, child: Text('Any')), ...options.map( (value) => DropdownMenuItem(value: value, child: Text(value)), ), ], onChanged: (val) { setState(() { _enumSelections[field.name] = val; }); _refreshTraction(); }, ), ); } final controller = _dynamicControllers[field.name] ?? TextEditingController(); _dynamicControllers[field.name] = controller; TextInputType? inputType; if (field.type != null) { final type = field.type!.toLowerCase(); if (type.contains('int') || type.contains('num') || type.contains('double')) { inputType = const TextInputType.numberWithOptions(decimal: true); } } return SizedBox( width: width, child: TextField( controller: controller, keyboardType: inputType, decoration: InputDecoration( labelText: field.display, border: const OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ); } Widget _buildTractionSliver( BuildContext context, DataService data, List traction, ) { if (data.isTractionLoading && traction.isEmpty) { return const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 32.0), child: Center(child: CircularProgressIndicator()), ), ); } if (traction.isEmpty) { return SliverToBoxAdapter( child: Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'No traction found', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), const Text('Try relaxing the filters or sync again.'), ], ), ), ), ); } final itemCount = traction.length + ((data.tractionHasMore || data.isTractionLoading) ? 1 : 0); return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index < traction.length) { final loco = traction[index]; return TractionCard( loco: loco, selectionMode: widget.selectionMode, isSelected: _isSelected(loco), onShowInfo: () => showTractionDetails( context, loco, onActionComplete: () => _refreshTraction(preservePosition: true), ), onOpenTimeline: () => _openTimeline(loco), onOpenLegs: () => _openLegs(loco), onActionComplete: _refreshTraction, onToggleSelect: widget.selectionMode && widget.replacementPendingLocoId == null && (widget.transferFromLocoId == null || widget.transferFromLocoId != loco.id) ? () => _toggleSelection(loco) : null, onReplacePending: widget.selectionMode && widget.selectionSingle && widget.replacementPendingLocoId != null && (widget.transferFromLocoId == null || widget.transferFromLocoId != loco.id) ? () => _confirmReplacePending(loco) : null, onTransferAllocations: widget.selectionMode && widget.selectionSingle && widget.transferFromLocoId != null && widget.transferFromLocoId != loco.id ? () => _confirmTransfer(loco) : null, ); } return Padding( padding: const EdgeInsets.only(top: 8.0), child: OutlinedButton.icon( onPressed: data.isTractionLoading ? null : () => _refreshTraction(append: true), icon: data.isTractionLoading ? const SizedBox( height: 14, width: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.expand_more), label: Text( data.isTractionLoading ? 'Loading...' : 'Load more', ), ), ); }, childCount: itemCount, ), ); } Future _toggleClassLeaderboardPanel() async { if (!_hasClassQuery) return; final targetState = !_showClassLeaderboardPanel; setState(() { _showClassLeaderboardPanel = targetState; }); if (targetState) { await _loadClassLeaderboard(friends: _classLeaderboardScope == _ClassLeaderboardScope.friends); } } void _refreshClassLeaderboardIfOpen({bool immediate = false}) { if (!_showClassLeaderboardPanel || !_hasClassQuery) return; final query = (_selectedClass ?? _classController.text).trim(); final scope = _classLeaderboardScope; final currentData = scope == _ClassLeaderboardScope.global ? _classLeaderboard : _classFriendsLeaderboard; final currentClass = scope == _ClassLeaderboardScope.global ? _classLeaderboardForClass : _classFriendsLeaderboardForClass; if (!immediate && currentClass == query && currentData.isNotEmpty) { return; } _loadClassLeaderboard( friends: scope == _ClassLeaderboardScope.friends, ); } Future _loadClassLeaderboard({required bool friends}) async { final query = (_selectedClass ?? _classController.text).trim(); if (query.isEmpty) return; final currentClass = friends ? _classFriendsLeaderboardForClass : _classLeaderboardForClass; final currentData = friends ? _classFriendsLeaderboard : _classLeaderboard; if (currentClass == query && currentData.isNotEmpty) return; setState(() { _classLeaderboardLoading = true; _classLeaderboardError = null; if (friends && _classFriendsLeaderboardForClass != query) { _classFriendsLeaderboard = []; } else if (!friends && _classLeaderboardForClass != query) { _classLeaderboard = []; } }); try { final data = context.read(); final leaderboard = await data.fetchClassLeaderboard( query, friends: friends, ); if (!mounted) return; setState(() { if (friends) { _classFriendsLeaderboard = leaderboard; _classFriendsLeaderboardForClass = query; } else { _classLeaderboard = leaderboard; _classLeaderboardForClass = query; } _classLeaderboardError = null; }); } catch (e) { if (!mounted) return; setState(() { _classLeaderboardError = 'Failed to load class leaderboard: $e'; }); } finally { if (mounted) { setState(() { _classLeaderboardLoading = false; }); } } } Widget _buildClassLeaderboardCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; final distanceUnits = context.watch(); final leaderboard = _classLeaderboardScope == _ClassLeaderboardScope.global ? _classLeaderboard : _classFriendsLeaderboard; final loading = _classLeaderboardLoading; final error = _classLeaderboardError; return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( (_selectedClass ?? _classController.text).trim().isEmpty ? 'Class leaderboard' : '${(_selectedClass ?? _classController.text).trim()} leaderboard', style: Theme.of(context).textTheme.titleMedium, ), ), SegmentedButton<_ClassLeaderboardScope>( segments: const [ ButtonSegment( value: _ClassLeaderboardScope.global, label: Text('Global'), ), ButtonSegment( value: _ClassLeaderboardScope.friends, label: Text('Friends'), ), ], selected: {_classLeaderboardScope}, onSelectionChanged: (vals) async { if (vals.isEmpty) return; final selected = vals.first; setState(() => _classLeaderboardScope = selected); if (selected == _ClassLeaderboardScope.friends && _classFriendsLeaderboard.isEmpty && !_classLeaderboardLoading) { await _loadClassLeaderboard(friends: true); } else if (selected == _ClassLeaderboardScope.global && _classLeaderboard.isEmpty && !_classLeaderboardLoading) { await _loadClassLeaderboard(friends: false); } }, style: SegmentedButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), ), ), const SizedBox(width: 8), IconButton( tooltip: 'Refresh leaderboard', icon: const Icon(Icons.refresh), onPressed: () => _loadClassLeaderboard( friends: _classLeaderboardScope == _ClassLeaderboardScope.friends, ), ), ], ), const SizedBox(height: 12), if (loading) Row( children: const [ SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), Text('Loading leaderboard...'), ], ) else if (error != null) Text( error, style: TextStyle(color: scheme.error), ) else if (leaderboard.isEmpty) const Text('No leaderboard data yet.') else Column( children: [ for (int i = 0; i < leaderboard.length; i++) ...[ ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: _placementBadge(context, i), title: Text( leaderboard[i].userFullName, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), trailing: Text( distanceUnits.format( leaderboard[i].mileage, decimals: 1, ), style: Theme.of(context).textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), ), onTap: () { final auth = context.read(); final userId = leaderboard[i].userId; if (auth.userId == userId) { context.go('/more/profile'); } else { context.pushNamed( 'user-profile', queryParameters: {'user_id': userId}, ); } }, ), if (i != leaderboard.length - 1) const Divider(height: 12), ], ], ), ], ), ), ); } } enum _ClassLeaderboardScope { global, friends }