Make inactive mode-switch button bleed to the header's true edge

The inactive Plan/Fly button used to sit inset by the header's own
padding on all sides, leaving a visible strip of the header tint
around it instead of filling the header exactly like the HTML
prototype. CSS solves this with a negative margin, which Flutter's
Padding widget rejects (padding.isNonNegative assertion) - restructured
so only the active button stays wrapped in the inset padding, while the
inactive one is a raw Row child that naturally touches the header
container's true top/bottom/outer edges and gets clipped to its rounded
corner. Also adds a short gradient from the header's own tint into the
button's solid color at the seam, softening what used to be a hard
color edge.
This commit is contained in:
Constantin Leue
2026-08-07 18:23:20 +02:00
parent d149d539f9
commit 853cce5b26
+110 -31
View File
@@ -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<AppModeCubit>().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<AppModeCubit>().toFly();
// Eintritt in den Fly-Modus setzt immer auf Waypoint
@@ -126,29 +157,62 @@ 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,
// 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],
),
decoration: BoxDecoration(
color: isActive ? null : inactiveColor.withValues(alpha: 0.85),
borderRadius: isActive ? null : cornerRadius,
),
child: Text(
label,
@@ -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,
);
}
}