import 'package:flutter/material.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 _numberController = TextEditingController(); bool _mileageFirst = true; bool _initialised = false; bool _showAdvancedFilters = false; String? _selectedClass; late Set _selectedKeys; final _nameController = TextEditingController(); final _operatorController = TextEditingController(); final _statusController = TextEditingController(); final _evnController = TextEditingController(); final _ownerController = TextEditingController(); final _locationController = TextEditingController(); final _liveryController = TextEditingController(); final _domainController = TextEditingController(); final _typeController = TextEditingController(); @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _selectedKeys = {...widget.selectedKeys}; WidgetsBinding.instance.addPostFrameCallback((_) { context.read().fetchClassList(); _refreshTraction(); }); } } @override void dispose() { _classController.dispose(); _numberController.dispose(); _nameController.dispose(); _operatorController.dispose(); _statusController.dispose(); _evnController.dispose(); _ownerController.dispose(); _locationController.dispose(); _liveryController.dispose(); _domainController.dispose(); _typeController.dispose(); super.dispose(); } bool get _hasFilters { return [ _selectedClass, _classController.text, _numberController.text, _nameController.text, _operatorController.text, _statusController.text, _evnController.text, _ownerController.text, _locationController.text, _liveryController.text, _domainController.text, _typeController.text, ].any((value) => (value ?? '').toString().trim().isNotEmpty); } Future _refreshTraction({bool append = false}) async { final data = context.read(); final filters = { "name": _nameController.text.trim(), "operator": _operatorController.text.trim(), "status": _statusController.text.trim(), "evn": _evnController.text.trim(), "owner": _ownerController.text.trim(), "location": _locationController.text.trim(), "livery": _liveryController.text.trim(), "domain": _domainController.text.trim(), "type": _typeController.text.trim(), }..removeWhere((key, value) => value.isEmpty); 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, _operatorController, _statusController, _evnController, _ownerController, _locationController, _liveryController, _domainController, _typeController, ]) { controller.clear(); } setState(() { _selectedClass = null; _mileageFirst = true; }); _refreshTraction(); } @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; final listView = RefreshIndicator( onRefresh: _refreshTraction, child: ListView( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ 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, ), ], ), IconButton( tooltip: 'Refresh', onPressed: _refreshTraction, icon: const Icon(Icons.refresh), ), ], ), 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: Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { final query = textEditingValue.text.toLowerCase(); if (query.isEmpty) { return classOptions; } return classOptions.where( (c) => c.toLowerCase().contains(query), ); }, initialValue: TextEditingValue( text: _classController.text, ), fieldViewBuilder: ( context, controller, focusNode, onFieldSubmitted, ) { controller.value = _classController.value; return TextField( controller: controller, focusNode: focusNode, decoration: const InputDecoration( labelText: 'Class', border: OutlineInputBorder(), ), onChanged: (val) { _classController.text = val; }, onSubmitted: (_) => _refreshTraction(), ); }, 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' : 'Had first', ), 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: Wrap( spacing: 12, runSpacing: 12, children: [ SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _operatorController, decoration: const InputDecoration( labelText: 'Operator', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _statusController, decoration: const InputDecoration( labelText: 'Status', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _evnController, decoration: const InputDecoration( labelText: 'EVN', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _ownerController, decoration: const InputDecoration( labelText: 'Owner', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _locationController, decoration: const InputDecoration( labelText: 'Location', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _liveryController, decoration: const InputDecoration( labelText: 'Livery', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _domainController, decoration: const InputDecoration( labelText: 'Domain', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), SizedBox( width: isMobile ? double.infinity : 220, child: TextField( controller: _typeController, decoration: const InputDecoration( labelText: 'Type', border: OutlineInputBorder(), ), onSubmitted: (_) => _refreshTraction(), ), ), ], ), ), 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 ?? ''; 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: [ Text( '${loco.locoClass} ${loco.number}', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), if ((loco.name ?? '').isNotEmpty) Text( loco.name ?? '', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontStyle: FontStyle.italic, ), ), ], ), Chip( label: Text(status), backgroundColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, ), ], ), const SizedBox(height: 8), Row( children: [ TextButton.icon( onPressed: () => _showLocoInfo(loco), icon: const Icon(Icons.info_outline), label: const Text('Details'), ), 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), ), ], ), ); } 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), Text( '${loco.locoClass} ${loco.number}', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), 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); } }