diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index 699419c..f0ddb6a 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -29,9 +29,26 @@ import 'map_search_controls.dart'; /// (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; + 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); @@ -50,6 +67,20 @@ class TopModeBar extends ConsumerWidget { 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, @@ -59,12 +90,18 @@ class TopModeBar extends ConsumerWidget { child: FractionallySizedBox( widthFactor: 0.77, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + // 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(26), - bottomRight: Radius.circular(26), + bottomLeft: Radius.circular(_pillRadius), + bottomRight: Radius.circular(_pillRadius), ), ), child: Row( @@ -72,34 +109,28 @@ class TopModeBar extends ConsumerWidget { _ModeButton( label: 'Plan', isActive: !isFly, + activeTint: activeTint, inactiveColor: DmcColors.planModeColor, - cornerRadius: const BorderRadius.only( - bottomLeft: Radius.circular(26), - ), + isLeftButton: true, onTap: () => context.read().toPlan(), ), - if (isFly) ...[ - const SizedBox(width: 8), - const HeaderWindPill(), - const SizedBox(width: 8), - const Expanded(child: FlightModePill()), - const SizedBox(width: 8), - const FlyMapControls(), - const SizedBox(width: 8), - ] else ...[ - const SizedBox(width: 8), - const HeaderWindPill(), - const SizedBox(width: 8), - const Expanded(child: MapSearchControls()), - const SizedBox(width: 12), - ], + 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, - cornerRadius: const BorderRadius.only( - bottomRight: Radius.circular(26), - ), + isLeftButton: false, onTap: () { context.read().toFly(); // Eintritt in den Fly-Modus setzt immer auf Waypoint @@ -126,30 +157,63 @@ class _ModeButton extends StatelessWidget { const _ModeButton({ required this.label, required this.isActive, + required this.activeTint, required this.inactiveColor, - required this.cornerRadius, + 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; - final BorderRadius cornerRadius; + + /// 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) { - return GestureDetector( + final button = 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, + // 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( @@ -160,5 +224,20 @@ class _ModeButton extends StatelessWidget { ), ), ); + + 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, + ); } }