Show mission map and footer in Fly mode, add area/drone zoom buttons

Fly mode previously showed just a black placeholder. It now renders the
same MissionMap (route + waypoint markers) and footer (warnings banner,
altitude profile, BottomStatsBar) as the Plan screen, minus the
reticle/wheels since flying observes the route rather than replanning it.
Waypoints can still be edited via the details list for spontaneous
in-flight changes.

Live drone position comes from a new telemetryProvider/
flightControllerLinkProvider pair (autoDispose), the first place
FlightControllerLink/TelemetryFrame get wired into the UI - currently
backed by MockFlightControllerLink until a real MSP transport exists
(Doku 4.19). A drone marker renders on the map once a telemetry frame
arrives.

The Fly-mode header keeps the fit-to-area button but replaces "zoom to
home" with "zoom to drone" (FlyMapControls) - the first waypoint isn't a
meaningful reference point anymore once airborne.

Extracted computeMissionStats and MissionFooterBar out of PlanScreen so
Plan/Fly share the exact same stats/footer logic instead of duplicating
it, and extracted NavIconButton out of MapSearchControls so both Plan's
and Fly's map controls use the same button widget.

Widget tests that now mount FlyScreen switch from the ProviderScope-based
_wrap() helper to a manual ProviderContainer with an explicit
FlightControllerLink.disconnect() call before test end - the mock's
Timer.periodic doesn't get cancelled by container disposal alone, and
flutter_test's pending-timer check runs before addTearDown callbacks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-30 07:45:36 +02:00
co-authored by Claude Sonnet 5
parent d745b76403
commit 310d92f187
13 changed files with 611 additions and 104 deletions
+28
View File
@@ -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<Waypoint> 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);
}
@@ -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<FlightControllerLink>((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<TelemetryFrame>((ref) async* {
final link = ref.watch(flightControllerLinkProvider);
await link.connect();
yield* link.subscribeTelemetry();
});
+165 -11
View File
@@ -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<void>(
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<MissionWarning> warnings,
) {
Navigator.of(context).push(MaterialPageRoute<void>(
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),
),
),
],
);
}
}
+13 -60
View File
@@ -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<PlanScreen> {
));
}
({double distanceM, double durationSec}) _missionStats(
List<Waypoint> 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<PlanScreen> {
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<PlanScreen> {
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),
),
),
],
+15
View File
@@ -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);
}
}
+55
View File
@@ -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),
),
],
);
}
}
+3 -25
View File
@@ -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<MapSearchControls> {
),
),
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),
),
);
}
}
@@ -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<Waypoint> waypoints;
final List<bool> legClimbBad;
final List<bool> vertexBad;
final int activeIndex;
final double totalDistanceM;
final double totalDurationSec;
final List<MissionWarning> 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,
),
],
),
),
],
);
}
}
+9 -1
View File
@@ -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<Marker> 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<RouteSegment> routeSegments;
@@ -85,7 +90,10 @@ class MissionMap extends StatelessWidget {
],
),
CircleLayer(circles: waypointMarkers),
MarkerLayer(markers: windMarkers),
MarkerLayer(markers: [
...windMarkers,
?droneMarker,
]),
const _CompactAttribution(),
],
);
+27
View File
@@ -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),
),
);
}
}
+8 -3
View File
@@ -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(),
+37
View File
@@ -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));
});
});
}
+145 -4
View File
@@ -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<void> _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 {