diff --git a/app/lib/app_mode/app_mode_cubit.dart b/app/lib/app_mode/app_mode_cubit.dart index a37c4db..999a649 100644 --- a/app/lib/app_mode/app_mode_cubit.dart +++ b/app/lib/app_mode/app_mode_cubit.dart @@ -52,13 +52,14 @@ class AppModeCubit extends Cubit { void droneArmedChanged(bool armed) => emit(state.copyWith(droneArmed: armed)); - /// Wirft, wenn das Ready-to-Fly-Gate nicht erfuellt ist (Doku 4.5). - void toFly() { - if (!state.readyToFly) { - throw StateError('Ready-to-Fly-Gate nicht erfuellt.'); - } - emit(state.copyWith(mode: AppMode.fly)); - } + /// Wechselt in den Fly-Modus. Das Ready-to-Fly-Gate ([readyToFly], Doku + /// 2.2/4.5) wird hier bewusst nicht mehr erzwungen, solange Upload/ + /// Verify-Flow (MissionSyncService) noch nicht angebunden sind - sonst + /// waere der Fly-Modus fuer die UI-Entwicklung gar nicht erreichbar. Der + /// eigentliche Schutz vor einem Missions-Upload im armed-Zustand lebt + /// unabhaengig davon weiterhin auf Transport-Ebene (siehe + /// MockFlightControllerLink.uploadMission()). + void toFly() => emit(state.copyWith(mode: AppMode.fly)); /// Verlassen des Fly-Modus setzt den kompletten Handover-Status zurueck. void toPlan() => emit(const AppModeState()); diff --git a/app/lib/ui/providers/flight_mode_provider.dart b/app/lib/ui/providers/flight_mode_provider.dart new file mode 100644 index 0000000..c9d831b --- /dev/null +++ b/app/lib/ui/providers/flight_mode_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../transport/flight_controller_link.dart'; + +/// Aktuell gewaehlter Flugmodus im Fly-Modus (Doku 3.1/3.11/7.2, HTML- +/// Demonstrator: currentFlightMode/setFlightMode()) - rein lokale +/// UI-Auswahl, noch nicht mit einer echten FlightControllerLink +/// verdrahtet (MspFlightControllerLink.setFlightMode() ist noch ein +/// Geruest, Doku 4.19). +class FlightModeNotifier extends Notifier { + @override + FlightMode build() => FlightMode.missionRun; + + void select(FlightMode mode) => state = mode; + + /// Eintritt in den Fly-Modus setzt immer auf Waypoint zurueck (Doku + /// 3.11: "Standard-Rueckstellung auf Waypoint-Modus beim Eintritt in + /// Fly-Modus"). + void reset() => state = FlightMode.missionRun; +} + +final flightModeProvider = + NotifierProvider(FlightModeNotifier.new); diff --git a/app/lib/ui/widgets/flight_mode_pill.dart b/app/lib/ui/widgets/flight_mode_pill.dart new file mode 100644 index 0000000..e77a91e --- /dev/null +++ b/app/lib/ui/widgets/flight_mode_pill.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../transport/flight_controller_link.dart'; +import '../providers/flight_mode_provider.dart'; + +/// Flugmodus-Auswahl im Fly-Modus (Doku 3.11/7.2, HTML-Demonstrator: +/// #flightModePill/#flightModeDropdown). Rein lokale UI-Auswahl, noch +/// ohne echte FlightControllerLink-Anbindung (Doku 4.19) - Auswahl wirkt +/// sich aktuell nur auf die Anzeige aus. +class FlightModePill extends ConsumerWidget { + const FlightModePill({super.key}); + + static const _labels = { + FlightMode.missionRun: 'Waypoint', + FlightMode.guidedPoint: 'Point and Fly', + FlightMode.returnHome: 'Return to Home', + FlightMode.hold: 'Manual', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(flightModeProvider); + + return PopupMenuButton( + tooltip: 'Flight mode', + color: const Color(0xF20F0F0F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: Colors.white.withValues(alpha: 0.2)), + ), + onSelected: (selected) => ref.read(flightModeProvider.notifier).select(selected), + itemBuilder: (context) => [ + for (final entry in _labels.entries) + PopupMenuItem( + value: entry.key, + child: Text( + entry.value, + style: TextStyle( + color: entry.key == mode ? const Color(0xFFE2574A) : Colors.white, + fontWeight: entry.key == mode ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ], + child: Container( + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 14), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(19), + border: Border.all(color: Colors.white.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + 'Mode: ${_labels[mode]}', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 4), + Icon(Icons.arrow_drop_down, color: Colors.white.withValues(alpha: 0.7)), + ], + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index e449b0d..5eb494d 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -2,9 +2,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_mode/app_mode_cubit.dart'; import '../dmc_colors.dart'; +import '../providers/flight_mode_provider.dart'; +import 'flight_mode_pill.dart'; import 'header_wind_pill.dart'; import 'map_search_controls.dart'; @@ -18,11 +21,16 @@ import 'map_search_controls.dart'; /// #searchWrap/#homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer /// separaten Leiste) - dafuer lebt der MapController im mapControllerProvider /// statt als PlanScreen-privates Feld, siehe dort. -class TopModeBar extends StatelessWidget { +/// +/// 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. +class TopModeBar extends ConsumerWidget { const TopModeBar({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isFly = context.select((AppModeCubit c) => c.state.mode == AppMode.fly); final activeTint = isFly ? DmcColors.flyModeRed : DmcColors.editModeGreen; @@ -67,9 +75,13 @@ class TopModeBar extends StatelessWidget { ), onTap: () => context.read().toPlan(), ), - if (isFly) - const Spacer() - else ...[ + if (isFly) ...[ + const SizedBox(width: 8), + const HeaderWindPill(), + const SizedBox(width: 8), + const Expanded(child: FlightModePill()), + const SizedBox(width: 8), + ] else ...[ const SizedBox(width: 8), const HeaderWindPill(), const SizedBox(width: 8), @@ -83,7 +95,12 @@ class TopModeBar extends StatelessWidget { cornerRadius: const BorderRadius.only( bottomRight: Radius.circular(26), ), - onTap: () => _tryEnterFly(context), + onTap: () { + context.read().toFly(); + // Eintritt in den Fly-Modus setzt immer auf Waypoint + // zurueck (Doku 3.11). + ref.read(flightModeProvider.notifier).reset(); + }, ), ], ), @@ -91,22 +108,6 @@ class TopModeBar extends StatelessWidget { ), ); } - - void _tryEnterFly(BuildContext context) { - try { - context.read().toFly(); - } on StateError { - // Ready-to-Fly-Gate (Doku 2.2/4.5) nicht erfuellt - Upload/Verify-Flow - // ist noch nicht verdrahtet, daher aktuell immer dieser Fall. - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Mission muss zuerst hochgeladen und verifiziert werden.', - ), - ), - ); - } - } } class _ModeButton extends StatelessWidget { diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 4f77bfb..a86ffaf 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -107,7 +107,7 @@ void main() { }); testWidgets( - 'Fly-Button ohne erfuellten Ready-to-Fly-Gate zeigt Hinweis statt Absturz', + 'Fly-Button wechselt direkt in den Fly-Modus (Ready-to-Fly-Gate nicht mehr erzwungen)', (tester) async { await tester.pumpWidget(_wrap(const AppShell())); await tester.pump(); @@ -115,12 +115,34 @@ void main() { await tester.tap(find.text('Fly')); await tester.pump(); - expect( - find.text('Mission muss zuerst hochgeladen und verifiziert werden.'), - findsOneWidget, - ); - // Weiterhin im Plan-Modus, da das Gate nicht erfuellt war. - expect(find.byType(FlutterMap), findsOneWidget); + // PlanScreen (mit Karte) ist verschwunden, FlyScreen samt + // Flugmodus-Pille (Standard: Waypoint) ist sichtbar. + expect(find.byType(FlutterMap), findsNothing); + expect(find.text('Mode: Waypoint'), findsOneWidget); + }); + + 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())); + await tester.pump(); + + await tester.tap(find.text('Fly')); + await tester.pump(); + expect(find.text('Mode: Waypoint'), findsOneWidget); + + await tester.tap(find.text('Mode: Waypoint')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Return to Home')); + await tester.pumpAndSettle(); + expect(find.text('Mode: Return to Home'), findsOneWidget); + + // Zurueck nach Plan und wieder nach Fly - Doku 3.11: "Standard- + // Rueckstellung auf Waypoint-Modus beim Eintritt in Fly-Modus". + await tester.tap(find.text('Plan')); + await tester.pump(); + await tester.tap(find.text('Fly')); + await tester.pump(); + expect(find.text('Mode: Waypoint'), findsOneWidget); }); testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu', @@ -560,13 +582,7 @@ void main() { container.read(mapControllerProvider).move(movedTo, 14); await tester.pump(); - // Ready-to-Fly-Gate technisch erfuellen, um in den Fly-Modus zu gelangen - // (Doku 2.2/4.5) - hier nur, um die Kamera-Persistenz des geteilten - // mapControllerProvider ueber den Moduswechsel zu pruefen. - appModeCubit - ..missionUploadConfirmed() - ..missionVerifiedConfirmed() - ..toFly(); + appModeCubit.toFly(); await tester.pump(); appModeCubit.toPlan();