From fc7d014fda2672a793927ca225dc4a6d54c18381 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:40:40 +0200 Subject: [PATCH] Add top bar for switching between Plan and Fly mode - TopModeBar widget: persistent pill-shaped header (HTML prototype: #topBarWrap/#editModeBtn/#flyModeBtn) tinted in the active mode's color, with the inactive mode offered as a solid button in the corner to switch into it. - AppShell now provides a single shared Scaffold + Stack, overlaying TopModeBar on top of whichever screen (Plan/Fly) is active, instead of each screen owning its own Scaffold. - Wired the Fly button to AppModeCubit.toFly(), which enforces the Ready-to-Fly gate (doc 4.5). Since upload/verify isn't wired up yet, the gate is never satisfied yet, so tapping Fly now shows a SnackBar instead of throwing an uncaught StateError. - Extended the widget test to cover both the bar's presence and the gate-rejection path (was the source of a real bug: an initial negative-margin Container hack for the "bleed into the corner" look violated Container's margin.isNonNegative assertion and crashed the whole tree - fixed with padding instead). Verified with flutter analyze, flutter test, and a real run on the Pixel_10a emulator (visually matches the prototype screenshots in design/, Fly-tap correctly shows the gate message without crashing or switching mode). Co-Authored-By: Claude Sonnet 5 --- app/lib/ui/app_shell.dart | 17 +++- app/lib/ui/screens/fly/fly_screen.dart | 10 +- app/lib/ui/screens/plan/plan_screen.dart | 12 +-- app/lib/ui/widgets/top_mode_bar.dart | 116 +++++++++++++++++++++++ app/test/widget_test.dart | 36 +++++-- 5 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 app/lib/ui/widgets/top_mode_bar.dart diff --git a/app/lib/ui/app_shell.dart b/app/lib/ui/app_shell.dart index 2c7f940..63fa3ca 100644 --- a/app/lib/ui/app_shell.dart +++ b/app/lib/ui/app_shell.dart @@ -4,9 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../app_mode/app_mode_cubit.dart'; import 'screens/fly/fly_screen.dart'; import 'screens/plan/plan_screen.dart'; +import 'widgets/top_mode_bar.dart'; -/// Schaltet zwischen Plan- und Fly-Screen um, gesteuert von der -/// App-Mode State Machine (Architektur-Doku 2.2). Connect/Upload sind +/// Haelt die persistente Kopfleiste (Plan/Fly-Umschalter) und schaltet +/// darunter zwischen Plan- und Fly-Screen um, gesteuert von der App-Mode +/// State Machine (Architektur-Doku 2.2). Connect/Upload sind /// Uebergangszustaende innerhalb des Plan-Screens (Handover-Prozess). class AppShell extends StatelessWidget { const AppShell({super.key}); @@ -14,6 +16,15 @@ class AppShell extends StatelessWidget { @override Widget build(BuildContext context) { final mode = context.select((AppModeCubit c) => c.state.mode); - return mode == AppMode.fly ? const FlyScreen() : const PlanScreen(); + final screen = mode == AppMode.fly ? const FlyScreen() : const PlanScreen(); + + return Scaffold( + body: Stack( + children: [ + Positioned.fill(child: screen), + const Align(alignment: Alignment.topCenter, child: TopModeBar()), + ], + ), + ); } } diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index bbeb62a..051e743 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -2,14 +2,18 @@ import 'package:flutter/material.dart'; /// Fly-Modus: Telemetrie, Draeder, Point-and-Fly-Fadenkreuz /// (Architektur-Doku 3.10/3.11). Platzhalter bis Telemetry-Service und -/// FlightControllerLink verdrahtet sind. +/// FlightControllerLink verdrahtet sind. Kein eigenes Scaffold, siehe +/// PlanScreen/AppShell. class FlyScreen extends StatelessWidget { const FlyScreen({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: Text('Fly')), + return const ColoredBox( + color: Color(0xFF0F0F0F), + child: Center( + child: Text('Fly', style: TextStyle(color: Colors.white)), + ), ); } } diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 31505a0..a712ae2 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -3,16 +3,12 @@ import 'package:flutter/material.dart'; import '../../widgets/mission_map.dart'; /// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7). -/// Top-/Bottom-Bar-Overlays (Modus-Umschalter, Draeder, Wegpunktliste) folgen -/// als naechster Schritt - dies ist bewusst erst die Vollbild-Karte. +/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der +/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber. Wegpunktliste, +/// Draeder und Bottom-Bar folgen als naechster Schritt. class PlanScreen extends StatelessWidget { const PlanScreen({super.key}); @override - Widget build(BuildContext context) { - return const Scaffold( - extendBodyBehindAppBar: true, - body: MissionMap(), - ); - } + Widget build(BuildContext context) => const MissionMap(); } diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart new file mode 100644 index 0000000..c89a344 --- /dev/null +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../app_mode/app_mode_cubit.dart'; +import '../dmc_colors.dart'; + +/// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus +/// (HTML-Demonstrator: #topBarWrap/#editModeBtn/#flyModeBtn). Die Leiste +/// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird +/// als solider Button am Rand angeboten, um in ihn zu wechseln. +class TopModeBar extends StatelessWidget { + const TopModeBar({super.key}); + + @override + Widget build(BuildContext context) { + final isFly = context.select((AppModeCubit c) => c.state.mode == AppMode.fly); + final activeTint = isFly ? DmcColors.flyModeRed : DmcColors.editModeGreen; + + return SafeArea( + bottom: false, + child: FractionallySizedBox( + widthFactor: 0.64, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: activeTint.withValues(alpha: 0.55), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(26), + bottomRight: Radius.circular(26), + ), + ), + child: Row( + children: [ + _ModeButton( + label: 'Plan', + isActive: !isFly, + inactiveColor: DmcColors.editModeGreen, + cornerRadius: const BorderRadius.only( + bottomLeft: Radius.circular(26), + ), + onTap: () => context.read().toPlan(), + ), + const Spacer(), + _ModeButton( + label: 'Fly', + isActive: isFly, + inactiveColor: DmcColors.flyModeRed, + cornerRadius: const BorderRadius.only( + bottomRight: Radius.circular(26), + ), + onTap: () => _tryEnterFly(context), + ), + ], + ), + ), + ), + ); + } + + 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 { + const _ModeButton({ + required this.label, + required this.isActive, + required this.inactiveColor, + required this.cornerRadius, + required this.onTap, + }); + + final String label; + final bool isActive; + final Color inactiveColor; + final BorderRadius cornerRadius; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: isActive ? null : onTap, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: isActive ? 6 : 11, + ), + decoration: BoxDecoration( + color: isActive ? null : inactiveColor.withValues(alpha: 0.85), + borderRadius: isActive ? null : cornerRadius, + ), + child: Text( + label, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Color(0xFF141414), + ), + ), + ), + ); + } +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 72af05e..a73de51 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -7,20 +7,40 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.dart'; import 'package:dmc_app/ui/app_shell.dart'; -void main() { - testWidgets('AppShell zeigt die Karte im Plan-Startzustand', (tester) async { - await tester.pumpWidget( - ProviderScope( - child: BlocProvider( - create: (_) => AppModeCubit(), - child: const MaterialApp(home: AppShell()), - ), +Widget _wrap(Widget child) => ProviderScope( + child: BlocProvider( + create: (_) => AppModeCubit(), + child: MaterialApp(home: child), ), ); + +void main() { + testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand', + (tester) async { + await tester.pumpWidget(_wrap(const AppShell())); // Nur ein Frame pumpen statt pumpAndSettle: TileLayer laedt echte // Netzwerk-Kacheln, die in der Testumgebung nie ankommen. await tester.pump(); expect(find.byType(FlutterMap), findsOneWidget); + expect(find.text('Plan'), findsOneWidget); + expect(find.text('Fly'), findsOneWidget); + }); + + testWidgets( + 'Fly-Button ohne erfuellten Ready-to-Fly-Gate zeigt Hinweis statt Absturz', + (tester) async { + await tester.pumpWidget(_wrap(const AppShell())); + await tester.pump(); + + 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); }); }