diff --git a/app/lib/ui/providers/follow_drone_provider.dart b/app/lib/ui/providers/follow_drone_provider.dart new file mode 100644 index 0000000..4037f6c --- /dev/null +++ b/app/lib/ui/providers/follow_drone_provider.dart @@ -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 { + @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.new, + isAutoDispose: true, +); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index a214f4e..21c2b37 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -9,8 +10,10 @@ import '../../../domain/mission/mission_stats.dart'; import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/wind/wind_math.dart'; +import '../../../transport/flight_controller_link.dart'; import '../../providers/active_drone_profile_provider.dart'; import '../../providers/current_mission_provider.dart'; +import '../../providers/follow_drone_provider.dart'; import '../../providers/map_controller_provider.dart'; import '../../providers/telemetry_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 /// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung), /// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier. -class FlyScreen extends ConsumerWidget { +class FlyScreen extends ConsumerStatefulWidget { const FlyScreen({super.key}); - void _onCenterOnWaypoint(WidgetRef ref, int index) { + @override + ConsumerState createState() => _FlyScreenState(); +} + +class _FlyScreenState extends ConsumerState { + late final MapController _mapController; + StreamSubscription? _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); if (index < 0 || index >= waypoints.length) return; final wp = waypoints[index]; - final controller = ref.read(mapControllerProvider); - controller.move(LatLng(wp.lat, wp.lon), controller.camera.zoom); + _mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom); } - void _openWaypointList( - BuildContext context, - WidgetRef ref, { - PanelTab initialTab = PanelTab.list, - }) { + void _openWaypointList({PanelTab initialTab = PanelTab.list}) { Navigator.of(context).push( PageRouteBuilder( opaque: true, @@ -57,7 +88,7 @@ class FlyScreen extends ConsumerWidget { initialTab: initialTab, onClose: () => Navigator.of(routeContext).pop(), onCenterOnWaypoint: (index) { - _onCenterOnWaypoint(ref, index); + _onCenterOnWaypoint(index); Navigator.of(routeContext).pop(); }, onDelete: (index) => @@ -79,11 +110,7 @@ class FlyScreen extends ConsumerWidget { ); } - void _openWarningsPanel( - BuildContext context, - WidgetRef ref, - List warnings, - ) { + void _openWarningsPanel(List warnings) { Navigator.of(context).push(MaterialPageRoute( builder: (routeContext) => WarningsPanel( warnings: warnings, @@ -92,9 +119,9 @@ class FlyScreen extends ConsumerWidget { Navigator.of(routeContext).pop(); switch (warning.action) { case WarningAction.altitude: - _openWaypointList(context, ref, initialTab: PanelTab.altitude); + _openWaypointList(initialTab: PanelTab.altitude); case WarningAction.speed: - _openWaypointList(context, ref, initialTab: PanelTab.speed); + _openWaypointList(initialTab: PanelTab.speed); case WarningAction.map: fitMapToWaypoints(ref, ref.read(currentMissionProvider)); } @@ -104,10 +131,23 @@ class FlyScreen extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); final droneProfile = ref.watch(activeDroneProfileProvider); 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>(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( waypoints, @@ -147,8 +187,8 @@ class FlyScreen extends ConsumerWidget { final droneMarker = telemetry != null && telemetry.hasFix ? Marker( point: LatLng(telemetry.lat, telemetry.lon), - width: 28, - height: 28, + width: 40, + height: 40, alignment: Alignment.center, child: DroneMarkerIcon(headingDeg: telemetry.headingDeg), ) @@ -156,17 +196,37 @@ class FlyScreen extends ConsumerWidget { ? null : Marker( point: LatLng(waypoints.first.lat, waypoints.first.lon), - width: 28, - height: 28, + width: 40, + height: 40, alignment: Alignment.center, 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? 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( children: [ MissionMap( - mapController: ref.watch(mapControllerProvider), + mapController: _mapController, routeSegments: routeGeometry.segments, + rubberBandLine: rubberBand, waypointMarkers: [ for (var i = 0; i < waypoints.length; i++) CircleMarker( @@ -209,8 +269,8 @@ class FlyScreen extends ConsumerWidget { totalDistanceM: missionStats.distanceM, totalDurationSec: missionStats.durationSec, warnings: warnings, - onDetailsTap: () => _openWaypointList(context, ref), - onWarningsTap: () => _openWarningsPanel(context, ref, warnings), + onDetailsTap: _openWaypointList, + onWarningsTap: () => _openWarningsPanel(warnings), onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const SettingsScreen(), )), diff --git a/app/lib/ui/widgets/drone_marker_icon.dart b/app/lib/ui/widgets/drone_marker_icon.dart index ea7465d..609a27e 100644 --- a/app/lib/ui/widgets/drone_marker_icon.dart +++ b/app/lib/ui/widgets/drone_marker_icon.dart @@ -10,7 +10,7 @@ import 'package:flutter/material.dart'; /// dasselbe Muster). Zeigt unrotiert (Spitze nach oben) bei fehlendem /// [headingDeg], sonst im Uhrzeigersinn um den Kurs ueber Grund gedreht. 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; diff --git a/app/lib/ui/widgets/fly_map_controls.dart b/app/lib/ui/widgets/fly_map_controls.dart index 6dd639a..cb54199 100644 --- a/app/lib/ui/widgets/fly_map_controls.dart +++ b/app/lib/ui/widgets/fly_map_controls.dart @@ -3,26 +3,42 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import '../providers/current_mission_provider.dart'; +import '../providers/follow_drone_provider.dart'; import '../providers/map_controller_provider.dart'; import '../providers/telemetry_provider.dart'; import 'nav_icon_button.dart'; /// Kartensteuerung im Fly-Modus (Doku 3.11): Ortssuche/Home entfallen hier -/// (keine Missionsbearbeitung), stattdessen zoomt der Drohnen-Button auf die -/// live Telemetrieposition - "Home" (erster Wegpunkt) ist waehrend des -/// Flugs kein sinnvoller Bezugspunkt mehr, der Fit-Button (gesamte Route) -/// bleibt dagegen unveraendert wie im Plan-Modus erhalten. +/// (keine Missionsbearbeitung), stattdessen schaltet der Drohnen-Button den +/// Kartenfolgemodus um (followDroneProvider) - "Home" (erster Wegpunkt) ist +/// waehrend des Flugs kein sinnvoller Bezugspunkt mehr, der Fit-Button +/// (gesamte Route) bleibt dagegen unveraendert wie im Plan-Modus erhalten. class FlyMapControls extends ConsumerWidget { 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) { + if (ref.read(followDroneProvider)) { + ref.read(followDroneProvider.notifier).set(false); + return; + } final frame = ref.read(telemetryProvider).value; if (frame == null) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('No telemetry yet'))); 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) { @@ -37,11 +53,13 @@ class FlyMapControls extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final following = ref.watch(followDroneProvider); return Row( mainAxisSize: MainAxisSize.min, children: [ NavIconButton( - icon: Icons.my_location, + icon: following ? Icons.gps_fixed : Icons.my_location, + active: following, onTap: () => _onDronePressed(context, ref), ), const SizedBox(width: 8), diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index ff27ff5..678d411 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -37,8 +37,11 @@ class MissionMap extends StatelessWidget { /// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius. final List routeSegments; - /// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen - /// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde. + /// Gestrichelte Verbindungslinie, Bedeutung je nach Modus: im Plan-Modus + /// 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? rubberBandLine; final VoidCallback? onMapReady; diff --git a/app/lib/ui/widgets/nav_icon_button.dart b/app/lib/ui/widgets/nav_icon_button.dart index 58c40cc..d056f01 100644 --- a/app/lib/ui/widgets/nav_icon_button.dart +++ b/app/lib/ui/widgets/nav_icon_button.dart @@ -4,11 +4,20 @@ import 'package:flutter/material.dart'; /// Plan- (MapSearchControls: Home/Fit) und Fly-Modus-Kartensteuerungen /// (FlyMapControls: Drohne/Fit). 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 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 Widget build(BuildContext context) { return GestureDetector( @@ -19,6 +28,9 @@ class NavIconButton extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, 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), ), diff --git a/design/.~lock.DMC logo sketches.odp# b/design/.~lock.DMC logo sketches.odp# index af6e94b..4a582da 100644 --- a/design/.~lock.DMC logo sketches.odp# +++ b/design/.~lock.DMC logo sketches.odp# @@ -1 +1 @@ -,AI/Consti,AI,03.08.2026 21:25,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4; \ No newline at end of file +,AI/Consti,AI,03.08.2026 21:43,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4; \ No newline at end of file diff --git a/design/DMC logo sketches.odp b/design/DMC logo sketches.odp index 4738c77..64ffe1b 100644 Binary files a/design/DMC logo sketches.odp and b/design/DMC logo sketches.odp differ