import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import './route_summary_widget.dart'; class StationAutocomplete extends StatefulWidget { const StationAutocomplete({ super.key, required this.allStations, this.initialValue, required this.onChanged, }); final List allStations; final String? initialValue; final ValueChanged onChanged; @override State createState() => _StationAutocompleteState(); } class _StationAutocompleteState extends State { late final TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.initialValue ?? ''); _controller.addListener(() { widget.onChanged(_controller.text); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text.isEmpty) { return const Iterable.empty(); } return _findTopMatches(textEditingValue.text); }, onSelected: (String selection) { _controller.text = selection; widget.onChanged(selection); }, fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) { textEditingController.value = _controller.value; return TextField( controller: textEditingController, focusNode: focusNode, textInputAction: TextInputAction.done, onSubmitted: (_) => onFieldSubmitted(), decoration: const InputDecoration( labelText: 'Select station', border: OutlineInputBorder(), ), ); }, ); } Iterable _findTopMatches(String rawQuery) { final query = rawQuery.trim().toLowerCase(); if (query.isEmpty) return const []; // Keep a bounded, sorted list (by shortest name, then alpha) without // sorting the entire match set. final best = []; for (final station in widget.allStations) { final name = station.name; if (name.isEmpty) continue; if (!name.toLowerCase().contains(query)) continue; _insertCandidate(best, name, max: 10); } return best; } void _insertCandidate(List best, String candidate, {required int max}) { final existingIndex = best.indexOf(candidate); if (existingIndex >= 0) return; int insertAt = 0; while (insertAt < best.length && _candidateCompare(best[insertAt], candidate) <= 0) { insertAt++; } best.insert(insertAt, candidate); if (best.length > max) best.removeLast(); } int _candidateCompare(String a, String b) { final byLength = a.length.compareTo(b.length); if (byLength != 0) return byLength; return a.compareTo(b); } } class RouteCalculator extends StatefulWidget { const RouteCalculator({ super.key, this.onDistanceComputed, this.onApplyRoute, this.initialStations, }); final ValueChanged? onDistanceComputed; final ValueChanged? onApplyRoute; final List? initialStations; @override State createState() => _RouteCalculatorState(); } class _RouteCalculatorState extends State { List allStations = []; List _networks = []; List _countries = []; List _selectedNetworks = []; List _selectedCountries = []; bool _loadingStations = false; RouteResult? _routeResult; List? _calculatedStations; RouteResult? get result => _routeResult; String? _errorMessage; bool _fetched = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_fetched) { _fetched = true; if (widget.initialStations != null && widget.initialStations!.isNotEmpty) { context.read().stations = List.from(widget.initialStations!); } WidgetsBinding.instance.addPostFrameCallback((_) async { final data = context.read(); await data.fetchStationFilters(); if (!mounted) return; setState(() { _networks = data.stationNetworks; _countries = data.stationCountryNetworks.keys.toList(); }); await _loadStations(); }); } } Future _loadStations() async { setState(() => _loadingStations = true); final data = context.read(); final stations = await data.fetchStations( countries: _selectedCountries, networks: _selectedNetworks, ); if (!mounted) return; setState(() { allStations = stations; _loadingStations = false; }); } Future _calculateRoute(List stations) async { final cleaned = stations.where((s) => s.trim().isNotEmpty).toList(); if (cleaned.length < 2) { setState(() { _routeResult = null; _calculatedStations = null; _errorMessage = 'Add at least two stations before calculating.'; }); return; } setState(() { _errorMessage = null; _routeResult = null; _calculatedStations = null; }); final api = context.read(); // context is valid here try { final res = await api.post('/route/distance2', { 'route': cleaned, }); if (res is Map && res['error'] == false) { setState(() { _routeResult = RouteResult.fromJson(Map.from(res)); _calculatedStations = List.from(cleaned); }); final distance = (_routeResult?.distance ?? 0); widget.onDistanceComputed?.call(distance); } else if (res is Map && res['error_obj'] is List && res['error_obj'].isNotEmpty) { setState(() { _errorMessage = RouteError.fromJson( Map.from(res['error_obj'][0] as Map), ).msg; }); } else { setState(() { _errorMessage = 'Failed to calculate route.'; _calculatedStations = null; }); } } catch (e) { setState(() { _errorMessage = 'Failed to calculate route: $e'; _calculatedStations = null; }); } } void _markRouteDirty() { _routeResult = null; _calculatedStations = null; _errorMessage = null; } void _addStation() { final data = context.read(); setState(() { data.stations.add(''); _markRouteDirty(); }); } void _removeStation(int index) { final data = context.read(); setState(() { data.stations.removeAt(index); _markRouteDirty(); }); } void _updateStation(int index, String value) { final data = context.read(); setState(() { data.stations[index] = value; _markRouteDirty(); }); } void _clearCalculator() { final data = context.read(); setState(() { data.stations = ['']; _markRouteDirty(); }); } bool _isResultCurrent(List stations) { if (_routeResult == null || _calculatedStations == null) return false; final cleaned = stations.where((s) => s.trim().isNotEmpty).toList(); if (cleaned.length != _calculatedStations!.length) return false; for (var i = 0; i < cleaned.length; i++) { if (cleaned[i] != _calculatedStations![i]) return false; } return true; } @override Widget build(BuildContext context) { final data = context.watch(); final isCompact = MediaQuery.of(context).size.width < 600; final showApply = widget.onApplyRoute != null && _isResultCurrent(data.stations); final primaryPadding = EdgeInsets.symmetric( horizontal: isCompact ? 14 : 20, vertical: isCompact ? 10 : 14, ); final secondaryPadding = EdgeInsets.symmetric( horizontal: isCompact ? 10 : 16, vertical: isCompact ? 8 : 12, ); final primaryStyle = FilledButton.styleFrom( padding: primaryPadding, minimumSize: Size(0, isCompact ? 38 : 46), ); final secondaryStyle = OutlinedButton.styleFrom( padding: secondaryPadding, minimumSize: Size(0, isCompact ? 34 : 42), ); Widget buildSecondaryButton({ required IconData icon, required String label, required VoidCallback onPressed, }) { if (isCompact) { return Tooltip( message: label, child: OutlinedButton( onPressed: onPressed, style: secondaryStyle, child: Icon(icon, size: 20), ), ); } return OutlinedButton.icon( onPressed: onPressed, icon: Icon(icon, size: 20), label: Text(label), style: secondaryStyle, ); } Widget buildPrimaryAction({required bool fullWidth}) { final button = showApply ? FilledButton.icon( onPressed: () => widget.onApplyRoute!(_routeResult!), icon: const Icon(Icons.check), label: const Text('Apply to entry'), style: primaryStyle, ) : FilledButton.icon( onPressed: () async { await _calculateRoute(data.stations); }, icon: const Icon(Icons.route), label: const Text('Calculate Route'), style: primaryStyle, ); final key = ValueKey(showApply ? 'apply-primary-action' : 'calc-primary-action'); if (!fullWidth) return KeyedSubtree(key: key, child: button); return KeyedSubtree( key: key, child: SizedBox(width: double.infinity, child: button), ); } return Column( children: [ Align( alignment: Alignment.centerRight, child: IconButton( tooltip: 'Clear calculator', icon: const Icon(Icons.clear_all), onPressed: _clearCalculator, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ MultiSelectFilter( label: 'Countries', options: _countries, selected: _selectedCountries, onChanged: (vals) { setState(() => _selectedCountries = vals); _loadStations(); }, ), MultiSelectFilter( label: 'Networks', options: _networks, selected: _selectedNetworks, onChanged: (vals) { setState(() => _selectedNetworks = vals); _loadStations(); }, onRefresh: () async { final data = context.read(); await data.fetchStationFilters(); if (!mounted) return; setState(() { _networks = data.stationNetworks; _countries = data.stationCountryNetworks.keys.toList(); }); await _loadStations(); }, ), if (_loadingStations) const Padding( padding: EdgeInsets.only(left: 8.0), child: SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ], ), ), Expanded( child: ReorderableListView( buildDefaultDragHandles: false, padding: const EdgeInsets.symmetric(vertical: 8), onReorder: (oldIndex, newIndex) { if (newIndex > oldIndex) newIndex -= 1; setState(() { final moved = data.stations.removeAt(oldIndex); data.stations.insert(newIndex, moved); _markRouteDirty(); }); }, children: List.generate(data.stations.length, (index) { return Container( key: ValueKey('$index-${data.stations[index]}'), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ReorderableDragStartListener( index: index, child: const Padding( padding: EdgeInsets.only(top: 28), child: Icon(Icons.drag_indicator), ), ), const SizedBox(width: 8), Expanded( child: Card( child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ Expanded( child: StationAutocomplete( allStations: allStations, initialValue: data.stations[index], onChanged: (val) => _updateStation(index, val), ), ), IconButton( icon: const Icon(Icons.close), onPressed: () => _removeStation(index), ), ], ), ), ), ), ], ), ); }), ), ), if (_errorMessage != null) Padding( padding: const EdgeInsets.all(8.0), child: Text( _errorMessage!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ) else if (_routeResult != null) ...[ RouteSummaryWidget( distance: _routeResult!.distance, onDetailsPressed: () { final result = _routeResult; if (result == null) return; context.push('/calculator/details', extra: result); }, ), ] else SizedBox.shrink(), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: isCompact ? Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ buildSecondaryButton( icon: Icons.swap_horiz, label: 'Reverse route', onPressed: () async { setState(() { data.stations = data.stations.reversed.toList(); }); await _calculateRoute(data.stations); }, ), const SizedBox(width: 12), buildSecondaryButton( icon: Icons.add, label: 'Add station', onPressed: _addStation, ), ], ), const SizedBox(height: 10), SizedBox( width: double.infinity, child: AnimatedSwitcher( duration: const Duration(milliseconds: 220), transitionBuilder: (child, animation) { final curved = CurvedAnimation( parent: animation, curve: Curves.easeOutBack, ); return ScaleTransition( scale: Tween(begin: 0.94, end: 1.0).animate(curved), child: FadeTransition(opacity: animation, child: child), ); }, child: buildPrimaryAction(fullWidth: true), ), ), ], ) : Wrap( alignment: WrapAlignment.center, spacing: 12, runSpacing: 8, children: [ buildSecondaryButton( icon: Icons.swap_horiz, label: 'Reverse route', onPressed: () async { setState(() { data.stations = data.stations.reversed.toList(); }); await _calculateRoute(data.stations); }, ), AnimatedSwitcher( duration: const Duration(milliseconds: 220), transitionBuilder: (child, animation) { final curved = CurvedAnimation( parent: animation, curve: Curves.easeOutBack, ); return ScaleTransition( scale: Tween(begin: 0.94, end: 1.0).animate(curved), child: FadeTransition(opacity: animation, child: child), ); }, child: buildPrimaryAction(fullWidth: false), ), buildSecondaryButton( icon: Icons.add, label: 'Add station', onPressed: _addStation, ), ], ), ), const SizedBox(height: 16), ], ); } } Widget debugPanel(List stations) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ const Text( 'Current Order:', style: TextStyle(fontWeight: FontWeight.bold), ), ...stations.map((s) => Chip(label: Text(s.isEmpty ? '' : s))), ], ), ); }