map follow on drone focus. rubberband pointing to next waypoint. drone icon bigger 40px

This commit is contained in:
Constantin Leue
2026-08-03 22:07:11 +02:00
parent 7b198e1755
commit 2f4353f3fa
8 changed files with 152 additions and 36 deletions
@@ -0,0 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Ob die Karte im Fly-Modus der Live-Telemetrieposition folgt (Doku 3.11).
/// Geteilter Zustand, weil der ausloesende GPS-Knopf (TopModeBar ->
/// FlyMapControls) und die Kamera-Nachfuehrung inkl. Abbruch bei manueller
/// Karteninteraktion (FlyScreen) unabhaengige Geschwister-Widgets in
/// AppShell sind, die sich sonst nicht erreichen koennten.
class FollowDroneNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
/// isAutoDispose: sobald weder der Knopf noch FlyScreen noch beobachten
/// (z.B. nach Verlassen des Fly-Modus), faellt der Zustand zurueck auf
/// false - ein erneuter Eintritt in den Fly-Modus startet also immer
/// unverfolgt (entspricht dem vorherigen Verhalten als FlyScreen-lokales
/// State-Feld, das beim Moduswechsel ohnehin verworfen wurde).
final followDroneProvider = NotifierProvider<FollowDroneNotifier, bool>(
FollowDroneNotifier.new,
isAutoDispose: true,
);
+85 -25
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -9,8 +10,10 @@ import '../../../domain/mission/mission_stats.dart';
import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/mission_warnings.dart';
import '../../../domain/mission/route_geometry.dart'; import '../../../domain/mission/route_geometry.dart';
import '../../../domain/wind/wind_math.dart'; import '../../../domain/wind/wind_math.dart';
import '../../../transport/flight_controller_link.dart';
import '../../providers/active_drone_profile_provider.dart'; import '../../providers/active_drone_profile_provider.dart';
import '../../providers/current_mission_provider.dart'; import '../../providers/current_mission_provider.dart';
import '../../providers/follow_drone_provider.dart';
import '../../providers/map_controller_provider.dart'; import '../../providers/map_controller_provider.dart';
import '../../providers/telemetry_provider.dart'; import '../../providers/telemetry_provider.dart';
import '../../providers/terrain_provider.dart'; import '../../providers/terrain_provider.dart';
@@ -28,22 +31,50 @@ import '../../widgets/warnings_panel.dart';
/// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen /// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen
/// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung), /// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung),
/// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier. /// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier.
class FlyScreen extends ConsumerWidget { class FlyScreen extends ConsumerStatefulWidget {
const FlyScreen({super.key}); const FlyScreen({super.key});
void _onCenterOnWaypoint(WidgetRef ref, int index) { @override
ConsumerState<FlyScreen> createState() => _FlyScreenState();
}
class _FlyScreenState extends ConsumerState<FlyScreen> {
late final MapController _mapController;
StreamSubscription<MapEvent>? _mapEventSub;
@override
void initState() {
super.initState();
_mapController = ref.read(mapControllerProvider);
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
}
@override
void dispose() {
_mapEventSub?.cancel();
super.dispose();
}
/// Jede manuelle Kartengeste (Pan/Pinch/Fling, ...) bricht den ueber den
/// GPS-Knopf in der Kopfleiste (FlyMapControls) aktivierten Folgemodus ab
/// - nur Bewegungen mit source == mapController sind programmatisch
/// (durch die Telemetrie-Nachfuehrung unten selbst oder andere
/// Kartensprmuenge ausgeloest) und sollen ihn nicht beenden.
void _onMapEvent(MapEvent event) {
if (ref.read(followDroneProvider) &&
event.source != MapEventSource.mapController) {
ref.read(followDroneProvider.notifier).set(false);
}
}
void _onCenterOnWaypoint(int index) {
final waypoints = ref.read(currentMissionProvider); final waypoints = ref.read(currentMissionProvider);
if (index < 0 || index >= waypoints.length) return; if (index < 0 || index >= waypoints.length) return;
final wp = waypoints[index]; final wp = waypoints[index];
final controller = ref.read(mapControllerProvider); _mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
controller.move(LatLng(wp.lat, wp.lon), controller.camera.zoom);
} }
void _openWaypointList( void _openWaypointList({PanelTab initialTab = PanelTab.list}) {
BuildContext context,
WidgetRef ref, {
PanelTab initialTab = PanelTab.list,
}) {
Navigator.of(context).push( Navigator.of(context).push(
PageRouteBuilder<void>( PageRouteBuilder<void>(
opaque: true, opaque: true,
@@ -57,7 +88,7 @@ class FlyScreen extends ConsumerWidget {
initialTab: initialTab, initialTab: initialTab,
onClose: () => Navigator.of(routeContext).pop(), onClose: () => Navigator.of(routeContext).pop(),
onCenterOnWaypoint: (index) { onCenterOnWaypoint: (index) {
_onCenterOnWaypoint(ref, index); _onCenterOnWaypoint(index);
Navigator.of(routeContext).pop(); Navigator.of(routeContext).pop();
}, },
onDelete: (index) => onDelete: (index) =>
@@ -79,11 +110,7 @@ class FlyScreen extends ConsumerWidget {
); );
} }
void _openWarningsPanel( void _openWarningsPanel(List<MissionWarning> warnings) {
BuildContext context,
WidgetRef ref,
List<MissionWarning> warnings,
) {
Navigator.of(context).push(MaterialPageRoute<void>( Navigator.of(context).push(MaterialPageRoute<void>(
builder: (routeContext) => WarningsPanel( builder: (routeContext) => WarningsPanel(
warnings: warnings, warnings: warnings,
@@ -92,9 +119,9 @@ class FlyScreen extends ConsumerWidget {
Navigator.of(routeContext).pop(); Navigator.of(routeContext).pop();
switch (warning.action) { switch (warning.action) {
case WarningAction.altitude: case WarningAction.altitude:
_openWaypointList(context, ref, initialTab: PanelTab.altitude); _openWaypointList(initialTab: PanelTab.altitude);
case WarningAction.speed: case WarningAction.speed:
_openWaypointList(context, ref, initialTab: PanelTab.speed); _openWaypointList(initialTab: PanelTab.speed);
case WarningAction.map: case WarningAction.map:
fitMapToWaypoints(ref, ref.read(currentMissionProvider)); fitMapToWaypoints(ref, ref.read(currentMissionProvider));
} }
@@ -104,10 +131,23 @@ class FlyScreen extends ConsumerWidget {
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider); final waypoints = ref.watch(currentMissionProvider);
final droneProfile = ref.watch(activeDroneProfileProvider); final droneProfile = ref.watch(activeDroneProfileProvider);
final telemetry = ref.watch(telemetryProvider).value; final telemetry = ref.watch(telemetryProvider).value;
final followDrone = ref.watch(followDroneProvider);
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
// programmatisch nachziehen (source == mapController, siehe
// _onMapEvent - bricht den Modus dadurch nicht selbst wieder ab).
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
if (!followDrone) return;
final frame = next.value;
if (frame != null && frame.hasFix) {
_mapController.move(LatLng(frame.lat, frame.lon), _mapController.camera.zoom);
}
});
final routeGeometry = buildRouteGeometry( final routeGeometry = buildRouteGeometry(
waypoints, waypoints,
@@ -147,8 +187,8 @@ class FlyScreen extends ConsumerWidget {
final droneMarker = telemetry != null && telemetry.hasFix final droneMarker = telemetry != null && telemetry.hasFix
? Marker( ? Marker(
point: LatLng(telemetry.lat, telemetry.lon), point: LatLng(telemetry.lat, telemetry.lon),
width: 28, width: 40,
height: 28, height: 40,
alignment: Alignment.center, alignment: Alignment.center,
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg), child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
) )
@@ -156,17 +196,37 @@ class FlyScreen extends ConsumerWidget {
? null ? null
: Marker( : Marker(
point: LatLng(waypoints.first.lat, waypoints.first.lon), point: LatLng(waypoints.first.lat, waypoints.first.lon),
width: 28, width: 40,
height: 28, height: 40,
alignment: Alignment.center, alignment: Alignment.center,
child: const DroneMarkerIcon(), child: const DroneMarkerIcon(),
); );
// Gestrichelte Linie von der Drohne zum aktiven Wegpunkt (Doku 3.4:
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
// rubberBandLine), hier aber Drohne -> naechster Wegpunkt statt letzter
// Wegpunkt -> Kartenmitte.
List<LatLng>? rubberBand;
final activeWpIndex = telemetry?.activeWaypointIndex;
if (telemetry != null &&
telemetry.hasFix &&
activeWpIndex != null &&
activeWpIndex >= 0 &&
activeWpIndex < waypoints.length) {
final target = waypoints[activeWpIndex];
rubberBand = [
LatLng(telemetry.lat, telemetry.lon),
LatLng(target.lat, target.lon),
];
}
return Stack( return Stack(
children: [ children: [
MissionMap( MissionMap(
mapController: ref.watch(mapControllerProvider), mapController: _mapController,
routeSegments: routeGeometry.segments, routeSegments: routeGeometry.segments,
rubberBandLine: rubberBand,
waypointMarkers: [ waypointMarkers: [
for (var i = 0; i < waypoints.length; i++) for (var i = 0; i < waypoints.length; i++)
CircleMarker( CircleMarker(
@@ -209,8 +269,8 @@ class FlyScreen extends ConsumerWidget {
totalDistanceM: missionStats.distanceM, totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec, totalDurationSec: missionStats.durationSec,
warnings: warnings, warnings: warnings,
onDetailsTap: () => _openWaypointList(context, ref), onDetailsTap: _openWaypointList,
onWarningsTap: () => _openWarningsPanel(context, ref, warnings), onWarningsTap: () => _openWarningsPanel(warnings),
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SettingsScreen(), builder: (_) => const SettingsScreen(),
)), )),
+1 -1
View File
@@ -10,7 +10,7 @@ import 'package:flutter/material.dart';
/// dasselbe Muster). Zeigt unrotiert (Spitze nach oben) bei fehlendem /// dasselbe Muster). Zeigt unrotiert (Spitze nach oben) bei fehlendem
/// [headingDeg], sonst im Uhrzeigersinn um den Kurs ueber Grund gedreht. /// [headingDeg], sonst im Uhrzeigersinn um den Kurs ueber Grund gedreht.
class DroneMarkerIcon extends StatelessWidget { class DroneMarkerIcon extends StatelessWidget {
const DroneMarkerIcon({super.key, this.size = 28, this.headingDeg}); const DroneMarkerIcon({super.key, this.size = 40, this.headingDeg});
final double size; final double size;
+24 -6
View File
@@ -3,26 +3,42 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../providers/current_mission_provider.dart'; import '../providers/current_mission_provider.dart';
import '../providers/follow_drone_provider.dart';
import '../providers/map_controller_provider.dart'; import '../providers/map_controller_provider.dart';
import '../providers/telemetry_provider.dart'; import '../providers/telemetry_provider.dart';
import 'nav_icon_button.dart'; import 'nav_icon_button.dart';
/// Kartensteuerung im Fly-Modus (Doku 3.11): Ortssuche/Home entfallen hier /// Kartensteuerung im Fly-Modus (Doku 3.11): Ortssuche/Home entfallen hier
/// (keine Missionsbearbeitung), stattdessen zoomt der Drohnen-Button auf die /// (keine Missionsbearbeitung), stattdessen schaltet der Drohnen-Button den
/// live Telemetrieposition - "Home" (erster Wegpunkt) ist waehrend des /// Kartenfolgemodus um (followDroneProvider) - "Home" (erster Wegpunkt) ist
/// Flugs kein sinnvoller Bezugspunkt mehr, der Fit-Button (gesamte Route) /// waehrend des Flugs kein sinnvoller Bezugspunkt mehr, der Fit-Button
/// bleibt dagegen unveraendert wie im Plan-Modus erhalten. /// (gesamte Route) bleibt dagegen unveraendert wie im Plan-Modus erhalten.
class FlyMapControls extends ConsumerWidget { class FlyMapControls extends ConsumerWidget {
const FlyMapControls({super.key}); const FlyMapControls({super.key});
/// Zielzoom beim Aktivieren des Folgemodus - naeher als der
/// Standard-Kartenzoom (siehe MissionMap._initialZoom), damit der Sprung
/// tatsaechlich als Zoom wahrgenommen wird und nicht nur als Verschieben.
static const _followZoom = 18.0;
/// Erster Tap zoomt auf die aktuelle Telemetrieposition und aktiviert den
/// Folgemodus (FlyScreen haengt sich darauf ein, zieht die Karte bei
/// jedem neuen Telemetrie-Frame nach und bricht bei manueller
/// Karteninteraktion selbststaendig wieder ab). Zweiter Tap beendet ihn
/// wieder, ohne die Kamera zu bewegen.
void _onDronePressed(BuildContext context, WidgetRef ref) { void _onDronePressed(BuildContext context, WidgetRef ref) {
if (ref.read(followDroneProvider)) {
ref.read(followDroneProvider.notifier).set(false);
return;
}
final frame = ref.read(telemetryProvider).value; final frame = ref.read(telemetryProvider).value;
if (frame == null) { if (frame == null) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('No telemetry yet'))); .showSnackBar(const SnackBar(content: Text('No telemetry yet')));
return; return;
} }
ref.read(mapControllerProvider).move(LatLng(frame.lat, frame.lon), 16); ref.read(mapControllerProvider).move(LatLng(frame.lat, frame.lon), _followZoom);
ref.read(followDroneProvider.notifier).set(true);
} }
void _onFitPressed(BuildContext context, WidgetRef ref) { void _onFitPressed(BuildContext context, WidgetRef ref) {
@@ -37,11 +53,13 @@ class FlyMapControls extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final following = ref.watch(followDroneProvider);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
NavIconButton( NavIconButton(
icon: Icons.my_location, icon: following ? Icons.gps_fixed : Icons.my_location,
active: following,
onTap: () => _onDronePressed(context, ref), onTap: () => _onDronePressed(context, ref),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
+5 -2
View File
@@ -37,8 +37,11 @@ class MissionMap extends StatelessWidget {
/// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius. /// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius.
final List<RouteSegment> routeSegments; final List<RouteSegment> routeSegments;
/// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen /// Gestrichelte Verbindungslinie, Bedeutung je nach Modus: im Plan-Modus
/// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde. /// vom letzten Wegpunkt zur aktuellen Kartenmitte, solange noch kein
/// neuer Punkt bestaetigt wurde (PlanScreen); im Fly-Modus von der
/// Drohne zum aktiven Wegpunkt (FlyScreen, TelemetryFrame.
/// activeWaypointIndex).
final List<LatLng>? rubberBandLine; final List<LatLng>? rubberBandLine;
final VoidCallback? onMapReady; final VoidCallback? onMapReady;
+13 -1
View File
@@ -4,11 +4,20 @@ import 'package:flutter/material.dart';
/// Plan- (MapSearchControls: Home/Fit) und Fly-Modus-Kartensteuerungen /// Plan- (MapSearchControls: Home/Fit) und Fly-Modus-Kartensteuerungen
/// (FlyMapControls: Drohne/Fit). /// (FlyMapControls: Drohne/Fit).
class NavIconButton extends StatelessWidget { class NavIconButton extends StatelessWidget {
const NavIconButton({super.key, required this.icon, required this.onTap}); const NavIconButton({
super.key,
required this.icon,
required this.onTap,
this.active = false,
});
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;
/// Rosa Akzentrand (Doku 3.10: DroneMarkerIcon-Farbe) fuer Knoepfe mit
/// an/aus-Zustand, z.B. den Drohnen-Folgemodus (FlyMapControls).
final bool active;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
@@ -19,6 +28,9 @@ class NavIconButton extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.55), color: Colors.black.withValues(alpha: 0.55),
border: active
? Border.all(color: const Color(0xFFFF2E93), width: 2)
: null,
), ),
child: Icon(icon, color: Colors.white, size: 16), child: Icon(icon, color: Colors.white, size: 16),
), ),
+1 -1
View File
@@ -1 +1 @@
,AI/Consti,AI,03.08.2026 21:25,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4; ,AI/Consti,AI,03.08.2026 21:43,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4;
Binary file not shown.