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 = []; 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(); final result = await data.fetchStations(); if (mounted) { setState(() => allStations = result); } }); } } 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: [ 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), LayoutBuilder( builder: (context, constraints) { double screenWidth = constraints.maxWidth; return Padding( padding: EdgeInsets.only(right: screenWidth < 450 ? 70 : 0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton.icon( icon: const Icon(Icons.add), label: const Text('Add Station'), onPressed: _addStation, ), const SizedBox(width: 16), 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))), ], ), ); }