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; // 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 = 5.0; 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, 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, 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.inactiveColor, required this.isLeftButton, required this.onTap, }); final String label; final bool isActive; 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.only( // Auf der Nahtstellenseite (zum mittleren Inhalt hin, wo der // Farbverlauf transparent beginnt - siehe unten) bekommt der Text // im inaktiven Zustand mehr Abstand als die uebliche 24 (Nutzer- // Feedback: "der Verlauf ist perfekt aber zu nah am text") - die // Aussenseite (zur Leistenkante hin) bleibt bei 24, dort liegt // laengst die volle Deckkraft an. left: !isActive && !isLeftButton ? 38 : 24, right: !isActive && isLeftButton ? 38 : 24, // Inaktiv: 6 (= aktives Padding) + TopModeBar._outerPaddingV, statt // eines fest verdrahteten Werts - dieser Button ist roh, ohne // umgebendes Leisten-Padding (siehe Klassendoku), muss also selbst // GENAU den Betrag zusaetzlich tragen, den der aktive Button durch // sein wrappendes Padding bekommt, um exakt gleich hoch wie dieser // (und wie der mittlere Inhalt) auszufallen. Ein fest verdrahteter // Wert bliebe sonst nach einer Aenderung von _outerPaddingV zu // gross und wuerde weiterhin ueberragen (frueher beobachtet: eine // Halbierung von _outerPaddingV blieb dadurch am sichtbaren // Randabstand komplett wirkungslos, weil dieser Button ohnehin // schon der hoechste Row-Inhalt war und es dank des zu grossen // festen Werts blieb). top: isActive ? 6 : 6 + TopModeBar._outerPaddingV, bottom: isActive ? 6 : 6 + TopModeBar._outerPaddingV, ), decoration: isActive ? null : BoxDecoration( // 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"). Reiner // Alpha-Verlauf DERSELBEN inactiveColor (0.0 -> 0.85) statt // einer RGB-Ueberblendung zwischen zwei verschiedenen Farben - // Letzteres erzeugte einen sichtbar "verschmutzten" // Zwischenton, der wie eine harte Kante mitten im Button // wirkte, statt wie ein weiches Einblenden (Nutzer-Feedback: // "der Verlauf schneidet in den Knopf rein"). Bei Alpha 0 // liegt exakt die Toenung des umgebenden TopModeBar- // Containers darunter (dieselbe activeTint-Farbe, derselbe // Alpha-Wert 0.55, siehe dortige Decoration), der Uebergang // an der Nahtstelle ist dadurch von selbst nahtlos. Stop bei // 45% statt vorher 30% laesst mehr Abstand, bevor die // Volltonflaeche des eigentlichen Knopfes beginnt (ebenfalls // Nutzer-Feedback). gradient: LinearGradient( begin: isLeftButton ? Alignment.centerRight : Alignment.centerLeft, end: isLeftButton ? Alignment.centerLeft : Alignment.centerRight, colors: [ inactiveColor.withValues(alpha: 0.0), inactiveColor.withValues(alpha: 0.85), ], stops: const [0.0, 0.45], ), ), 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, ); } }