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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
dc8e6f9d6e
commit
13da935f99
@@ -1,23 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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 '../../../domain/mission/default_drone_profile.dart';
|
||||
import '../../../domain/mission/route_geometry.dart';
|
||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||
import '../../providers/current_mission_provider.dart';
|
||||
import '../../providers/map_controller_provider.dart';
|
||||
import '../../widgets/bottom_stats_bar.dart';
|
||||
import '../../widgets/halo_menu.dart';
|
||||
import '../../widgets/mini_altitude_profile.dart';
|
||||
import '../../widgets/mission_map.dart';
|
||||
import '../../widgets/reticle_button.dart';
|
||||
import '../../widgets/top_nav_bar.dart';
|
||||
import '../../widgets/value_wheel.dart';
|
||||
import '../../widgets/waypoint_list_panel.dart';
|
||||
|
||||
@@ -39,7 +37,10 @@ class PlanScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
final _mapController = MapController();
|
||||
// Geteilter, app-lebenslanger Controller statt einer PlanScreen-eigenen
|
||||
// Instanz (siehe mapControllerProvider) - PlanScreen besitzt ihn nicht
|
||||
// mehr und darf ihn daher auch nicht disposen.
|
||||
late final MapController _mapController;
|
||||
StreamSubscription<MapEvent>? _mapEventSub;
|
||||
|
||||
_ReticleMode _mode = _ReticleMode.idle;
|
||||
@@ -64,13 +65,13 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = ref.read(mapControllerProvider);
|
||||
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapEventSub?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -78,7 +79,14 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
if (event is MapEventMoveEnd) {
|
||||
_handleMoveEnd();
|
||||
} else if (event is MapEventMove) {
|
||||
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
|
||||
if (event.source == MapEventSource.mapController) {
|
||||
// Programmatischer Sprung (Suche/Home/Fit in der Kopfleiste,
|
||||
// Zeile-antippen in der Wegpunktliste) - im Gegensatz zu einer
|
||||
// Gesten-Verschiebung ist die Position hier sofort final, es
|
||||
// kommt aber nie ein MoveEnd fuer diesen Fall. Direkt reagieren
|
||||
// statt darauf zu warten.
|
||||
if (_mode != _ReticleMode.editing) _handleMoveEnd();
|
||||
} else if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (_targetIndex < waypoints.length) {
|
||||
final center = _mapController.camera.center;
|
||||
@@ -245,78 +253,14 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6).
|
||||
/// Danach wird die Snap-Erkennung einmal manuell angestossen, da
|
||||
/// flutter_maps MapController.move() - anders als Leaflets panTo() - kein
|
||||
/// MoveEnd-Event feuert.
|
||||
/// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). Die
|
||||
/// Snap-Erkennung reagiert automatisch ueber _onMapEvent (source ==
|
||||
/// mapController), ein manueller Folgeaufruf ist nicht noetig.
|
||||
void _onCenterOnWaypoint(int index) {
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (index < 0 || index >= waypoints.length) return;
|
||||
final wp = waypoints[index];
|
||||
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
|
||||
_handleMoveEnd();
|
||||
}
|
||||
|
||||
/// Ortssuche per Nominatim-Geocoding (HTML-Demonstrator: doSearch()).
|
||||
/// Zentriert die Karte auf den ersten Treffer.
|
||||
Future<void> _onSearch(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.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': q,
|
||||
});
|
||||
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);
|
||||
_mapController.move(LatLng(lat, lon), 15);
|
||||
_handleMoveEnd();
|
||||
} 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;
|
||||
_mapController.move(LatLng(home.lat, home.lon), 16);
|
||||
_handleMoveEnd();
|
||||
}
|
||||
|
||||
/// 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)];
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: LatLngBounds.fromPoints(points),
|
||||
padding: const EdgeInsets.fromLTRB(50, 50, 50, 140),
|
||||
),
|
||||
);
|
||||
_handleMoveEnd();
|
||||
}
|
||||
|
||||
/// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als
|
||||
@@ -422,21 +366,6 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
// Direkt unterhalb der Plan/Fly-Kopfleiste (AppShell/TopModeBar),
|
||||
// dieselbe Hoehe wie der Start der Draeder (top:56) - dort ist
|
||||
// die Kopfleiste garantiert bereits zu Ende.
|
||||
top: 62,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: TopNavBar(
|
||||
onSearch: _onSearch,
|
||||
onHome: _onHomePressed,
|
||||
onFit: _onFitPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mode == _ReticleMode.editing && targetWaypoint != null)
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
|
||||
Reference in New Issue
Block a user