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 'fly_map_controls.dart'; import 'header_wind_pill.dart'; import 'map_search_controls.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. /// /// Traegt ausserhalb des Fly-Modus zusaetzlich die Wind-Pille, Ortssuche und /// die Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #headerWindPill/ /// #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. /// /// Im Fly-Modus traegt sie stattdessen die Wind-Pille (Doku 7.2: "Wind- /// 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. /// /// Der inaktive Moduswechsel-Button (im HTML-Demonstrator per negativem /// CSS-Margin geloest, `#topBarWrap.mode-edit #flyModeBtn{margin:-5px -10px /// -5px 0}`) sitzt hier bewusst als ROHES Row-Kind ohne umgebendes Padding /// (Flutters `Padding`-Widget erlaubt anders als CSS-Margin keine negativen /// Werte, siehe `padding.isNonNegative`-Assertion) - dadurch beruehrt er von /// selbst alle drei Aussenkanten der Leiste (oben/unten/aussen) und wird von /// deren abgerundetem Container (clipBehavior: Clip.antiAlias) exakt an der /// Kontur beschnitten. Der aktive Button bleibt dagegen ueber ein /// zusaetzliches Padding im Leisteninneren eingerueckt. class TopModeBar extends ConsumerWidget { const TopModeBar({super.key}); /// Einrueckung des jeweils AKTIVEN Moduswechsel-Buttons gegenueber der /// Leistenkante (HTML-Demonstrator: `#topBarWrap{padding:5px 10px}`) - der /// inaktive Button verzichtet bewusst darauf, siehe Klassendoku oben. static const _outerPaddingH = 10.0; // Halbiert (Nutzerwunsch: vertikaler Innenabstand noch zu gross) - der // aktive Moduswechsel-Button und der mittlere Inhalt (Wind-Pille/ // Suchleiste/Flugmodus) bleiben oben/unten um diesen Betrag von der // Leistenkante eingerueckt, siehe Klassendoku. static const _outerPaddingV = 2.5; static const _pillRadius = 26.0; @override Widget build(BuildContext context, WidgetRef ref) { final isFly = context.select((AppModeCubit c) => c.state.mode == AppMode.fly); final activeTint = isFly ? DmcColors.flyModeColor : DmcColors.planModeColor; // Kein SafeArea: das wendet links/rechts die jeweils tatsaechlichen // (oft asymmetrischen) Insets an, wodurch die Leiste sichtbar von der // Bildschirmmitte abweicht, sobald eine Seite einen groesseren // Aussparungsbereich meldet als die andere. Stattdessen derselbe // symmetrische Randabstand wie bei den Drehraedern (PlanScreen: // safeEdgeInset) - dadurch bleibt die per FractionallySizedBox // zentrierte Leiste auch bei asymmetrischen Insets echt mittig. final topInset = MediaQuery.of(context).padding.top; final safeEdgeInset = math.max( MediaQuery.of(context).padding.left, MediaQuery.of(context).padding.right, ); final middleContent = isFly ? const [ HeaderWindPill(), SizedBox(width: 8), Expanded(child: FlightModePill()), SizedBox(width: 8), FlyMapControls(), ] : const [ HeaderWindPill(), SizedBox(width: 8), Expanded(child: MapSearchControls()), ]; return Padding( padding: EdgeInsets.only( top: topInset, left: safeEdgeInset, right: safeEdgeInset, ), child: FractionallySizedBox( widthFactor: 0.77, child: Container( // Schneidet den bis an den Rand reichenden inaktiven // Moduswechsel-Button (siehe Klassendoku) exakt an der // abgerundeten Leistenkontur ab, statt dass er rechteckig ueber // die Rundung hinaussteht - dieselbe Kontur uebernimmt gleich // auch die Eckenrundung fuer beide Buttons, eine eigene // BorderRadius je Button ist dadurch nicht mehr noetig. clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: activeTint.withValues(alpha: 0.55), borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(_pillRadius), bottomRight: Radius.circular(_pillRadius), ), ), child: Row( children: [ _ModeButton( label: 'Plan', isActive: !isFly, activeTint: activeTint, inactiveColor: DmcColors.planModeColor, isLeftButton: true, onTap: () => context.read().toPlan(), ), Expanded( child: Padding( padding: EdgeInsets.only( left: 8, right: isFly ? 8 : 12, top: _outerPaddingV, bottom: _outerPaddingV, ), child: Row(children: middleContent), ), ), _ModeButton( label: 'Fly', isActive: isFly, activeTint: activeTint, inactiveColor: DmcColors.flyModeColor, isLeftButton: false, onTap: () { context.read().toFly(); // Eintritt in den Fly-Modus setzt immer auf Waypoint // zurueck (Doku 3.11). ref.read(flightModeProvider.notifier).reset(); // Kein Auto-Connect mehr beim Wechsel in den Fly-Modus // (bewusst entfernt, um das Debuggen auf echter Hardware // zu vereinfachen - Verbindungsaufbau laeuft jetzt // ausschliesslich ueber den expliziten "Connect"-Knopf in // den Settings). Die Verbindung selbst bleibt ohnehin ueber // Moduswechsel hinweg bestehen, siehe // flightControllerLinkProvider. }, ), ], ), ), ), ); } } class _ModeButton extends StatelessWidget { const _ModeButton({ required this.label, required this.isActive, required this.activeTint, required this.inactiveColor, required this.isLeftButton, required this.onTap, }); final String label; final bool isActive; /// Aktuelle Kopfleisten-Toenung (TopModeBar.activeTint) - dient nur als /// Startfarbe des Farbverlaufs im inaktiven Zustand (siehe unten), der /// aktive Button hat ohnehin keinen eigenen Hintergrund. final Color activeTint; final Color inactiveColor; /// true = Plan-Button (linkes Ende der Leiste), false = Fly-Button /// (rechtes Ende) - bestimmt sowohl die Seite der Einrueckung im aktiven /// Zustand als auch die Verlaufsrichtung des Farbverlaufs im inaktiven. final bool isLeftButton; final VoidCallback onTap; @override Widget build(BuildContext context) { final button = GestureDetector( onTap: isActive ? null : onTap, child: Container( padding: EdgeInsets.symmetric( horizontal: 24, // Grosszuegiger als der aktive Zustand (6), damit dieser // Button - roh, ohne umgebendes Leisten-Padding, siehe // TopModeBar-Klassendoku - garantiert der hoechste Row-Inhalt // bleibt und dadurch die Leiste exakt auf ihre volle Hoehe // (Text + diese Polsterung) bringt, statt von einem anderen // Zeileninhalt (z.B. den 34px hohen Pillen in der Mitte) // ueberragt zu werden und sichtbaren Rand stehen zu lassen. vertical: isActive ? 6 : 16, ), decoration: isActive ? null : BoxDecoration( // Kurzer Farbverlauf von der Kopfleisten-eigenen Toenung zur // vollen Button-Farbe statt einer harten Kante an der // Nahtstelle zum mittleren Inhalt (Nutzerwunsch: "linearer // kurzer Farbverlauf zwischen button und rest header") - nur // die ersten 30% der Breite blenden ein, der Rest bleibt // solide, damit der Text weiterhin auf vollem Kontrast sitzt. gradient: LinearGradient( begin: isLeftButton ? Alignment.centerRight : Alignment.centerLeft, end: isLeftButton ? Alignment.centerLeft : Alignment.centerRight, colors: [ activeTint.withValues(alpha: 0.55), inactiveColor.withValues(alpha: 0.85), ], stops: const [0.0, 0.3], ), ), child: Text( label, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w800, color: Color(0xFF141414), ), ), ), ); if (!isActive) return button; // Nur der aktive Button bleibt gegenueber der Leistenkante eingerueckt // (HTML-Demonstrator: unveraendertes #topBarWrap-Padding gilt fuer ihn // weiterhin) - der inaktive bleibt roh (siehe Klassendoku). return Padding( padding: EdgeInsets.only( top: TopModeBar._outerPaddingV, bottom: TopModeBar._outerPaddingV, left: isLeftButton ? TopModeBar._outerPaddingH : 0, right: isLeftButton ? 0 : TopModeBar._outerPaddingH, ), child: button, ); } }