Files
dmc/app/lib/ui/widgets/top_mode_bar.dart
T
Constantin Leue b9d0b947c9 Give inactive mode-button text more clearance from the gradient
The gradient itself was fine, but the button's symmetric 24px padding
put the label too close to the fade zone on its seam side (a short
word like "Fly" left little room). Bumped padding to 38 on that side
only, leaving the outer side untouched.
2026-08-07 22:31:55 +02:00

263 lines
11 KiB
Dart

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<AppModeCubit>().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<AppModeCubit>().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,
);
}
}