Files
dmc/app/lib/ui/widgets/map_search_controls.dart
T
Constantin LeueandClaude Sonnet 5 13da935f99 Merge search/home/fit controls into the Plan/Fly header pill
Suche, Home- und Fit-Button sassen bisher in einer eigenen Leiste unter
der Plan/Fly-Kopfleiste - ein reiner UI-Kompromiss, weil die Karte
(inkl. MapController) in PlanScreen lebte und von der aeusseren
Kopfleiste aus nicht erreichbar war. Der HTML-Demonstrator zeigt
Suche/Home/Fit dagegen als Mittelteil derselben Leiste wie die
Modus-Buttons (#searchWrap/#homeBtn/#fitBtn in #topBarMiddle) - das war
also keine Stilfrage, sondern eine Datenfluss-Frage.

Loesung: MapController aus einem PlanScreen-privaten Feld in einen
app-lebenslangen mapControllerProvider (Riverpod) gehoben. Damit kann
die neue MapSearchControls (ersetzt TopNavBar) direkt in TopModeBar
eingebettet werden und lebt architektonisch dort, wo sie hingehoert:
bei der Kartensteuerung, nicht als Kind von PlanScreen.

Als Nebeneffekt - tatsaechlich der wichtigere Punkt - bleibt die
Kamera-Position/Zoom jetzt auch beim Wechsel Plan -> Fly -> Plan
erhalten. Verifiziert per flutter_map-Quellcode (FlutterMap haengt
einen extern uebergebenen Controller beim Neu-Mounten nicht an und
disposed ihn nicht) sowie per neuem Widget-Test, der den Cubit direkt
durch das Ready-to-Fly-Gate schickt (der Fly-Modus ist ueber die UI
aktuell nicht erreichbar, da Upload/Verify noch nicht implementiert
ist) und die Kamera vor/nach dem Wechsel vergleicht.

Zusaetzlich: _onMapEvent unterscheidet jetzt per MapEventMove.source,
ob eine Kartenbewegung programmatisch (mapController, z.B. durch Suche/
Home/Fit) oder per Geste ausgeloest wurde, und triggert den
Snap-to-Edit-Handler entsprechend nur bei Gesten bzw. sofort bei
programmatischen Moves - das ersetzt den bisherigen Ansatz, an jeder
Aufrufstelle manuell _handleMoveEnd() zu rufen, der nicht mehr skaliert
sobald diese Aufrufstellen (wie jetzt) in einem anderen Widget liegen.

Verifiziert: flutter analyze (0 issues), flutter test (22/22, inkl.
neuem Kamera-Persistenz-Test), manuell auf Pixel_10a-Emulator (Release-
Build) - Suche/Home/Fit erscheinen fusioniert in der gruenen Pille,
Kartenverschiebung bleibt nach Wechsel in den (durch das Gate weiterhin
blockierten) Fly-Modus sichtbar unveraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 09:37:22 +02:00

166 lines
5.5 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import '../providers/current_mission_provider.dart';
import '../providers/map_controller_provider.dart';
/// Ortssuche (Nominatim) + Home-/Fit-Buttons, eingebettet als Mittelteil
/// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/
/// #fitBtn sitzen alle in derselben Leiste wie die Modus-Buttons).
///
/// Vollstaendig selbststaendig (kein Callback-Durchreichen von aussen):
/// greift direkt auf den geteilten mapControllerProvider und
/// currentMissionProvider zu, da diese Aktionen konzeptionell zur
/// Kartensteuerung gehoeren, nicht zum Elternwidget.
class MapSearchControls extends ConsumerStatefulWidget {
const MapSearchControls({super.key});
@override
ConsumerState<MapSearchControls> createState() => _MapSearchControlsState();
}
class _MapSearchControlsState extends ConsumerState<MapSearchControls> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submitSearch() async {
final query = _controller.text.trim();
FocusScope.of(context).unfocus();
if (query.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
'format': 'json',
'addressdetails': '1',
'accept-language': 'en',
'limit': '1',
'q': query,
});
final response = await http
.get(uri, headers: {'User-Agent': 'dmc_app (Drone Mission Control)'});
final results = jsonDecode(response.body) as List;
if (results.isEmpty) {
messenger.showSnackBar(const SnackBar(content: Text('Location not found')));
return;
}
final first = results.first as Map<String, dynamic>;
final lat = double.parse(first['lat'] as String);
final lon = double.parse(first['lon'] as String);
ref.read(mapControllerProvider).move(LatLng(lat, lon), 15);
} catch (_) {
messenger.showSnackBar(const SnackBar(content: Text('Search failed')));
}
}
/// Zentriert auf den ersten Wegpunkt (HTML-Demonstrator: homeBtn).
void _onHomePressed() {
final waypoints = ref.read(currentMissionProvider);
if (waypoints.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('No waypoints yet')));
return;
}
final home = waypoints.first;
ref.read(mapControllerProvider).move(LatLng(home.lat, home.lon), 16);
}
/// Zoomt auf die gesamte Mission (HTML-Demonstrator: fitToWaypoints()).
void _onFitPressed() {
final waypoints = ref.read(currentMissionProvider);
if (waypoints.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('No waypoints to fit')));
return;
}
final points = [for (final w in waypoints) LatLng(w.lat, w.lon)];
ref.read(mapControllerProvider).fitCamera(
CameraFit.bounds(
bounds: LatLngBounds.fromPoints(points),
padding: const EdgeInsets.fromLTRB(50, 50, 50, 140),
),
);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Container(
height: 34,
padding: const EdgeInsets.only(left: 12, right: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(17),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
onSubmitted: (_) => _submitSearch(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Search location',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
),
),
),
),
IconButton(
onPressed: _submitSearch,
icon: const Icon(Icons.search, color: Colors.white, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
),
],
),
),
),
const SizedBox(width: 8),
_NavIconButton(icon: Icons.home, onTap: _onHomePressed),
const SizedBox(width: 8),
_NavIconButton(icon: Icons.crop_free, onTap: _onFitPressed),
],
);
}
}
class _NavIconButton extends StatelessWidget {
const _NavIconButton({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.55),
),
child: Icon(icon, color: Colors.white, size: 16),
),
);
}
}