511 lines
20 KiB
Dart
511 lines
20 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 '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<MissionWarning> 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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|