diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index b86d680..d83bdb0 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -289,6 +289,7 @@ class _FlyScreenState extends ConsumerState { flyMissionStatus: flyMissionStatus, warnings: warnings, showWarningsBanner: false, + batteryPercent: telemetry?.batteryPercent, onDetailsTap: _openWaypointList, onWarningsTap: () => _openWarningsPanel(warnings), onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( diff --git a/app/lib/ui/widgets/battery_indicator_bar.dart b/app/lib/ui/widgets/battery_indicator_bar.dart new file mode 100644 index 0000000..1eaa3d0 --- /dev/null +++ b/app/lib/ui/widgets/battery_indicator_bar.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; + +import '../dmc_colors.dart'; + +/// Schmaler Batteriebalken ueber der Fusszeile im Fly-Modus (Doku: +/// "Balken ueber die gesamte Breite, feste Farbzonen von links: 20% rot +/// (sofort landen), 30% orange (landen), Rest gruen"). Nur sichtbar, +/// solange Telemetrie vorliegt - siehe Aufrufstelle in MissionFooterBar, +/// die dafuer sorgt, dass die Anzeige im Plan-Modus (kein [percent]) +/// entfaellt. +/// +/// Die Farbzonen sind an FESTEN Positionen im Balken verankert, gemessen +/// vom LINKEN Rand (0-20% rot, 20-30% orange, 30-100% gruen) - "30% +/// orange" bedeutet also, dass die orangene Zone bis zur 30%-Marke reicht +/// (nur 10 Prozentpunkte weiter als rot), nicht dass sie selbst 30 Punkte +/// breit ist. Die gefuellte Laenge (= [percent] der Gesamtbreite) schrumpft +/// mit sinkendem Ladestand von rechts nach links, bis irgendwann nur noch +/// der orangene bzw. rote Bereich links uebrig bleibt. Berechnet die je +/// Zone tatsaechlich sichtbare Breite direkt in Dart (statt ueber Align. +/// widthFactor + ClipRect zu clippen) - robuster gegenueber Layout- +/// Eigenheiten und leichter nachvollziehbar. Farben rot/gruen bewusst +/// identisch zur Kopfleiste (DmcColors.warnRed/flyModeColor), Orange aus +/// der sonst im Rest der App bereits verwendeten Amber-Farbe +/// (DmcColors.pafAmber) - der Screenshot in design/ diente nur als +/// Layout-Vorlage, nicht als Farbvorgabe. +class BatteryIndicatorBar extends StatelessWidget { + const BatteryIndicatorBar({super.key, required this.percent}); + + /// 0-100, ungeklemmter Rohwert aus TelemetryFrame.batteryPercent. + final int percent; + + /// Absolute Positionen (vom linken Rand aus), an denen rot in orange und + /// orange in gruen uebergeht - siehe Klassendoku. + static const _redZoneEndFraction = 0.20; + static const _amberZoneEndFraction = 0.30; + + static const _barHeight = 10.0; + static const _pillHeight = 22.0; + static const _pillMinWidth = 46.0; + + /// Durchsichtiger als der Fusszeilen-Hintergrund (0.72 in + /// MissionFooterBar) - der Balken liegt ueber diesem bereits halb- + /// opaken Hintergrund, ohne eigene zusaetzliche Abdunkelung waere er + /// also automatisch schon genauso transparent; dieser niedrige Wert + /// sorgt dafuer, dass er sogar noch etwas heller/durchsichtiger wirkt. + static const _trackAlpha = 0.12; + static const _fillAlpha = 0.55; + static const _pillNeutralAlpha = 0.55; + static const _pillWarningAlpha = 0.8; + + @override + Widget build(BuildContext context) { + final clampedPercent = percent.clamp(0, 100); + + return SizedBox( + height: _pillHeight, + width: double.infinity, + child: LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final fillWidth = totalWidth * clampedPercent / 100; + + final redZoneEnd = totalWidth * _redZoneEndFraction; + final amberZoneEnd = totalWidth * _amberZoneEndFraction; + final redZoneWidth = redZoneEnd; + final amberZoneWidth = amberZoneEnd - redZoneEnd; + final greenZoneWidth = totalWidth - amberZoneEnd; + + // Je Zone nur so viel sichtbar wie vom aktuellen Fuellstand noch + // "uebrig" ist, sobald die vorherigen Zonen bereits voll + // abgedeckt sind - siehe Klassendoku. + final redVisible = fillWidth.clamp(0.0, redZoneWidth); + final amberVisible = (fillWidth - redZoneWidth).clamp(0.0, amberZoneWidth); + final greenVisible = + (fillWidth - redZoneWidth - amberZoneWidth).clamp(0.0, greenZoneWidth); + + final pillLeft = (fillWidth - _pillMinWidth / 2).clamp( + 0.0, + (totalWidth - _pillMinWidth) < 0 ? 0.0 : totalWidth - _pillMinWidth, + ); + + // Faerbt die Prozent-Pille passend zur Zone, in der der aktuelle + // Ladestand liegt (Doku: "sobald die Pille den farbigen [Warn-] + // Bereich erreicht, auch die Pille entsprechend faerben") - im + // gruenen (normalen) Bereich bleibt sie neutral dunkel, um nicht + // staendig unnoetig Aufmerksamkeit zu ziehen. + final Color pillColor; + if (clampedPercent <= _redZoneEndFraction * 100) { + pillColor = DmcColors.warnRed.withValues(alpha: _pillWarningAlpha); + } else if (clampedPercent <= _amberZoneEndFraction * 100) { + pillColor = DmcColors.pafAmber.withValues(alpha: _pillWarningAlpha); + } else { + pillColor = Colors.black.withValues(alpha: _pillNeutralAlpha); + } + + return Stack( + clipBehavior: Clip.none, + children: [ + // Transparenter Hintergrund fuer den ungefuellten Rest (Doku: + // "Transparenz des Hintergrunds hoeher machen als in der + // Fusszeile") - nur der dunkle Fusszeilen-Hintergrund scheint + // durch. + Positioned( + left: 0, + right: 0, + top: (_pillHeight - _barHeight) / 2, + height: _barHeight, + child: ColoredBox(color: Colors.black.withValues(alpha: _trackAlpha)), + ), + Positioned( + left: 0, + top: (_pillHeight - _barHeight) / 2, + height: _barHeight, + child: Row( + children: [ + ColoredBox( + color: DmcColors.warnRed.withValues(alpha: _fillAlpha), + child: SizedBox(width: redVisible, height: _barHeight), + ), + ColoredBox( + color: DmcColors.pafAmber.withValues(alpha: _fillAlpha), + child: SizedBox(width: amberVisible, height: _barHeight), + ), + ColoredBox( + color: DmcColors.flyModeColor.withValues(alpha: _fillAlpha), + child: SizedBox(width: greenVisible, height: _barHeight), + ), + ], + ), + ), + Positioned( + left: pillLeft, + child: Container( + height: _pillHeight, + constraints: const BoxConstraints(minWidth: _pillMinWidth), + padding: const EdgeInsets.symmetric(horizontal: 6), + alignment: Alignment.center, + decoration: BoxDecoration( + color: pillColor, + borderRadius: BorderRadius.circular(_pillHeight / 2), + ), + child: Text( + '$clampedPercent%', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index 57a5a70..fc558f8 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -5,6 +5,7 @@ import '../../domain/mission/mission_stats.dart'; import '../../domain/mission/mission_warnings.dart'; import '../../domain/waypoint/flat_waypoint_list.dart'; import '../providers/altitude_profile_provider.dart'; +import 'battery_indicator_bar.dart'; import 'bottom_stats_bar.dart'; import 'mini_altitude_profile.dart'; @@ -24,6 +25,7 @@ class MissionFooterBar extends ConsumerWidget { this.flyMissionStatus, required this.warnings, this.showWarningsBanner = true, + this.batteryPercent, required this.onDetailsTap, required this.onWarningsTap, required this.onSettingsTap, @@ -49,6 +51,12 @@ class MissionFooterBar extends ConsumerWidget { /// davon unberuehrt, nur die staendig sichtbare Textzeile verschwindet. /// Default true, damit der Plan-Modus unveraendert bleibt. final bool showWarningsBanner; + + /// Nur im Fly-Modus gesetzt (Doku: "Batterieanzeige im Fly-Modus + /// hinzufuegen") - zeigt den Batteriebalken ueber der BottomStatsBar an, + /// unter einem ggf. ausgeklappten Hoehenprofil. Bleibt im Plan-Modus null + /// (keine Telemetrie), die Anzeige entfaellt dann vollstaendig. + final int? batteryPercent; final VoidCallback onDetailsTap; final VoidCallback onWarningsTap; final VoidCallback onSettingsTap; @@ -87,6 +95,11 @@ class MissionFooterBar extends ConsumerWidget { activeIndex: activeIndex, ), ), + if (batteryPercent != null) + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 0), + child: BatteryIndicatorBar(percent: batteryPercent!), + ), BottomStatsBar( waypointCount: waypoints.length, totalDistanceM: totalDistanceM,