diff --git a/app/lib/domain/mission/mission_warnings.dart b/app/lib/domain/mission/mission_warnings.dart new file mode 100644 index 0000000..077e382 --- /dev/null +++ b/app/lib/domain/mission/mission_warnings.dart @@ -0,0 +1,84 @@ +import '../waypoint/flat_waypoint_list.dart'; +import 'drone_profile.dart'; +import 'route_geometry.dart'; +import 'terrain_math.dart'; + +/// Auf welchen Screen eine Warnung verweist, um sie zu beheben (Doku 3.10, +/// analog dem `action`-Feld der Warnungs-Items im HTML-Demonstrator). +enum WarningAction { map, altitude, speed } + +/// Eine einzelne aktive Warnung zur aktuellen Mission. +class MissionWarning { + const MissionWarning({required this.text, required this.action}); + + final String text; + final WarningAction action; +} + +/// Sammelt alle aktuell zutreffenden Warnungen zur Mission (Doku 3.10, +/// analog dem `warnItems`-Aufbau in redraw() im HTML-Demonstrator) - reine +/// Funktion aus bereits andernorts berechneten Zwischenergebnissen +/// (RouteGeometry, Windkomponenten, Gelaendeprofil), damit sie unabhaengig +/// von Riverpod/Widgets testbar bleibt. +/// +/// [legWindBad] kommt aus computeLegWindBad() (wind_math.dart). [terrain] +/// ist null, solange noch kein Gelaendeprofil geladen wurde, oder wenn das +/// geladene Profil nicht mehr zur aktuellen Route passt (siehe +/// TerrainProfile.routeKey) - die Gelaende-Warnung wird dann uebersprungen, +/// statt faelschlich "sicher" oder "gefaehrlich" zu behaupten. +List computeMissionWarnings({ + required List waypoints, + required RouteGeometry routeGeometry, + required List legWindBad, + required DroneProfile droneProfile, + required double totalDistanceM, + required double totalDurationSec, + TerrainProfile? terrain, +}) { + final warnings = []; + + final anyBad = routeGeometry.vertexBad.any((bad) => bad) || + routeGeometry.segments.any((s) => s.bad); + if (anyBad) { + warnings.add(const MissionWarning( + text: 'Route exceeds drone capabilities at highlighted point', + action: WarningAction.map, + )); + } + + if (legWindBad.any((bad) => bad)) { + warnings.add(const MissionWarning( + text: 'Wind too strong for safe ground speed on one or more legs', + action: WarningAction.speed, + )); + } + + if (terrain != null && + terrain.routeKey == routeKeyForTerrain(waypoints) && + terrainDangerRanges(waypoints, terrain).isNotEmpty) { + warnings.add(MissionWarning( + text: 'Terrain clearance below ${minTerrainClearanceM.round()}m ' + 'minimum on part of the route', + action: WarningAction.altitude, + )); + } + + if (droneProfile.maxRangeM > 0 && totalDistanceM > droneProfile.maxRangeM) { + warnings.add(MissionWarning( + text: 'Mission distance exceeds max range ' + '(${droneProfile.maxRangeM.round()} m)', + action: WarningAction.map, + )); + } + + if (droneProfile.maxEnduranceMin > 0 && + (totalDurationSec / 60) > droneProfile.maxEnduranceMin) { + warnings.add(MissionWarning( + text: 'Mission duration exceeds max endurance ' + '(${droneProfile.maxEnduranceMin.round()} min)', + action: WarningAction.map, + )); + } + + return warnings; +} diff --git a/app/lib/domain/wind/wind_math.dart b/app/lib/domain/wind/wind_math.dart index 92fe0db..432afc4 100644 --- a/app/lib/domain/wind/wind_math.dart +++ b/app/lib/domain/wind/wind_math.dart @@ -128,3 +128,22 @@ List computeGroundSpeeds(List waypoints) { }, ]; } + +/// Pro Leg: true, wenn der Wind eine sichere Bodengeschwindigkeit +/// unmoeglich macht (Doku 3.8/3.10, analog legWindBad im HTML- +/// Demonstrator) - entweder sinkt die resultierende Bodengeschwindigkeit +/// auf <= 1 m/s, oder die rohe Windgeschwindigkeit am Zielwegpunkt +/// uebersteigt die maximale Fluggeschwindigkeit der Drohne. False (nicht +/// unbekannt), solange fuer das Leg noch kein Wind geladen wurde - ein +/// fehlender Wert soll kein Warnungs-false-positive ausloesen. +List computeLegWindBad(List waypoints, double maxSpeedMs) { + return [ + for (var i = 0; i < waypoints.length - 1; i++) + switch (computeLegWindComponent(waypoints[i], waypoints[i + 1])) { + final wind? => (waypoints[i + 1].speedMs + wind <= 1) || + (waypoints[i + 1].windSpeedMs != null && + waypoints[i + 1].windSpeedMs! > maxSpeedMs), + null => false, + }, + ]; +} diff --git a/app/lib/ui/providers/map_controller_provider.dart b/app/lib/ui/providers/map_controller_provider.dart index b7b47c5..f2d3ae4 100644 --- a/app/lib/ui/providers/map_controller_provider.dart +++ b/app/lib/ui/providers/map_controller_provider.dart @@ -1,5 +1,9 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../domain/waypoint/flat_waypoint_list.dart'; /// Ein einziger, app-lebenslanger MapController statt einer pro /// PlanScreen-Mount neu erzeugten Instanz. @@ -20,3 +24,19 @@ final mapControllerProvider = Provider((ref) { ref.onDispose(controller.dispose); return controller; }); + +/// Zentriert die Karte auf die Bounding-Box der uebergebenen Wegpunkte +/// (Doku 3.6, analog fitToWaypoints() im HTML-Demonstrator) - geteilt +/// zwischen dem manuellen Fit-Button (MapSearchControls) und den +/// map-bezogenen Warnungs-Aktionen (Doku 3.10), damit beide Stellen exakt +/// dieselbe Zoom-/Padding-Logik verwenden. +void fitMapToWaypoints(WidgetRef ref, List waypoints) { + if (waypoints.isEmpty) 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), + ), + ); +} diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index fdbda1d..caa120a 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -7,12 +7,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import '../../../domain/mission/default_drone_profile.dart'; +import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.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/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'; @@ -21,6 +24,7 @@ import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; import '../../widgets/value_wheel.dart'; import '../../widgets/waypoint_list_panel.dart'; +import '../../widgets/warnings_panel.dart'; import '../../widgets/wind_marker_pill.dart'; /// Reticle-Zustand (HTML-Demonstrator: Variable `mode`). @@ -299,8 +303,10 @@ class _PlanScreenState extends ConsumerState { /// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als /// internen Stack-Overlay, damit sie wirklich alles ueberdeckt - - /// inklusive der Kopfleiste im AppShell darueber. - void _openWaypointList() { + /// inklusive der Kopfleiste im AppShell darueber. [initialTab] erlaubt + /// gezieltes Oeffnen auf Altitude/Speed statt immer auf der Liste (Doku + /// 3.10: Antippen einer Warnung). + void _openWaypointList({PanelTab initialTab = PanelTab.list}) { Navigator.of(context).push( PageRouteBuilder( opaque: true, @@ -311,6 +317,7 @@ class _PlanScreenState extends ConsumerState { opacity: animation, child: WaypointListPanel( activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex, + initialTab: initialTab, onClose: () => Navigator.of(routeContext).pop(), onCenterOnWaypoint: (index) { _onCenterOnWaypoint(index); @@ -328,6 +335,30 @@ class _PlanScreenState extends ConsumerState { ); } + /// Oeffnet die Warnungsliste (Doku 3.10, HTML-Demonstrator: + /// #warningsBtn/#warningsPanel). Antippen einer Warnung schliesst die + /// Liste und navigiert zum passenden Screen: Altitude-/Speed-Tab der + /// Wegpunktliste, oder ein Kartensprung auf die gesamte Route. + void _openWarningsPanel(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(initialTab: PanelTab.altitude); + case WarningAction.speed: + _openWaypointList(initialTab: PanelTab.speed); + case WarningAction.map: + fitMapToWaypoints(ref, ref.read(currentMissionProvider)); + } + }, + ), + )); + } + ({double distanceM, double durationSec}) _missionStats( List waypoints, ) { @@ -356,8 +387,16 @@ class _PlanScreenState extends ConsumerState { maxDescentRate: droneProfile.maxDescentRate, ); final missionStats = _missionStats(waypoints); - final anyBad = routeGeometry.vertexBad.any((bad) => bad) || - routeGeometry.segments.any((s) => s.bad); + 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, + ); List? rubberBand; if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) { @@ -477,15 +516,15 @@ class _PlanScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (anyBad) + if (warnings.isNotEmpty) Container( width: double.infinity, color: const Color(0xFF7C2D12), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - child: const Text( - 'Route exceeds drone capabilities at highlighted point', + child: Text( + warnings.map((w) => w.text).join(' ยท '), textAlign: TextAlign.center, - style: TextStyle(color: Color(0xFFFDE3D0), fontSize: 12), + style: const TextStyle(color: Color(0xFFFDE3D0), fontSize: 12), ), ), Container( @@ -509,6 +548,8 @@ class _PlanScreenState extends ConsumerState { totalDistanceM: missionStats.distanceM, totalDurationSec: missionStats.durationSec, onTap: _openWaypointList, + warnings: warnings, + onWarningsTap: () => _openWarningsPanel(warnings), ), ], ), diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index 049c7ca..15cb474 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/mission/mission_warnings.dart'; import '../providers/active_drone_profile_provider.dart'; import '../providers/mission_meta_provider.dart'; import 'missions_drones_panel.dart'; @@ -18,6 +19,8 @@ class BottomStatsBar extends ConsumerWidget { required this.totalDistanceM, required this.totalDurationSec, required this.onTap, + this.warnings = const [], + this.onWarningsTap, }); final int waypointCount; @@ -25,6 +28,12 @@ class BottomStatsBar extends ConsumerWidget { final double totalDurationSec; final VoidCallback onTap; + /// Aktive Warnungen zur Mission (Doku 3.10) - der Button erscheint nur, + /// solange diese Liste nicht leer ist (HTML-Demonstrator: #warningsBtn, + /// per default versteckt). + final List warnings; + final VoidCallback? onWarningsTap; + @override Widget build(BuildContext context, WidgetRef ref) { final missionName = ref.watch(currentMissionMetaProvider).name; @@ -69,6 +78,10 @@ class BottomStatsBar extends ConsumerWidget { )), ), ), + if (warnings.isNotEmpty) ...[ + const SizedBox(width: 6), + _WarningButton(onTap: onWarningsTap), + ], ], ), ), @@ -130,6 +143,31 @@ class BottomStatsBar extends ConsumerWidget { } } +/// Kleiner, roter Rundknopf fuer aktive Warnungen (Doku 3.10, HTML- +/// Demonstrator: #warningsBtn) - oeffnet die Warnungsliste. +class _WarningButton extends StatelessWidget { + const _WarningButton({required this.onTap}); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFE2574A).withValues(alpha: 0.18), + border: Border.all(color: const Color(0xFFE2574A).withValues(alpha: 0.5)), + ), + child: const Icon(Icons.warning_amber_rounded, color: Color(0xFFFFB3A8), size: 18), + ), + ); + } +} + class _NameChip extends StatelessWidget { const _NameChip({ required this.label, diff --git a/app/lib/ui/widgets/map_search_controls.dart b/app/lib/ui/widgets/map_search_controls.dart index 8a68e35..10fdea3 100644 --- a/app/lib/ui/widgets/map_search_controls.dart +++ b/app/lib/ui/widgets/map_search_controls.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; @@ -80,13 +79,7 @@ class _MapSearchControlsState extends ConsumerState { .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), - ), - ); + fitMapToWaypoints(ref, waypoints); } @override diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index 851e526..649ff9b 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -52,6 +52,13 @@ class MissionMap extends StatelessWidget { initialZoom: _initialZoom, minZoom: 3, maxZoom: 19, + // Kartendrehung (Zwei-Finger-Twist) bewusst deaktiviert - die Drohne + // fliegt nordorientiert, eine gedrehte Karte wuerde die Peilung der + // Wegpunkte/des Reticles gegenueber Kompass/HTML-Demonstrator (der + // gar keine Drehung kennt) nur verwirren. + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all & ~InteractiveFlag.rotate, + ), onMapReady: onMapReady, ), children: [ diff --git a/app/lib/ui/widgets/warnings_panel.dart b/app/lib/ui/widgets/warnings_panel.dart new file mode 100644 index 0000000..81bb89c --- /dev/null +++ b/app/lib/ui/widgets/warnings_panel.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../domain/mission/mission_warnings.dart'; + +/// Vollbild-Warnungsliste (Doku 3.10, HTML-Demonstrator: #warningsPanel) - +/// jede Zeile verweist per [onWarningTap] auf den Screen, der die Ursache +/// beheben kann (Altitude-/Speed-Tab der Wegpunktliste, oder die Karte). +class WarningsPanel extends StatelessWidget { + const WarningsPanel({ + super.key, + required this.warnings, + required this.onClose, + required this.onWarningTap, + }); + + final List warnings; + final VoidCallback onClose; + final void Function(MissionWarning warning) onWarningTap; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xEB080808), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Row( + children: [ + const Expanded( + child: Text( + 'Warnings', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: warnings.isEmpty + ? const Center( + child: Text( + 'No active warnings', + style: TextStyle(color: Colors.white60, fontSize: 15), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: warnings.length, + itemBuilder: (context, index) => _row(warnings[index]), + ), + ), + ], + ), + ), + ); + } + + Widget _row(MissionWarning warning) { + return GestureDetector( + onTap: () => onWarningTap(warning), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Text(warning.text, style: const TextStyle(color: Colors.white)), + ), + Text( + _actionLabel(warning.action), + style: const TextStyle( + color: Color(0xFF4D9DFF), + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + ); + } + + String _actionLabel(WarningAction action) => switch (action) { + WarningAction.altitude => 'Open altitude view', + WarningAction.speed => 'Open speed view', + WarningAction.map => 'Show on map', + }; +} diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index 179302b..a4ca62f 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -16,7 +16,10 @@ import 'waypoint_chip.dart'; import 'wind_arrow_icon.dart'; import 'wind_validate_panel.dart'; -enum _PanelTab { list, altitude, speed } +/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public, +/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed +/// verweisen koennen, statt immer auf der Liste zu starten. +enum PanelTab { list, altitude, speed } /// Vollbild-Wegpunktliste (HTML-Demonstrator: #wpListPanel). Wird von /// PlanScreen als eigene Vollbild-Route gepusht (Navigator.push), damit sie @@ -34,6 +37,7 @@ class WaypointListPanel extends ConsumerStatefulWidget { const WaypointListPanel({ super.key, required this.activeIndex, + this.initialTab = PanelTab.list, required this.onClose, required this.onCenterOnWaypoint, required this.onDelete, @@ -44,6 +48,11 @@ class WaypointListPanel extends ConsumerStatefulWidget { }); final int activeIndex; + + /// Startet direkt auf einem bestimmten Tab statt immer auf der Liste - + /// genutzt, wenn eine Warnung (Doku 3.10) gezielt auf Altitude/Speed + /// verweist. + final PanelTab initialTab; final VoidCallback onClose; final void Function(int index) onCenterOnWaypoint; final void Function(int index) onDelete; @@ -58,7 +67,7 @@ class WaypointListPanel extends ConsumerStatefulWidget { class _WaypointListPanelState extends ConsumerState { int _rowEditIndex = -1; - _PanelTab _tab = _PanelTab.list; + late PanelTab _tab; final _listScrollController = ScrollController(); static const _headerStyle = TextStyle( @@ -75,14 +84,24 @@ class _WaypointListPanelState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive()); + _tab = widget.initialTab; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToActive(); + // Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der + // Warnungsliste), muss der Gelaendeprofil-Abruf hier ausgeloest + // werden - sonst passiert das erst beim (dann gar nicht mehr + // stattfindenden) Antippen des Tab-Buttons. + if (_tab == PanelTab.altitude) { + ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider)); + } + }); // Laedt/aktualisiert das Gelaendeprofil (Doku 3.9) nur, waehrend der // Altitude-Tab tatsaechlich sichtbar ist - aendert sich die Route // waehrenddessen (Editieren, Loeschen, neuer Wegpunkt), muss neu // gesampelt werden. ensureFor() selbst filtert per routeKey, ob ein // erneuter Kachel-Download noetig ist. ref.listenManual(currentMissionProvider, (previous, next) { - if (_tab == _PanelTab.altitude) { + if (_tab == PanelTab.altitude) { ref.read(terrainProvider.notifier).ensureFor(next); } }); @@ -198,16 +217,16 @@ class _WaypointListPanelState extends ConsumerState { ), Row( children: [ - _tabButton('List', _PanelTab.list), - _tabButton('Altitude', _PanelTab.altitude), - _tabButton('Speed', _PanelTab.speed), + _tabButton('List', PanelTab.list), + _tabButton('Altitude', PanelTab.altitude), + _tabButton('Speed', PanelTab.speed), ], ), const Divider(color: Colors.white24, height: 1), Expanded( child: switch (_tab) { - _PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile), - _PanelTab.altitude => _chartTab( + PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile), + PanelTab.altitude => _chartTab( waypoints, routeGeometry, values: [for (final w in waypoints) w.altitudeM], @@ -223,7 +242,7 @@ class _WaypointListPanelState extends ConsumerState { : null, terrainLoading: terrainState.loading, ), - _PanelTab.speed => _chartTab( + PanelTab.speed => _chartTab( waypoints, routeGeometry, values: [for (final w in waypoints) w.speedMs], @@ -244,15 +263,15 @@ class _WaypointListPanelState extends ConsumerState { ); } - Widget _tabButton(String label, _PanelTab tab) { + Widget _tabButton(String label, PanelTab tab) { final active = _tab == tab; return Expanded( child: InkWell( onTap: () { setState(() => _tab = tab); - if (tab == _PanelTab.list) { + if (tab == PanelTab.list) { WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive()); - } else if (tab == _PanelTab.altitude) { + } else if (tab == PanelTab.altitude) { ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider)); } }, diff --git a/app/test/domain/mission_warnings_test.dart b/app/test/domain/mission_warnings_test.dart new file mode 100644 index 0000000..3998257 --- /dev/null +++ b/app/test/domain/mission_warnings_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/mission/drone_profile.dart'; +import 'package:dmc_app/domain/mission/mission_warnings.dart'; +import 'package:dmc_app/domain/mission/route_geometry.dart'; +import 'package:dmc_app/domain/mission/terrain_math.dart'; +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; + +void main() { + const waypoints = [ + Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + Waypoint(lat: 0, lon: 0.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ]; + final drone = DroneProfile.t1Ranger(); + const okGeometry = RouteGeometry(segments: [], vertexBad: [false, false], legClimbBad: [false]); + + group('computeMissionWarnings', () { + test('keine Warnungen bei unauffaelliger Route', () { + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: okGeometry, + legWindBad: const [false], + droneProfile: drone, + totalDistanceM: 100, + totalDurationSec: 60, + ); + expect(warnings, isEmpty); + }); + + test('meldet eine Routen-Warnung bei vertexBad und verweist auf die Karte', () { + const badGeometry = RouteGeometry(segments: [], vertexBad: [true, false], legClimbBad: [false]); + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: badGeometry, + legWindBad: const [false], + droneProfile: drone, + totalDistanceM: 100, + totalDurationSec: 60, + ); + expect(warnings, hasLength(1)); + expect(warnings.single.action, WarningAction.map); + }); + + test('meldet eine Wind-Warnung und verweist auf den Speed-Tab', () { + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: okGeometry, + legWindBad: const [true], + droneProfile: drone, + totalDistanceM: 100, + totalDurationSec: 60, + ); + expect(warnings.single.action, WarningAction.speed); + }); + + test('meldet eine Gelaende-Warnung bei zum routeKey passendem Profil und verweist auf den Altitude-Tab', () { + final profile = TerrainProfile(routeKey: routeKeyForTerrain(waypoints), points: const [ + TerrainSamplePoint(distanceM: 0, elevationRelM: 55), // clearance 5 < 10 + TerrainSamplePoint(distanceM: 500, elevationRelM: 55), + ]); + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: okGeometry, + legWindBad: const [false], + droneProfile: drone, + totalDistanceM: 100, + totalDurationSec: 60, + terrain: profile, + ); + expect(warnings.single.action, WarningAction.altitude); + }); + + test('ignoriert ein Gelaendeprofil, dessen routeKey nicht mehr zur Route passt', () { + const staleProfile = TerrainProfile(routeKey: 'stale', points: [ + TerrainSamplePoint(distanceM: 0, elevationRelM: 55), + ]); + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: okGeometry, + legWindBad: const [false], + droneProfile: drone, + totalDistanceM: 100, + totalDurationSec: 60, + terrain: staleProfile, + ); + expect(warnings, isEmpty); + }); + + test('meldet Reichweiten- und Ausdauer-Ueberschreitung, beide verweisen auf die Karte', () { + final tightDrone = drone.copyWith(maxRangeM: 50, maxEnduranceMin: 0.5); + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: okGeometry, + legWindBad: const [false], + droneProfile: tightDrone, + totalDistanceM: 100, + totalDurationSec: 60, + ); + expect(warnings, hasLength(2)); + expect(warnings.every((w) => w.action == WarningAction.map), isTrue); + }); + + test('maxRangeM/maxEnduranceMin <= 0 bedeutet "kein Limit" und wird nie ueberschritten', () { + final unlimitedDrone = drone.copyWith(maxRangeM: 0, maxEnduranceMin: 0); + final warnings = computeMissionWarnings( + waypoints: waypoints, + routeGeometry: okGeometry, + legWindBad: const [false], + droneProfile: unlimitedDrone, + totalDistanceM: 1000000, + totalDurationSec: 1000000, + ); + expect(warnings, isEmpty); + }); + }); +} diff --git a/app/test/domain/wind_math_test.dart b/app/test/domain/wind_math_test.dart index 9402a39..946b076 100644 --- a/app/test/domain/wind_math_test.dart +++ b/app/test/domain/wind_math_test.dart @@ -113,4 +113,42 @@ void main() { expect(legWind[1], isNull); }); }); + + group('computeLegWindBad', () { + const start = Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60); + const tailwindTarget = Waypoint( + lat: 1, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60, + windSpeedMs: 10, windDirFromDeg: 180, + ); + const noWindTarget = Waypoint(lat: 1, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60); + + test('true, wenn die resultierende Bodengeschwindigkeit auf <= 1 m/s sinkt', () { + // Bearing a->b ist 0 (Nord); Wind aus Nord (windDirFromDeg 0) weht nach + // Sueden -> reiner Gegenwind, component = -windSpeedMs. + const headwindTarget = Waypoint( + lat: 1, lon: 0, altitudeM: 60, speedMs: 3, catchRadiusM: 60, + windSpeedMs: 10, windDirFromDeg: 0, + ); + final bad = computeLegWindBad([start, headwindTarget], 25); + expect(bad.single, isTrue); + }); + + test('true, wenn die rohe Windgeschwindigkeit die maximale Fluggeschwindigkeit uebersteigt', () { + // Reiner Rueckenwind - Bodengeschwindigkeit (15+10=25) ist hoch genug, + // aber der Wind selbst (10 m/s) ist staerker als die Drohne (hier: max + // 8 m/s) fliegen kann. + final bad = computeLegWindBad([start, tailwindTarget], 8); + expect(bad.single, isTrue); + }); + + test('false bei moderatem Rueckenwind innerhalb der Grenzen', () { + final bad = computeLegWindBad([start, tailwindTarget], 30); + expect(bad.single, isFalse); + }); + + test('false (nicht unbekannt), solange fuer das Leg kein Wind geladen ist', () { + final bad = computeLegWindBad([start, noWindTarget], 25); + expect(bad.single, isFalse); + }); + }); } diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 1eec8a7..4f77bfb 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -788,4 +788,93 @@ void main() { final parsed = parseDroneImportJson(fakeSharing.sharedJson!); expect(parsed.profile.name, 'T1 Ranger'); }); + + testWidgets( + 'Warnungs-Button erscheint bei Reichweiten-Ueberschreitung und oeffnet die Warnungsliste', + (tester) async { + final container = _testContainer(); + addTearDown(container.dispose); + + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + + // T1-Ranger-Standardprofil erlaubt max. 15000 m Reichweite - 0.2 Grad + // Breitenunterschied (~22 km) ueberschreitet das deutlich. + container.read(currentMissionProvider.notifier) + ..addWaypoint(const Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60)) + ..addWaypoint(const Waypoint(lat: 0.2, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60)); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.textContaining('exceeds max range'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.warning_amber_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Warnings'), findsOneWidget); + expect(find.textContaining('exceeds max range'), findsWidgets); + + // Antippen der Warnung (Aktion "map") schliesst die Liste wieder, ohne + // dass eine Ausnahme auftritt. + await tester.tap(find.textContaining('Show on map')); + await tester.pumpAndSettle(); + + expect(find.text('Warnings'), findsNothing); + + // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein + // Timer mehr aussteht (Doku 7.5). + await tester.pump(const Duration(milliseconds: 900)); + }); + + testWidgets( + 'Antippen einer Speed-Warnung oeffnet die Wegpunktliste direkt auf dem Speed-Tab', + (tester) async { + final container = _testContainer(); + addTearDown(container.dispose); + + // Starker Gegenwind auf dem zweiten Wegpunkt macht die Bodengeschwindigkeit + // negativ - loest computeLegWindBad() aus. + container.read(currentMissionProvider.notifier) + ..addWaypoint(const Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60)) + ..addWaypoint(const Waypoint( + lat: 0.001, + lon: 0, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + windSpeedMs: 30, + windDirFromDeg: 0, + )); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.warning_amber_rounded)); + await tester.pumpAndSettle(); + + await tester.tap(find.textContaining('Open speed view')); + await tester.pumpAndSettle(); + + expect(find.text('Speed'), findsWidgets); + expect(find.byType(FullValueChart), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 900)); + }); }