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:
co-authored by
Claude Sonnet 5
parent
d745b76403
commit
310d92f187
+145
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user