part of 'traction.dart'; class TractionPage extends StatefulWidget { const TractionPage({ super.key, this.selectionMode = false, this.onSelect, this.selectedKeys = const {}, }); final bool selectionMode; 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; bool _showAdvancedFilters = false; String? _selectedClass; late Set _selectedKeys; String? _lastEventFieldsSignature; Timer? _classStatsDebounce; bool _showClassStatsPanel = false; bool _classStatsLoading = false; String? _classStatsError; String? _classStatsForClass; Map? _classStats; final Map _dynamicControllers = {}; final Map _enumSelections = {}; bool _restoredFromPrefs = false; @override void initState() { super.initState(); _classController.addListener(_onClassTextChanged); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _selectedKeys = {...widget.selectedKeys}; WidgetsBinding.instance.addPostFrameCallback((_) { _initialLoad(); }); } } Future _initialLoad() async { final data = context.read(); await _restoreSearchState(); data.fetchClassList(); data.fetchEventFields(); 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; } Future _refreshTraction({bool append = false}) async { 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; await data.fetchTraction( hadOnly: hadOnly, locoClass: _selectedClass ?? _classController.text.trim(), locoNumber: _numberController.text.trim(), offset: append ? data.traction.length : 0, append: append, filters: filters, mileageFirst: _mileageFirst, ); await _persistSearchState(); } 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; }); _refreshTraction(); } void _onClassTextChanged() { if (_selectedClass != null && _classController.text.trim() != (_selectedClass ?? '')) { setState(() { _selectedClass = null; }); } _refreshClassStatsIfOpen(); } 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 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, ), ], ), ), Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Refresh', onPressed: _refreshTraction, icon: const Icon(Icons.refresh), ), if (_hasClassQuery) ...[ const SizedBox(width: 8), FilledButton.tonalIcon( onPressed: _toggleClassStatsPanel, icon: Icon( _showClassStatsPanel ? Icons.bar_chart : Icons.insights, ), label: Text( _showClassStatsPanel ? 'Hide class stats' : 'Class stats', ), ), ], const SizedBox(width: 8), FilledButton.icon( onPressed: () async { final createdClass = await context.push( '/traction/new', ); if (createdClass != null && createdClass.isNotEmpty) { _classController.text = createdClass; _selectedClass = createdClass; if (mounted) { _refreshTraction(); } } else if (mounted && createdClass == '') { _refreshTraction(); } }, icon: const Icon(Icons.add), label: const Text('New Traction'), ), ], ), ], ), 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); }, ), ), 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: const Icon(Icons.search), label: const Text('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, 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; } 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; 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 mi / loco had', avgMileagePerLoco.toStringAsFixed(2)), _metricTile('Avg mi / entry', avgMileagePerEntry.toStringAsFixed(2)), _metricTile('Total mileage', totalMileage.toStringAsFixed(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; } 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); } setState(() { if (_selectedKeys.contains(keyVal)) { _selectedKeys.remove(keyVal); } else { _selectedKeys.add(keyVal); } }); } 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 _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), onOpenTimeline: () => _openTimeline(loco), onOpenLegs: () => _openLegs(loco), onToggleSelect: widget.selectionMode ? () => _toggleSelection(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, ), ); } }