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/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: (_) { final matches = _findTopMatches(textEditingController.text); final firstMatch = matches.isEmpty ? null : matches.first; if (firstMatch == null) return; _controller.text = firstMatch; widget.onChanged(firstMatch); focusNode.unfocus(); // optionally close keyboard }, 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; 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 { setState(() { _errorMessage = null; _routeResult = null; }); final api = context.read(); // context is valid here try { final res = await api.post('/route/distance2', { 'route': stations.where((s) => s.trim().isNotEmpty).toList(), }); if (res is Map && res['error'] == false) { setState(() { _routeResult = RouteResult.fromJson(Map.from(res)); }); 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.'); } } catch (e) { setState(() => _errorMessage = 'Failed to calculate route: $e'); } } void _addStation() { final data = context.read(); setState(() { data.stations.add(''); }); } void _removeStation(int index) { final data = context.read(); setState(() { data.stations.removeAt(index); }); } void _updateStation(int index, String value) { final data = context.read(); setState(() { data.stations[index] = value; }); } @override Widget build(BuildContext context) { final data = context.watch(); return Column( children: [ 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(); }, ), 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); }); }, 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); }, ), if (widget.onApplyRoute != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: ElevatedButton.icon( onPressed: () => widget.onApplyRoute!(_routeResult!), icon: const Icon(Icons.check), label: const Text('Apply to entry'), ), ), ] else SizedBox.shrink(), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Wrap( alignment: WrapAlignment.center, spacing: 12, runSpacing: 8, children: [ ElevatedButton.icon( icon: const Icon(Icons.add), label: const Text('Add Station'), onPressed: _addStation, ), ElevatedButton.icon( icon: const Icon(Icons.route), label: const Text('Calculate Route'), onPressed: () async { await _calculateRoute(data.stations); }, ), ], ), ), 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))), ], ), ); } class _MultiSelectFilter extends StatefulWidget { const _MultiSelectFilter({ required this.label, required this.options, required this.selected, required this.onChanged, }); final String label; final List options; final List selected; final ValueChanged> onChanged; @override State<_MultiSelectFilter> createState() => _MultiSelectFilterState(); } class _MultiSelectFilterState extends State<_MultiSelectFilter> { late List _tempSelected; String _query = ''; @override void initState() { super.initState(); _tempSelected = List.from(widget.selected); } @override void didUpdateWidget(covariant _MultiSelectFilter oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.selected != widget.selected) { _tempSelected = List.from(widget.selected); } } void _openPicker() async { _tempSelected = List.from(widget.selected); _query = ''; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) { return StatefulBuilder( builder: (ctx, setModalState) { final filtered = widget.options .where((opt) => _query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase())) .toList(); return SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Select ${widget.label.toLowerCase()}', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const Spacer(), TextButton( onPressed: () { setModalState(() { _tempSelected.clear(); }); Navigator.of(ctx).pop(); widget.onChanged(const []); }, child: const Text('Clear'), ), ], ), const SizedBox(height: 8), TextField( decoration: const InputDecoration( labelText: 'Search', border: OutlineInputBorder(), ), onChanged: (val) { setModalState(() { _query = val; }); }, ), const SizedBox(height: 12), SizedBox( height: 320, child: ListView.builder( itemCount: filtered.length, itemBuilder: (_, index) { final option = filtered[index]; final selected = _tempSelected.contains(option); return CheckboxListTile( value: selected, title: Text(option), onChanged: (val) { setModalState(() { if (val == true) { if (!_tempSelected.contains(option)) { _tempSelected.add(option); } } else { _tempSelected.removeWhere((e) => e == option); } }); widget.onChanged(List.from(_tempSelected.toSet())); }, ); }, ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: FilledButton.icon( onPressed: () { widget.onChanged(List.from(_tempSelected.toSet())); Navigator.of(ctx).pop(); }, icon: const Icon(Icons.check), label: const Text('Apply'), ), ), ], ), ), ); }, ); }, ); } @override Widget build(BuildContext context) { final hasSelection = widget.selected.isNotEmpty; final display = hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}'; return OutlinedButton.icon( onPressed: _openPicker, icon: const Icon(Icons.filter_alt), label: SizedBox( width: 180, child: Text( '${widget.label}: $display', overflow: TextOverflow.ellipsis, ), ), ); } }