Files
dmc/app/lib/ui/widgets/bottom_stats_bar.dart
T
Constantin LeueandClaude Sonnet 5 b09d00ab9f Rename the Fly-mode Warnings tab to System Messages, log connection/upload events
Renames DroneEvent/DroneEventSeverity/droneEventLogProvider/
DroneStatusWarningsPanel to SystemMessage/SystemMessageSeverity/
systemMessageLogProvider/DroneStatusMessagesPanel throughout, matching
what the tab now actually shows - general system messages, not just
drone-health warnings.

Adds a new SystemMessageSeverity.info level (blue) for messages that
aren't a warning/error, and three new message sources on top of the
existing battery/failsafe detection:
- "Connected", logged the first time telemetryProvider produces a frame
  after having none - the mirror image of the existing "Connection lost"
  detection (which already fires on the first stream error after having
  had data), so no new transport-specific dependency was needed.
- "Connection type changed to X", from watching connectionSettingsProvider
  (skips the initial load so it doesn't fire on every app start).
- "Mission sent (N waypoints)" / "Mission upload failed: ...", logged
  from FlyScreen's send handler via a new public log() method on the
  notifier, alongside the existing snackbar.

Verified on the Pixel_10a emulator: entering Fly mode logs "Connected"
once the mock telemetry starts, and tapping the send button logs
"Mission sent (3 waypoints)" right after the snackbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:47:38 +02:00

580 lines
22 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/mission_warnings.dart';
import '../../domain/mission/mission_stats.dart';
import '../../domain/telemetry/drone_status.dart';
import '../dmc_colors.dart';
import '../providers/active_drone_profile_provider.dart';
import '../providers/altitude_profile_provider.dart';
import '../providers/mission_meta_provider.dart';
import 'drone_status_messages_panel.dart';
import 'missions_drones_panel.dart';
import 'mountain_peak_icon.dart';
import 'paper_plane_icon.dart';
/// Einheitliche Hoehe fuer alle Pillen/Knoepfe der Fusszeile (Doku: "fuer
/// alle Pillen und Buttons die gleiche Hoehe wie bei der Details-Pille
/// verwenden") - vorher hatten Text-Pillen (implizit aus Padding+Zeilen-
/// hoehe) und die runden Knoepfe (fest 34px) leicht unterschiedliche
/// Hoehen, was in der Fusszeile ein unruhiges Bild ergab.
const _footerElementHeight = 36.0;
/// Einheitlicher Abstand zwischen allen Pillen/Knoepfen der Fusszeile
/// (Doku: "vereinheitliche den Abstand zwischen Buttons und Pillen") -
/// vorher fehlte er stellenweise ganz (z.B. zwischen Details- und
/// Drohnen-Pille), an anderen Stellen war er fest auf 6px verdrahtet.
const _footerElementSpacing = 8.0;
/// Einheitliche Icon-/Textgroesse und Schriftgewicht fuer alle Pillen und
/// Knoepfe der Fusszeile (Doku: "text und symbol hoehe in allen pillen und
/// buttons der fusszeile genau so machen wie in der neuen drone pille.
/// einheitliche schriftart und gewicht verwenden") - uebernommen von der
/// zuvor eigens dafuer eingefuehrten Drohnen-Status-Pille. Eine eigene
/// Schriftart wird nicht gesetzt, alle Texte hier verwenden bereits ohne
/// explizites fontFamily das App-Theme, sind also schon einheitlich.
const _footerIconSize = 20.0;
const _footerFontSize = 14.0;
const _footerFontWeight = FontWeight.w600;
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
/// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste;
/// Mission-Chip links und Drohnen-Chip rechts oeffnen das Verwaltungsmenue
/// mit dem passenden Starttab (HTML-Demonstrator: #missionNameBtn/
/// #droneNameBtn). Warnungs- (Plan-Modus) bzw. Sende-Knopf (Fly-Modus,
/// siehe onSendTap) und Settings-Knopf sitzen neben dem Drohnen-Chip
/// rechts aussen. Der Hoehenprofil-Toggle (Bergkuppen-Icon) sitzt links
/// der Details-Pille.
class BottomStatsBar extends ConsumerWidget {
const BottomStatsBar({
super.key,
required this.waypointCount,
required this.totalDistanceM,
required this.totalDurationSec,
this.flyMissionStatus,
this.droneStatus,
required this.onTap,
required this.onSettingsTap,
this.warnings = const [],
this.onWarningsTap,
this.onSendTap,
this.sending = false,
});
final int waypointCount;
final double totalDistanceM;
final double totalDurationSec;
/// Siehe MissionFooterBar-Doku - nur im Fly-Modus gesetzt, ersetzt dann
/// die statischen Felder in der Details-Pille.
final FlyMissionStatus? flyMissionStatus;
/// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille
/// den zustand der drohne") - ersetzt dort den Drohnennamen-Chip durch
/// die 3-Icon-Statusanzeige (Verbindung/Position/Zustand).
final DroneStatus? droneStatus;
final VoidCallback onTap;
/// Oeffnet das Settings-Menue (runder Zahnrad-Knopf rechts der
/// Details-Pille).
final VoidCallback onSettingsTap;
/// Aktive Warnungen zur Mission (Doku 3.10) - der Button erscheint nur,
/// solange diese Liste nicht leer ist (HTML-Demonstrator: #warningsBtn,
/// per default versteckt).
final List<MissionWarning> warnings;
final VoidCallback? onWarningsTap;
/// Nur im Fly-Modus gesetzt (Doku: "im fly screen den warnings button
/// durch einen wp send button ersetzen") - wenn gesetzt, ersetzt der
/// Sende-Knopf (Papierflieger, horizontal, pink) den runden Warnungs-
/// Knopf komplett, unabhaengig von [warnings]. System Messages (Doku:
/// "aendere das warnings Menü in System Messages") bleiben im Fly-Modus
/// weiterhin ueber die Drohnen-Status-Pille erreichbar (dort als eigener
/// Tab), siehe DroneStatusMessagesPanel.
final VoidCallback? onSendTap;
/// Zeigt einen Ladeindikator statt des Papierflieger-Icons und ignoriert
/// Taps, waehrend ein Upload laeuft (verhindert doppeltes Abschicken).
final bool sending;
@override
Widget build(BuildContext context, WidgetRef ref) {
final missionName = ref.watch(currentMissionMetaProvider).name;
final droneName = ref.watch(activeDroneProfileProvider).name;
final isAltitudeProfileExpanded = ref.watch(altitudeProfileExpandedProvider);
// Gespiegelter Randabstand (wie in PlanScreen fuer die Drehraeder, Doku:
// "Symmetrischer Randabstand auf beiden Seiten"): die groessere der
// beiden echten Seiten-Insets wird auf BEIDE Seiten angewendet, statt
// SafeAreas Standardverhalten (jede Seite nur ihren eigenen, ggf. 0)
// zu uebernehmen - sonst waere z.B. eine Frontkamera-Aussparung im
// Querformat nur auf einer Seite der Fusszeile sichtbar beruecksichtigt,
// je nachdem wie herum das Geraet gehalten wird.
final safeEdgeInset = math.max(
MediaQuery.of(context).padding.left,
MediaQuery.of(context).padding.right,
);
// Row statt Stack: ein Align/Center ohne width-/heightFactor nimmt sich
// immer den gesamten verfuegbaren Platz seines Elternwidgets (auch wenn
// sein Kind viel kleiner ist) - in einem Stack ohne begrenzende
// Positioned-Kinder blaeht das dessen eigene Groesse unkontrolliert auf
// und verschiebt die Details-Pille aus ihrer sichtbaren Position.
// Zwei gleich grosse Expanded-Bereiche links/rechts der Pille halten sie
// dagegen zuverlaessig zentriert.
return SafeArea(
top: false,
left: false,
right: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
12 + safeEdgeInset,
10,
12 + safeEdgeInset,
10,
),
child: Row(
children: [
// Nur noch die Mission-Pille links - sie darf die gesamte
// verfuegbare Breite dieser Seite nutzen (Align loosent die
// Constraints statt sie wie Expanded zu erzwingen, das
// begrenzt auf die verfuegbare Breite, ohne einen kuenstlich
// kleinen Fixwert vorzugeben).
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: _NameChip(
label: 'Mission: $missionName',
color: Colors.white.withValues(alpha: 0.12),
textColor: Colors.white,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const MissionsDronesPanel.missions(),
)),
),
),
),
const SizedBox(width: _footerElementSpacing),
_AltitudeProfileToggleButton(
isExpanded: isAltitudeProfileExpanded,
onTap: () => ref
.read(altitudeProfileExpandedProvider.notifier)
.toggle(),
),
const SizedBox(width: _footerElementSpacing),
_detailsPill(),
const SizedBox(width: _footerElementSpacing),
// Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille
// sitzt in Flexible, damit sie bei langen Namen zu Gunsten der
// beiden festgroessen Rundknoepfe schrumpft/ellipsiert, statt
// sie aus dem sichtbaren Bereich zu draengen.
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: droneStatus != null
? _DroneStatusPill(
status: droneStatus!,
// Oeffnet im Fly-Modus das neue Status-Menue
// (Doku: "ein neues menue anlegen ... nach dem
// vorbild des menues fuer drones and
// missions") direkt auf dem Status-Tab, statt
// wie zuvor die Drohnenprofil-Verwaltung -
// Profile werden waehrend des Fluges ohnehin
// nicht gewechselt.
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const DroneStatusMessagesPanel.status(),
)),
)
: _NameChip(
// Gleicher Hintergrund wie die Details-Pille
// (Doku: "gleichen Hintergrund wie bei der
// Detail Pille fuer die Drohne Pille
// verwenden") statt des vorherigen blauen
// Farbtons.
label: 'Drone: $droneName',
color: Colors.white.withValues(alpha: 0.12),
textColor: Colors.white,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const MissionsDronesPanel.drones(),
)),
),
),
const SizedBox(width: _footerElementSpacing),
if (onSendTap != null) ...[
_SendButton(sending: sending, onTap: onSendTap),
const SizedBox(width: _footerElementSpacing),
] else if (warnings.isNotEmpty) ...[
_WarningButton(onTap: onWarningsTap),
const SizedBox(width: _footerElementSpacing),
],
_SettingsButton(onTap: onSettingsTap),
],
),
),
),
],
),
),
);
}
Widget _detailsPill() {
final flyStatus = flyMissionStatus;
// Im Fly-Modus (flyStatus != null) ersetzen aktiver Wegpunkt sowie
// Distanz/Zeit dorthin die statischen Plan-Werte (Doku: "Live-Status
// der Mission in der Details-Pille, genau wie vorher aber mit aktivem
// Wegpunkt statt Gesamtzahl, Distanz/Zeit zum naechsten Wegpunkt statt
// Gesamtdistanz/-dauer").
final waypointText = flyStatus != null
? '${flyStatus.activeWaypointNumber}/${flyStatus.waypointCount}'
: '$waypointCount WP';
final distanceText = flyStatus != null
? (flyStatus.distanceToActiveWaypointM != null
? '${flyStatus.distanceToActiveWaypointM!.round()} m'
: '—')
: '${totalDistanceM.round()} m';
final durationText = flyStatus != null
? (flyStatus.etaToActiveWaypointSec != null
? _fmtDuration(flyStatus.etaToActiveWaypointSec!)
: '—')
: _fmtDuration(totalDurationSec);
return GestureDetector(
onTap: onTap,
child: Container(
height: _footerElementHeight,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Details:',
style: TextStyle(
color: Colors.white70,
fontWeight: _footerFontWeight,
fontSize: _footerFontSize,
),
),
const SizedBox(width: 10),
const Icon(Icons.flag, size: _footerIconSize, color: Colors.white),
const SizedBox(width: 4),
Text(waypointText,
style: const TextStyle(
color: Colors.white,
fontSize: _footerFontSize,
fontWeight: _footerFontWeight,
)),
const SizedBox(width: 14),
const Icon(Icons.straighten, size: _footerIconSize, color: Colors.white),
const SizedBox(width: 4),
Text(distanceText,
style: const TextStyle(
color: Colors.white,
fontSize: _footerFontSize,
fontWeight: _footerFontWeight,
)),
const SizedBox(width: 14),
const Icon(Icons.access_time, size: _footerIconSize, color: Colors.white),
const SizedBox(width: 4),
Text(durationText,
style: const TextStyle(
color: Colors.white,
fontSize: _footerFontSize,
fontWeight: _footerFontWeight,
)),
],
),
),
);
}
String _fmtDuration(double seconds) {
final total = seconds.round();
final minutes = total ~/ 60;
final secs = total % 60;
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
}
/// Runder Knopf links der Details-Pille zum Ein-/Ausklappen des
/// Mini-Hoehenprofils. Zeigt eine stilisierte Bergkuppe: ausgeklappt steht
/// sie kopfueber (Zacken zeigen nach unten - signalisiert "einklappen"),
/// eingeklappt (Standard, um moeglichst viel Kartenflaeche frei zu lassen)
/// richtig herum (Zacken zeigen nach oben - signalisiert "ausklappen").
class _AltitudeProfileToggleButton extends StatelessWidget {
const _AltitudeProfileToggleButton({
required this.isExpanded,
required this.onTap,
});
final bool isExpanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
child: Center(
child: MountainPeakIcon(
size: _footerIconSize,
color: Colors.white,
upsideDown: isExpanded,
),
),
),
);
}
}
/// Kleiner, roter Rundknopf fuer aktive Warnungen (Doku 3.10, HTML-
/// Demonstrator: #warningsBtn) - oeffnet die Warnungsliste.
class _WarningButton extends StatelessWidget {
const _WarningButton({required this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE2574A).withValues(alpha: 0.18),
border: Border.all(color: const Color(0xFFE2574A).withValues(alpha: 0.5)),
),
child: const Icon(Icons.warning_amber_rounded, color: Color(0xFFFFB3A8), size: _footerIconSize),
),
);
}
}
/// Runder Sende-Knopf im Fly-Modus (Doku: "im fly screen den warnings
/// button durch einen wp send button ersetzen. verwende unser paperplane
/// shape. ausrichtung horizontal und pink gefuellt") - ersetzt dort den
/// Warnungs-Knopf komplett, siehe BottomStatsBar.onSendTap.
class _SendButton extends StatelessWidget {
const _SendButton({required this.sending, required this.onTap});
final bool sending;
final VoidCallback? onTap;
static const _pink = Color(0xFFFF2E93);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: sending ? null : onTap,
child: Container(
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
child: Center(
child: sending
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: _pink),
)
// Papierflieger liegend statt der Spitze-nach-oben-
// Kartenmarker-Orientierung (Doku: "ausrichtung horizontal") -
// um 90 Grad im Uhrzeigersinn gedreht zeigt die Nase nach
// rechts, wie ein abgeschickter/wegfliegender Flieger.
: Transform.rotate(
angle: math.pi / 2,
child: const PaperPlaneIcon(size: _footerIconSize, color: _pink),
),
),
),
);
}
}
/// Runder Zahnrad-Knopf rechts der Details-Pille - oeffnet das
/// Settings-Menue (SettingsScreen).
class _SettingsButton extends StatelessWidget {
const _SettingsButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
child: const Icon(Icons.settings, color: Colors.white, size: _footerIconSize),
),
);
}
}
/// Ersetzt im Fly-Modus die Drohnen-Namen-Pille (Doku: "im fly modus zeigt
/// die drone pille den zustand der drohne. das beinhaltet diese 3
/// elemente: connection quality ..., position quality ..., drone state
/// ..."). Farbcodierung siehe computeDroneStatus in
/// domain/telemetry/drone_status.dart; das Papierflieger-Icon zeigt
/// zusaetzlich einen Kurztext (idle/ready/armed/waypoint/error).
class _DroneStatusPill extends StatelessWidget {
const _DroneStatusPill({required this.status, required this.onTap});
final DroneStatus status;
final VoidCallback onTap;
static const _stateLabels = {
DroneState.idle: 'idle',
DroneState.ready: 'ready',
DroneState.armed: 'armed',
DroneState.waypoint: 'waypoint',
DroneState.error: 'error',
};
/// Nur 3 Farben fuer 5 Zustaende (Doku: "papierflieger icon mit
/// farbcodierung (gruen/gelb/rot)") - gruen fuer die beiden Zustaende, in
/// denen alles nominal laeuft (bereit zum Start bzw. aktiv unterwegs),
/// gelb fuer die beiden "Achtung"-Uebergangszustaende (am Boden wartend
/// bzw. bereits scharf aber ohne aktive Wegpunktnavigation, z.B. manuell
/// oder im Hold), rot ausschliesslich fuer Emergency.
static const _stateColors = {
DroneState.idle: DmcColors.pafAmber,
DroneState.ready: DmcColors.flyModeColor,
DroneState.armed: DmcColors.pafAmber,
DroneState.waypoint: DmcColors.flyModeColor,
DroneState.error: DmcColors.warnRed,
};
static const _signalColors = {
SignalLevel.green: DmcColors.flyModeColor,
SignalLevel.yellow: DmcColors.pafAmber,
SignalLevel.red: DmcColors.warnRed,
};
@override
Widget build(BuildContext context) {
final stateColor = _stateColors[status.state]!;
return GestureDetector(
onTap: onTap,
child: Container(
height: _footerElementHeight,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
),
// FittedBox statt eines festen Layouts: die Pille sitzt in einem
// Flexible neben Warnungs-/Settings-Knopf (BottomStatsBar) und hat
// je nach Bildschirmbreite/Drohnennamenslaenge unterschiedlich viel
// Platz - ohne Skalierung wuerde 3 Icons + Statustext auf schmalen
// Screens einen RenderFlex-Overflow ausloesen statt einfach etwas
// kleiner zu werden.
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Papierflieger-Symbol ganz links in der Pille (Doku: "fuer
// das drohnen symbol ... unsere standard paperplane form
// verwenden und nach ganz links innerhalb der pille
// anordnen") - unsere Standardform (dieselben Koordinaten
// wie DroneMarkerIcon/DroneFollowIcon), hier einfarbig nach
// Zustand statt der zweifarbigen/pinken Kartenmarker-Variante.
// Zustandstext folgt erst ganz am Ende hinter den beiden
// Qualitaets-Symbolen (Doku: "text fuer drone state als
// letztes hinter den symbolen anzeigen"). Icon-/Textgroessen
// an die Pillenhoehe angeglichen (Doku: "hoehe der pille
// besser ausnutzen") statt der vorher recht kleinen 12-15px.
PaperPlaneIcon(size: _footerIconSize, color: stateColor),
const SizedBox(width: 8),
Icon(Icons.signal_cellular_alt,
size: _footerIconSize, color: _signalColors[status.connectionQuality]),
const SizedBox(width: 8),
Icon(Icons.satellite_alt,
size: _footerIconSize, color: _signalColors[status.positionQuality]),
const SizedBox(width: 10),
Text(
_stateLabels[status.state]!,
style: TextStyle(
color: stateColor,
fontWeight: _footerFontWeight,
fontSize: _footerFontSize,
),
),
],
),
),
),
);
}
}
class _NameChip extends StatelessWidget {
const _NameChip({
required this.label,
required this.color,
required this.textColor,
required this.onTap,
});
final String label;
final Color color;
final Color textColor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// Kein fester maxWidth mehr - die Pille darf die verfuegbare Breite
// ihres Elternwidgets nutzen (Align/Flexible am Aufrufort begrenzen sie
// bereits auf die dort tatsaechlich verfuegbare Breite).
return GestureDetector(
onTap: onTap,
child: Container(
height: _footerElementHeight,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
),
child: Text(
label,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: textColor,
fontWeight: _footerFontWeight,
fontSize: _footerFontSize,
),
),
),
);
}
}