diff --git a/app/lib/domain/mission/mission_stats.dart b/app/lib/domain/mission/mission_stats.dart new file mode 100644 index 0000000..7f8c18b --- /dev/null +++ b/app/lib/domain/mission/mission_stats.dart @@ -0,0 +1,28 @@ +import 'package:latlong2/latlong.dart'; + +import '../waypoint/flat_waypoint_list.dart'; + +const _distance = Distance(roundResult: false); + +/// Gesamtdistanz/-dauer einer Mission (HTML-Demonstrator: computeStats()). +class MissionStats { + const MissionStats({required this.distanceM, required this.durationSec}); + + final double distanceM; + final double durationSec; +} + +/// Geteilt zwischen Plan- und Fly-Modus sowie der Warnungsberechnung (Doku +/// 3.9/3.10), die dieselben Werte fuer Zeit-/Reichweiten-Warnungen braucht. +MissionStats computeMissionStats(List waypoints) { + var totalDist = 0.0; + var totalDur = 0.0; + for (var i = 0; i < waypoints.length - 1; i++) { + final a = LatLng(waypoints[i].lat, waypoints[i].lon); + final b = LatLng(waypoints[i + 1].lat, waypoints[i + 1].lon); + final d = _distance(a, b); + totalDist += d; + totalDur += d / waypoints[i + 1].speedMs; + } + return MissionStats(distanceM: totalDist, durationSec: totalDur); +} diff --git a/app/lib/ui/providers/telemetry_provider.dart b/app/lib/ui/providers/telemetry_provider.dart new file mode 100644 index 0000000..08c4664 --- /dev/null +++ b/app/lib/ui/providers/telemetry_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../transport/flight_controller_link.dart'; +import '../../transport/mock/mock_flight_controller_link.dart'; + +/// FlightControllerLink fuer den Fly-Modus (Doku 3.1) - aktuell fest auf den +/// Mock verdrahtet, bis ein echter Transport (MSP, Doku 4.19) existiert. +/// autoDispose: verbindet erst, wenn der Fly-Screen tatsaechlich beobachtet +/// wird, und trennt/stoppt den Mock-Ticker wieder, sobald niemand mehr +/// zuhoert (z.B. Rueckkehr in den Plan-Modus) statt endlos im Hintergrund +/// weiterzulaufen. +final flightControllerLinkProvider = Provider.autoDispose((ref) { + final link = MockFlightControllerLink(); + ref.onDispose(link.disconnect); + return link; +}); + +/// Live-Telemetrie des verbundenen Flightcontrollers (Doku 3.4). connect() +/// wird beim ersten Abonnenten ausgeloest - MockFlightControllerLink +/// verlangt das vor subscribeTelemetry(). +final telemetryProvider = StreamProvider.autoDispose((ref) async* { + final link = ref.watch(flightControllerLinkProvider); + await link.connect(); + yield* link.subscribeTelemetry(); +}); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 051e743..6c9d753 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -1,19 +1,173 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart'; -/// Fly-Modus: Telemetrie, Draeder, Point-and-Fly-Fadenkreuz -/// (Architektur-Doku 3.10/3.11). Platzhalter bis Telemetry-Service und -/// FlightControllerLink verdrahtet sind. Kein eigenes Scaffold, siehe -/// PlanScreen/AppShell. -class FlyScreen extends StatelessWidget { +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 '../../providers/active_drone_profile_provider.dart'; +import '../../providers/current_mission_provider.dart'; +import '../../providers/map_controller_provider.dart'; +import '../../providers/telemetry_provider.dart'; +import '../../providers/terrain_provider.dart'; +import '../../widgets/drone_marker_icon.dart'; +import '../../widgets/mission_footer_bar.dart'; +import '../../widgets/mission_map.dart'; +import '../../widgets/waypoint_list_panel.dart'; +import '../../widgets/warnings_panel.dart'; + +/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile +/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route +/// beobachtet statt neu geplant. Der Kartencontroller ist derselbe +/// app-lebenslange Provider wie im Plan-Modus (Doku: mapControllerProvider), +/// 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 { const FlyScreen({super.key}); - @override - Widget build(BuildContext context) { - return const ColoredBox( - color: Color(0xFF0F0F0F), - child: Center( - child: Text('Fly', style: TextStyle(color: Colors.white)), + void _onCenterOnWaypoint(WidgetRef ref, 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); + } + + void _openWaypointList( + BuildContext context, + WidgetRef ref, { + PanelTab initialTab = PanelTab.list, + }) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: true, + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), + pageBuilder: (routeContext, animation, secondaryAnimation) { + return FadeTransition( + opacity: animation, + child: WaypointListPanel( + activeIndex: -1, + initialTab: initialTab, + onClose: () => Navigator.of(routeContext).pop(), + onCenterOnWaypoint: (index) { + _onCenterOnWaypoint(ref, index); + Navigator.of(routeContext).pop(); + }, + onDelete: (index) => + ref.read(currentMissionProvider.notifier).removeAt(index), + onAltitudeChanged: (index, value) => ref + .read(currentMissionProvider.notifier) + .setAltitude(index, value), + onSpeedChanged: (index, value) => + ref.read(currentMissionProvider.notifier).setSpeed(index, value), + onCatchRadiusChanged: (index, value) => ref + .read(currentMissionProvider.notifier) + .setCatchRadius(index, value), + onActionChanged: (index, action) => + ref.read(currentMissionProvider.notifier).setAction(index, action), + ), + ); + }, ), ); } + + void _openWarningsPanel( + BuildContext context, + WidgetRef ref, + List warnings, + ) { + Navigator.of(context).push(MaterialPageRoute( + builder: (routeContext) => WarningsPanel( + warnings: warnings, + onClose: () => Navigator.of(routeContext).pop(), + onWarningTap: (warning) { + Navigator.of(routeContext).pop(); + switch (warning.action) { + case WarningAction.altitude: + _openWaypointList(context, ref, initialTab: PanelTab.altitude); + case WarningAction.speed: + _openWaypointList(context, ref, initialTab: PanelTab.speed); + case WarningAction.map: + fitMapToWaypoints(ref, ref.read(currentMissionProvider)); + } + }, + ), + )); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final waypoints = ref.watch(currentMissionProvider); + final droneProfile = ref.watch(activeDroneProfileProvider); + final telemetry = ref.watch(telemetryProvider).value; + + final routeGeometry = buildRouteGeometry( + waypoints, + minTurnRadius: droneProfile.minTurnRadius, + maxClimbRate: droneProfile.maxClimbRate, + maxDescentRate: droneProfile.maxDescentRate, + ); + final missionStats = computeMissionStats(waypoints); + final terrainProfile = ref.watch(terrainProvider).profile; + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: routeGeometry, + legWindBad: computeLegWindBad(waypoints, droneProfile.maxSpeed), + droneProfile: droneProfile, + totalDistanceM: missionStats.distanceM, + totalDurationSec: missionStats.durationSec, + terrain: terrainProfile, + ); + + return Stack( + children: [ + MissionMap( + mapController: ref.watch(mapControllerProvider), + routeSegments: routeGeometry.segments, + waypointMarkers: [ + for (var i = 0; i < waypoints.length; i++) + CircleMarker( + point: LatLng(waypoints[i].lat, waypoints[i].lon), + radius: 6, + color: i < routeGeometry.vertexBad.length && routeGeometry.vertexBad[i] + ? const Color(0xFFE2574A) + : const Color(0xFF2B2B28), + borderStrokeWidth: 1.5, + borderColor: Colors.white, + ), + ], + droneMarker: telemetry == null + ? null + : Marker( + point: LatLng(telemetry.lat, telemetry.lon), + width: 28, + height: 28, + alignment: Alignment.center, + child: const DroneMarkerIcon(), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: MissionFooterBar( + waypoints: waypoints, + legClimbBad: routeGeometry.legClimbBad, + vertexBad: routeGeometry.vertexBad, + activeIndex: -1, + totalDistanceM: missionStats.distanceM, + totalDurationSec: missionStats.durationSec, + warnings: warnings, + onDetailsTap: () => _openWaypointList(context, ref), + onWarningsTap: () => _openWarningsPanel(context, ref, warnings), + ), + ), + ], + ); + } } diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index caa120a..8aa456c 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import '../../../domain/mission/default_drone_profile.dart'; +import '../../../domain/mission/mission_stats.dart'; import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart'; @@ -17,9 +18,8 @@ import '../../providers/map_controller_provider.dart'; import '../../providers/mission_meta_provider.dart'; import '../../providers/terrain_provider.dart'; import '../../providers/wind_provider.dart'; -import '../../widgets/bottom_stats_bar.dart'; import '../../widgets/halo_menu.dart'; -import '../../widgets/mini_altitude_profile.dart'; +import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; import '../../widgets/value_wheel.dart'; @@ -359,21 +359,6 @@ class _PlanScreenState extends ConsumerState { )); } - ({double distanceM, double durationSec}) _missionStats( - List waypoints, - ) { - var totalDist = 0.0; - var totalDur = 0.0; - for (var i = 0; i < waypoints.length - 1; i++) { - final a = LatLng(waypoints[i].lat, waypoints[i].lon); - final b = LatLng(waypoints[i + 1].lat, waypoints[i + 1].lon); - final d = _distance(a, b); - totalDist += d; - totalDur += d / waypoints[i + 1].speedMs; - } - return (distanceM: totalDist, durationSec: totalDur); - } - @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); @@ -386,7 +371,7 @@ class _PlanScreenState extends ConsumerState { maxClimbRate: droneProfile.maxClimbRate, maxDescentRate: droneProfile.maxDescentRate, ); - final missionStats = _missionStats(waypoints); + final missionStats = computeMissionStats(waypoints); final terrainProfile = ref.watch(terrainProvider).profile; final warnings = computeMissionWarnings( waypoints: waypoints, @@ -513,48 +498,16 @@ class _PlanScreenState extends ConsumerState { left: 0, right: 0, bottom: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (warnings.isNotEmpty) - Container( - width: double.infinity, - color: const Color(0xFF7C2D12), - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - child: Text( - warnings.map((w) => w.text).join(' · '), - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFFFDE3D0), fontSize: 12), - ), - ), - Container( - width: double.infinity, - color: const Color(0xFF0F0F0F).withValues(alpha: 0.72), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), - child: MiniAltitudeProfile( - waypoints: waypoints, - legClimbBad: routeGeometry.legClimbBad, - vertexBad: routeGeometry.vertexBad, - activeIndex: - _mode == _ReticleMode.idle ? -1 : _targetIndex, - ), - ), - BottomStatsBar( - waypointCount: waypoints.length, - totalDistanceM: missionStats.distanceM, - totalDurationSec: missionStats.durationSec, - onTap: _openWaypointList, - warnings: warnings, - onWarningsTap: () => _openWarningsPanel(warnings), - ), - ], - ), - ), - ], + child: MissionFooterBar( + waypoints: waypoints, + legClimbBad: routeGeometry.legClimbBad, + vertexBad: routeGeometry.vertexBad, + activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex, + totalDistanceM: missionStats.distanceM, + totalDurationSec: missionStats.durationSec, + warnings: warnings, + onDetailsTap: _openWaypointList, + onWarningsTap: () => _openWarningsPanel(warnings), ), ), ], diff --git a/app/lib/ui/widgets/drone_marker_icon.dart b/app/lib/ui/widgets/drone_marker_icon.dart new file mode 100644 index 0000000..caf8392 --- /dev/null +++ b/app/lib/ui/widgets/drone_marker_icon.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// Live-Positionsmarker der Drohne im Fly-Modus (Doku 3.10) - noch ohne +/// Peilungsanzeige, da TelemetryFrame aktuell keinen Kurs (heading) +/// mitliefert (nur MockFlightControllerLink, siehe Doku 4.19). +class DroneMarkerIcon extends StatelessWidget { + const DroneMarkerIcon({super.key, this.size = 22}); + + final double size; + + @override + Widget build(BuildContext context) { + return Icon(Icons.airplanemode_active, color: const Color(0xFF4D9DFF), size: size); + } +} diff --git a/app/lib/ui/widgets/fly_map_controls.dart b/app/lib/ui/widgets/fly_map_controls.dart new file mode 100644 index 0000000..6dd639a --- /dev/null +++ b/app/lib/ui/widgets/fly_map_controls.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart'; + +import '../providers/current_mission_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. +class FlyMapControls extends ConsumerWidget { + const FlyMapControls({super.key}); + + void _onDronePressed(BuildContext context, WidgetRef ref) { + 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); + } + + void _onFitPressed(BuildContext context, WidgetRef ref) { + final waypoints = ref.read(currentMissionProvider); + if (waypoints.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('No waypoints to fit'))); + return; + } + fitMapToWaypoints(ref, waypoints); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + NavIconButton( + icon: Icons.my_location, + onTap: () => _onDronePressed(context, ref), + ), + const SizedBox(width: 8), + NavIconButton( + icon: Icons.crop_free, + onTap: () => _onFitPressed(context, ref), + ), + ], + ); + } +} diff --git a/app/lib/ui/widgets/map_search_controls.dart b/app/lib/ui/widgets/map_search_controls.dart index 10fdea3..c374160 100644 --- a/app/lib/ui/widgets/map_search_controls.dart +++ b/app/lib/ui/widgets/map_search_controls.dart @@ -7,6 +7,7 @@ import '../providers/geocoding_provider.dart'; import '../providers/map_controller_provider.dart'; import '../providers/mission_meta_provider.dart'; import '../providers/wind_provider.dart'; +import 'nav_icon_button.dart'; /// Ortssuche (Nominatim) + Home-/Fit-Buttons, eingebettet als Mittelteil /// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/ @@ -123,33 +124,10 @@ class _MapSearchControlsState extends ConsumerState { ), ), const SizedBox(width: 8), - _NavIconButton(icon: Icons.home, onTap: _onHomePressed), + NavIconButton(icon: Icons.home, onTap: _onHomePressed), const SizedBox(width: 8), - _NavIconButton(icon: Icons.crop_free, onTap: _onFitPressed), + 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), - ), - ); - } -} diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart new file mode 100644 index 0000000..85c6d44 --- /dev/null +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../../domain/mission/mission_warnings.dart'; +import '../../domain/waypoint/flat_waypoint_list.dart'; +import 'bottom_stats_bar.dart'; +import 'mini_altitude_profile.dart'; + +/// Gemeinsamer Fusszeilen-Aufbau (Warnungsbanner + Hoehenprofil + +/// BottomStatsBar), geteilt zwischen Plan- und Fly-Modus (Doku 3.9/3.10) - +/// beide zeigen dieselbe Missionsuebersicht, nur Reticle/Draeder gibt es +/// ausschliesslich im Plan-Modus. +class MissionFooterBar extends StatelessWidget { + const MissionFooterBar({ + super.key, + required this.waypoints, + required this.legClimbBad, + required this.vertexBad, + required this.activeIndex, + required this.totalDistanceM, + required this.totalDurationSec, + required this.warnings, + required this.onDetailsTap, + required this.onWarningsTap, + }); + + final List waypoints; + final List legClimbBad; + final List vertexBad; + final int activeIndex; + final double totalDistanceM; + final double totalDurationSec; + final List warnings; + final VoidCallback onDetailsTap; + final VoidCallback onWarningsTap; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (warnings.isNotEmpty) + Container( + width: double.infinity, + color: const Color(0xFF7C2D12), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Text( + warnings.map((w) => w.text).join(' · '), + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFFFDE3D0), fontSize: 12), + ), + ), + Container( + width: double.infinity, + color: const Color(0xFF0F0F0F).withValues(alpha: 0.72), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: MiniAltitudeProfile( + waypoints: waypoints, + legClimbBad: legClimbBad, + vertexBad: vertexBad, + activeIndex: activeIndex, + ), + ), + BottomStatsBar( + waypointCount: waypoints.length, + totalDistanceM: totalDistanceM, + totalDurationSec: totalDurationSec, + onTap: onDetailsTap, + warnings: warnings, + onWarningsTap: onWarningsTap, + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index 649ff9b..ff27ff5 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -15,6 +15,7 @@ class MissionMap extends StatelessWidget { this.windMarkers = const [], this.routeSegments = const [], this.rubberBandLine, + this.droneMarker, this.onMapReady, }); @@ -28,6 +29,10 @@ class MissionMap extends StatelessWidget { /// geladen wurde (HTML-Demonstrator: showPerWaypointWind). final List windMarkers; + /// Live-Position der Drohne im Fly-Modus (Doku 3.10), null solange noch + /// keine Telemetrie eingetroffen ist. + final Marker? droneMarker; + /// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot /// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius. final List routeSegments; @@ -85,7 +90,10 @@ class MissionMap extends StatelessWidget { ], ), CircleLayer(circles: waypointMarkers), - MarkerLayer(markers: windMarkers), + MarkerLayer(markers: [ + ...windMarkers, + ?droneMarker, + ]), const _CompactAttribution(), ], ); diff --git a/app/lib/ui/widgets/nav_icon_button.dart b/app/lib/ui/widgets/nav_icon_button.dart new file mode 100644 index 0000000..58c40cc --- /dev/null +++ b/app/lib/ui/widgets/nav_icon_button.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +/// Runder Icon-Button ueber dunklem Kartenhintergrund, geteilt zwischen den +/// 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}); + + 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), + ), + ); + } +} diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index 5eb494d..138d885 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -8,6 +8,7 @@ import '../../app_mode/app_mode_cubit.dart'; import '../dmc_colors.dart'; import '../providers/flight_mode_provider.dart'; import 'flight_mode_pill.dart'; +import 'fly_map_controls.dart'; import 'header_wind_pill.dart'; import 'map_search_controls.dart'; @@ -23,9 +24,11 @@ import 'map_search_controls.dart'; /// statt als PlanScreen-privates Feld, siehe dort. /// /// Im Fly-Modus traegt sie stattdessen die Wind-Pille (Doku 7.2: "Wind- -/// Anzeige nach Hoehe interpoliert") und die Flugmodus-Auswahl (Doku 3.11, -/// HTML-Demonstrator: #flightModePill) - Ortssuche/Home/Fit sind dort -/// nicht relevant, da im Fly-Modus keine Missionsbearbeitung stattfindet. +/// Anzeige nach Hoehe interpoliert), die Flugmodus-Auswahl (Doku 3.11, +/// HTML-Demonstrator: #flightModePill) sowie Fit-/Drohnen-Zoom-Buttons +/// (FlyMapControls) - Ortssuche/Home entfallen dort, da im Fly-Modus keine +/// Missionsbearbeitung stattfindet und "Home" (erster Wegpunkt) waehrend +/// des Flugs kein sinnvoller Bezugspunkt mehr ist. class TopModeBar extends ConsumerWidget { const TopModeBar({super.key}); @@ -81,6 +84,8 @@ class TopModeBar extends ConsumerWidget { const SizedBox(width: 8), const Expanded(child: FlightModePill()), const SizedBox(width: 8), + const FlyMapControls(), + const SizedBox(width: 8), ] else ...[ const SizedBox(width: 8), const HeaderWindPill(), diff --git a/app/test/domain/mission_stats_test.dart b/app/test/domain/mission_stats_test.dart new file mode 100644 index 0000000..a185068 --- /dev/null +++ b/app/test/domain/mission_stats_test.dart @@ -0,0 +1,37 @@ +import 'package:dmc_app/domain/mission/mission_stats.dart'; +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Waypoint _wp(double lat, double lon, {double speed = 15}) => + Waypoint(lat: lat, lon: lon, altitudeM: 60, speedMs: speed, catchRadiusM: 60); + +void main() { + group('computeMissionStats', () { + test('leere/einzelne Wegpunktliste ergibt Distanz/Dauer 0', () { + expect(computeMissionStats(const []).distanceM, 0); + expect(computeMissionStats([_wp(0, 0)]).distanceM, 0); + expect(computeMissionStats([_wp(0, 0)]).durationSec, 0); + }); + + test('zwei Wegpunkte: Distanz aus Grosskreis, Dauer aus Zielgeschwindigkeit', () { + final stats = computeMissionStats([_wp(0, 0), _wp(0, 0.01, speed: 20)]); + + // 0.01 Grad Laengendifferenz am Aequator entspricht rund 1112 m. + expect(stats.distanceM, closeTo(1112.6, 1)); + expect(stats.durationSec, closeTo(stats.distanceM / 20, 0.01)); + }); + + test('mehrere Beine summieren sich, Dauer je Bein mit dessen Zielgeschwindigkeit', () { + final stats = computeMissionStats( + [_wp(0, 0, speed: 10), _wp(0, 0.01, speed: 20), _wp(0, 0.02, speed: 10)], + ); + + final leg1 = 1112.6; + final leg2 = 1112.6; + expect(stats.distanceM, closeTo(leg1 + leg2, 2)); + // Erstes Bein zaehlt mit der Geschwindigkeit des Zielwegpunkts (20), + // zweites Bein mit dessen Zielwegpunkt (10). + expect(stats.durationSec, closeTo(leg1 / 20 + leg2 / 10, 0.1)); + }); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index a86ffaf..0d3c804 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -23,6 +23,7 @@ import 'package:dmc_app/ui/providers/database_provider.dart'; import 'package:dmc_app/ui/providers/geocoding_provider.dart'; import 'package:dmc_app/ui/providers/mission_meta_provider.dart'; import 'package:dmc_app/ui/providers/sharing_provider.dart'; +import 'package:dmc_app/ui/providers/telemetry_provider.dart'; import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/header_wind_pill.dart'; @@ -70,6 +71,14 @@ ProviderContainer _testContainer() => ProviderContainer( overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())], ); +/// Der Fly-Modus startet ueber telemetryProvider einen Mock-Telemetrie- +/// Timer (Timer.periodic, MockFlightControllerLink). Anders als der +/// einmalige Autosave-Debounce reicht bei einem periodischen Timer kein +/// blosses Vorspulen der Zeit - er muss explizit getrennt werden, bevor der +/// Test endet, sonst meldet flutter_test einen noch ausstehenden Timer. +Future _stopTelemetry(ProviderContainer container) => + container.read(flightControllerLinkProvider).disconnect(); + Widget _wrap(Widget child) => ProviderScope( overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())], child: BlocProvider( @@ -109,21 +118,49 @@ void main() { testWidgets( 'Fly-Button wechselt direkt in den Fly-Modus (Ready-to-Fly-Gate nicht mehr erzwungen)', (tester) async { - await tester.pumpWidget(_wrap(const AppShell())); + // Manueller Container statt _wrap(): der Fly-Modus startet ueber + // telemetryProvider einen Mock-Telemetrie-Timer (Timer.periodic), der + // ohne explizites container.dispose() als "noch ausstehender Timer" + // das Testende blockieren wuerde. + final container = _testContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); await tester.pump(); await tester.tap(find.text('Fly')); await tester.pump(); - // PlanScreen (mit Karte) ist verschwunden, FlyScreen samt + // PlanScreen ist verschwunden, FlyScreen (mit eigener Karte) samt // Flugmodus-Pille (Standard: Waypoint) ist sichtbar. - expect(find.byType(FlutterMap), findsNothing); + expect(find.byType(FlutterMap), findsOneWidget); expect(find.text('Mode: Waypoint'), findsOneWidget); + + await _stopTelemetry(container); }); testWidgets('Flugmodus-Dropdown wechselt den Modus und setzt ihn beim ' 'erneuten Eintritt in den Fly-Modus zurueck', (tester) async { - await tester.pumpWidget(_wrap(const AppShell())); + final container = _testContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); await tester.pump(); await tester.tap(find.text('Fly')); @@ -143,6 +180,8 @@ void main() { await tester.tap(find.text('Fly')); await tester.pump(); expect(find.text('Mode: Waypoint'), findsOneWidget); + + await _stopTelemetry(container); }); testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu', @@ -587,6 +626,7 @@ void main() { appModeCubit.toPlan(); await tester.pump(); + await _stopTelemetry(container); final camera = container.read(mapControllerProvider).camera; expect(camera.center.latitude, closeTo(movedTo.latitude, 0.0001)); @@ -851,6 +891,107 @@ void main() { await tester.pump(const Duration(milliseconds: 900)); }); + testWidgets( + 'FlyScreen zeigt die Karte mit der aktuellen Mission und die Fusszeile', + (tester) async { + final container = _testContainer(); + addTearDown(container.dispose); + container.read(currentMissionProvider.notifier).addWaypoint(const Waypoint( + lat: 52.3676, + lon: 4.9041, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + )); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Fly')); + await tester.pump(); + + expect(find.byType(FlutterMap), findsOneWidget); + expect(find.text('1 WP'), findsOneWidget); + + // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein + // Timer mehr aussteht (Doku 7.5). + await tester.pump(const Duration(milliseconds: 900)); + await _stopTelemetry(container); + }); + + testWidgets( + 'Drohnen-Button im Fly-Modus zentriert nach Eintreffen der Telemetrie auf die Drohne', + (tester) async { + final container = _testContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Fly')); + await tester.pump(); + + // Noch keine Telemetrie eingetroffen (MockFlightControllerLink tickt + // erst nach 1s) - Hinweis statt Kamerasprung. + await tester.tap(find.byIcon(Icons.my_location)); + await tester.pump(); + expect(find.text('No telemetry yet'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1, milliseconds: 100)); + + await tester.tap(find.byIcon(Icons.my_location)); + await tester.pump(); + + final camera = container.read(mapControllerProvider).camera; + expect(camera.center.latitude, closeTo(52.5, 0.0001)); + expect(camera.center.longitude, closeTo(13.4, 0.0001)); + + await _stopTelemetry(container); + }); + + testWidgets('Fit-Button im Fly-Modus ohne Wegpunkte zeigt Hinweis statt Absturz', + (tester) async { + final container = _testContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Fly')); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.crop_free)); + await tester.pump(); + + expect(find.text('No waypoints to fit'), findsOneWidget); + + await _stopTelemetry(container); + }); + testWidgets( 'Antippen einer Speed-Warnung oeffnet die Wegpunktliste direkt auf dem Speed-Tab', (tester) async {