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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
02dd1bc11a
commit
fc7d014fda
@@ -4,9 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../app_mode/app_mode_cubit.dart';
|
import '../app_mode/app_mode_cubit.dart';
|
||||||
import 'screens/fly/fly_screen.dart';
|
import 'screens/fly/fly_screen.dart';
|
||||||
import 'screens/plan/plan_screen.dart';
|
import 'screens/plan/plan_screen.dart';
|
||||||
|
import 'widgets/top_mode_bar.dart';
|
||||||
|
|
||||||
/// Schaltet zwischen Plan- und Fly-Screen um, gesteuert von der
|
/// Haelt die persistente Kopfleiste (Plan/Fly-Umschalter) und schaltet
|
||||||
/// App-Mode State Machine (Architektur-Doku 2.2). Connect/Upload sind
|
/// 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).
|
/// Uebergangszustaende innerhalb des Plan-Screens (Handover-Prozess).
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
const AppShell({super.key});
|
const AppShell({super.key});
|
||||||
@@ -14,6 +16,15 @@ class AppShell extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final mode = context.select((AppModeCubit c) => c.state.mode);
|
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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
/// Fly-Modus: Telemetrie, Draeder, Point-and-Fly-Fadenkreuz
|
/// Fly-Modus: Telemetrie, Draeder, Point-and-Fly-Fadenkreuz
|
||||||
/// (Architektur-Doku 3.10/3.11). Platzhalter bis Telemetry-Service und
|
/// (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 {
|
class FlyScreen extends StatelessWidget {
|
||||||
const FlyScreen({super.key});
|
const FlyScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return const ColoredBox(
|
||||||
body: Center(child: Text('Fly')),
|
color: Color(0xFF0F0F0F),
|
||||||
|
child: Center(
|
||||||
|
child: Text('Fly', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../widgets/mission_map.dart';
|
import '../../widgets/mission_map.dart';
|
||||||
|
|
||||||
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
|
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
|
||||||
/// Top-/Bottom-Bar-Overlays (Modus-Umschalter, Draeder, Wegpunktliste) folgen
|
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
|
||||||
/// als naechster Schritt - dies ist bewusst erst die Vollbild-Karte.
|
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber. Wegpunktliste,
|
||||||
|
/// Draeder und Bottom-Bar folgen als naechster Schritt.
|
||||||
class PlanScreen extends StatelessWidget {
|
class PlanScreen extends StatelessWidget {
|
||||||
const PlanScreen({super.key});
|
const PlanScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => const MissionMap();
|
||||||
return const Scaffold(
|
|
||||||
extendBodyBehindAppBar: true,
|
|
||||||
body: MissionMap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AppModeCubit>().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<AppModeCubit>().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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/app_mode/app_mode_cubit.dart';
|
||||||
import 'package:dmc_app/ui/app_shell.dart';
|
import 'package:dmc_app/ui/app_shell.dart';
|
||||||
|
|
||||||
void main() {
|
Widget _wrap(Widget child) => ProviderScope(
|
||||||
testWidgets('AppShell zeigt die Karte im Plan-Startzustand', (tester) async {
|
child: BlocProvider(
|
||||||
await tester.pumpWidget(
|
create: (_) => AppModeCubit(),
|
||||||
ProviderScope(
|
child: MaterialApp(home: child),
|
||||||
child: BlocProvider(
|
|
||||||
create: (_) => AppModeCubit(),
|
|
||||||
child: const MaterialApp(home: AppShell()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
// Nur ein Frame pumpen statt pumpAndSettle: TileLayer laedt echte
|
||||||
// Netzwerk-Kacheln, die in der Testumgebung nie ankommen.
|
// Netzwerk-Kacheln, die in der Testumgebung nie ankommen.
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(FlutterMap), findsOneWidget);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user