import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.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; final Map _dynamicControllers = {}; final Map _enumSelections = {}; @override void initState() { super.initState(); _classController.addListener(_onClassTextChanged); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _selectedKeys = {...widget.selectedKeys}; WidgetsBinding.instance.addPostFrameCallback((_) { final data = context.read(); data.fetchClassList(); data.fetchEventFields(); _refreshTraction(); }); } } @override void dispose() { _classController.removeListener(_onClassTextChanged); _classController.dispose(); _classFocusNode.dispose(); _numberController.dispose(); _nameController.dispose(); for (final controller in _dynamicControllers.values) { controller.dispose(); } super.dispose(); } 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, ); } 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; }); _refreshTraction(); } void _onClassTextChanged() { if (_selectedClass != null && _classController.text.trim() != (_selectedClass ?? '')) { setState(() { _selectedClass = null; }); } } List _activeEventFields(List fields) { return fields .where( (field) => ![ 'class', 'number', 'name', 'build date', 'build_date', ].contains(field.name.toLowerCase()), ) .toList(); } 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; _ensureControllersForFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields); final listView = RefreshIndicator( onRefresh: _refreshTraction, child: ListView( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), children: [ 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), ), 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(); }, ), ), 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), if (data.isTractionLoading && traction.isEmpty) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: CircularProgressIndicator(), ), ) else if (traction.isEmpty) 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.'), ], ), ), ) else Column( children: [ ...traction.map((loco) => _buildTractionCard(context, loco)), if (data.tractionHasMore || data.isTractionLoading) 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', ), ), ), ], ), ], ), ); if (widget.selectionMode) { return Scaffold( appBar: AppBar( leadingWidth: 140, leading: Padding( padding: const EdgeInsets.only(left: 8.0), child: TextButton.icon( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.arrow_back), label: const Text('Back'), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), foregroundColor: Theme.of(context).colorScheme.onSurface, ), ), ), title: null, ), body: listView, ); } return listView; } Widget _buildTractionCard(BuildContext context, LocoSummary loco) { final keyVal = '${loco.locoClass}-${loco.number}'; final isSelected = _selectedKeys.contains(keyVal); final status = loco.status ?? 'Unknown'; final operatorName = loco.operator ?? ''; final domain = loco.domain ?? ''; final hasMileageOrTrips = _hasMileageOrTrips(loco); final statusColors = _statusChipColors(context, status); 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: [ Row( children: [ Text( loco.number, style: Theme.of(context).textTheme.headlineSmall ?.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, ), ), ], ), 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), ), ), ], ), Chip( label: Text(status), backgroundColor: statusColors.$1, labelStyle: TextStyle(color: statusColors.$2), ), ], ), const SizedBox(height: 8), Row( children: [ TextButton.icon( onPressed: () => _showLocoInfo(loco), icon: const Icon(Icons.info_outline), label: const Text('Details'), ), const SizedBox(width: 8), TextButton.icon( onPressed: () => _openTimeline(loco), icon: const Icon(Icons.timeline), label: const Text('Timeline'), ), const Spacer(), if (widget.selectionMode) TextButton.icon( onPressed: () { if (widget.onSelect != null) { widget.onSelect!(loco); } setState(() { if (isSelected) { _selectedKeys.remove(keyVal); } else { _selectedKeys.add(keyVal); } }); }, icon: Icon( isSelected ? Icons.remove_circle_outline : Icons.add_circle_outline, ), label: Text(isSelected ? 'Remove' : 'Add to entry'), ), ], ), Wrap( spacing: 8, runSpacing: 4, children: [ _statPill( context, label: 'Miles', value: _formatNumber(loco.mileage), ), _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), ), ], ), ); } (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.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity), scheme.surface, ); final fg = Color.alphaBlend( base.withOpacity(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.withOpacity(isDark ? 0.85 : 0.9); } else if (key.contains('active')) { background = blend(scheme.primary); foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8); } else if (key.contains('withdrawn')) { background = blend(Colors.amber); foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8); } else if (key.contains('stored') || key.contains('unknown')) { background = blend(Colors.grey); foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75); } else { background = scheme.surfaceContainerHighest; foreground = scheme.onSurface; } return (background, foreground); } void _openTimeline(LocoSummary loco) { final label = '${loco.locoClass} ${loco.number}'.trim(); context.push( '/traction/${loco.id}/timeline', extra: {'label': label}, ); } Future _showLocoInfo(LocoSummary loco) async { 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: [ Row( children: [ Text( loco.number, style: Theme.of(context).textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.w800), ), if (_hasMileageOrTrips(loco)) Padding( padding: const EdgeInsets.only(left: 6.0), child: Icon( Icons.check_circle, size: 18, color: Colors.green.shade600, ), ), ], ), 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: [ _detailRow('Status', loco.status ?? 'Unknown'), _detailRow('Operator', loco.operator ?? ''), _detailRow('Domain', loco.domain ?? ''), _detailRow('Owner', loco.owner ?? ''), _detailRow('Livery', loco.livery ?? ''), _detailRow('Location', loco.location ?? ''), _detailRow('Mileage', _formatNumber(loco.mileage ?? 0)), _detailRow( 'Trips', (loco.trips ?? loco.journeys ?? 0).toString(), ), _detailRow('EVN', loco.evn ?? ''), if (loco.notes != null && loco.notes!.isNotEmpty) _detailRow('Notes', loco.notes!), ], ), ), ], ), ); }, ); }, ); } Widget _detailRow(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), ), ], ), ); } String _formatNumber(double? value) { if (value == null) return '0'; return value.toStringAsFixed(1); } bool _hasMileageOrTrips(LocoSummary loco) { final mileage = loco.mileage ?? 0; final trips = loco.trips ?? loco.journeys ?? 0; return mileage > 0 || trips > 0; } 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(), ), ); } }