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 '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- und Settings-Knopf sitzen neben dem /// Drohnen-Chip rechts aussen. Der Hoehenprofil-Toggle (Bergkuppen-Icon) /// sitzt links der Details-Pille. /// Der Send-Button ist bewusst nicht Teil dieses Schritts - die dafuer /// noetige Upload-Anbindung existiert noch nicht. 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, }); 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 warnings; final VoidCallback? onWarningsTap; @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!, onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const MissionsDronesPanel.drones(), )), ) : _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 (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 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, ), ), ), ); } }