import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.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 { static const String _prefsKey = 'traction_search_state_v1'; 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 = {}; 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(); } Future _restoreSearchState() async { if (widget.selectionMode) return; if (_restoredFromPrefs) return; _restoredFromPrefs = true; try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_prefsKey); if (raw == null || raw.trim().isEmpty) return; final decoded = jsonDecode(raw); if (decoded is! Map) return; final classText = decoded['classText']?.toString(); final numberText = decoded['number']?.toString(); final nameText = decoded['name']?.toString(); final selectedClass = decoded['selectedClass']?.toString(); final mileageFirst = decoded['mileageFirst']; final showAdvanced = decoded['showAdvancedFilters']; if (classText != null) _classController.text = classText; if (numberText != null) _numberController.text = numberText; if (nameText != null) _nameController.text = nameText; final dynamicValues = {}; final enumValues = {}; final dynamicRaw = decoded['dynamic']; if (dynamicRaw is Map) { for (final entry in dynamicRaw.entries) { final key = entry.key.toString(); final val = entry.value?.toString() ?? ''; dynamicValues[key] = val; } } final enumRaw = decoded['enum']; if (enumRaw is Map) { for (final entry in enumRaw.entries) { enumValues[entry.key.toString()] = entry.value?.toString(); } } for (final entry in dynamicValues.entries) { _dynamicControllers.putIfAbsent( entry.key, () => TextEditingController(text: entry.value), ); _dynamicControllers[entry.key]?.text = entry.value; } for (final entry in enumValues.entries) { _enumSelections[entry.key] = entry.value; } if (!mounted) return; setState(() { _selectedClass = (selectedClass != null && selectedClass.trim().isNotEmpty) ? selectedClass : null; if (mileageFirst is bool) _mileageFirst = mileageFirst; if (showAdvanced is bool) _showAdvancedFilters = showAdvanced; }); } catch (_) { // Ignore preference restore failures. } } Future _persistSearchState() async { if (widget.selectionMode) return; final payload = { 'classText': _classController.text, 'number': _numberController.text, 'name': _nameController.text, 'selectedClass': _selectedClass, 'mileageFirst': _mileageFirst, 'showAdvancedFilters': _showAdvancedFilters, 'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)), 'enum': _enumSelections, }; try { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKey, jsonEncode(payload)); } catch (_) { // Ignore persistence failures. } } @override void dispose() { _classController.removeListener(_onClassTextChanged); _persistSearchState(); _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, ); 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; }); _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), Stack( children: [ if (data.isTractionLoading && traction.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 32.0), child: Center(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) => TractionCard( loco: loco, selectionMode: widget.selectionMode, isSelected: _isSelected(loco), onShowInfo: () => showTractionDetails(context, loco), onOpenTimeline: () => _openTimeline(loco), onToggleSelect: widget.selectionMode ? () => _toggleSelection(loco) : null, ), ), 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 (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: 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; } 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(); } 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(), ), ); } }