drone status pill in footer for fly mode implemented. footer layout and sizes homogenized

This commit is contained in:
Constantin Leue
2026-08-04 15:47:53 +02:00
parent f2f2953642
commit 1a17fe5b36
12 changed files with 585 additions and 28 deletions
+17
View File
@@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart';
import '../../../domain/mission/mission_stats.dart';
import '../../../domain/mission/mission_warnings.dart';
import '../../../domain/mission/route_geometry.dart';
import '../../../domain/telemetry/drone_status.dart';
import '../../../domain/wind/wind_math.dart';
import '../../../transport/flight_controller_link.dart';
import '../../providers/active_drone_profile_provider.dart';
@@ -239,6 +240,21 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
currentSpeedMs: telemetry?.speedMs,
);
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
// drone pille den zustand der drohne") - null solange keine Telemetrie
// vorliegt, dann zeigt BottomStatsBar weiterhin den Drohnennamen-Chip.
final droneStatus = telemetry != null
? computeDroneStatus(
armed: telemetry.armed,
hasFix: telemetry.hasFix,
numSat: telemetry.numSat,
hdop: telemetry.hdop,
linkQuality: telemetry.linkQuality,
snrDb: telemetry.snrDb,
navMode: telemetry.navMode,
)
: null;
return Stack(
children: [
MissionMap(
@@ -287,6 +303,7 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
flyMissionStatus: flyMissionStatus,
droneStatus: droneStatus,
warnings: warnings,
showWarningsBanner: false,
batteryPercent: telemetry?.batteryPercent,
+155 -22
View File
@@ -3,13 +3,29 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/mission_stats.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;
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
/// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste;
@@ -27,6 +43,7 @@ class BottomStatsBar extends ConsumerWidget {
required this.totalDistanceM,
required this.totalDurationSec,
this.flyMissionStatus,
this.droneStatus,
required this.onTap,
required this.onSettingsTap,
this.warnings = const [],
@@ -40,6 +57,11 @@ class BottomStatsBar extends ConsumerWidget {
/// 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
@@ -108,14 +130,16 @@ class BottomStatsBar extends ConsumerWidget {
),
),
),
const SizedBox(width: _footerElementSpacing),
_AltitudeProfileToggleButton(
isExpanded: isAltitudeProfileExpanded,
onTap: () => ref
.read(altitudeProfileExpandedProvider.notifier)
.toggle(),
),
const SizedBox(width: 6),
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
@@ -127,19 +151,31 @@ class BottomStatsBar extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: _NameChip(
label: 'Drone: $droneName',
color: const Color(0xFF4D9DFF).withValues(alpha: 0.18),
textColor: const Color(0xFFCFE2FF),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const MissionsDronesPanel.drones(),
)),
),
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: 6),
const SizedBox(width: _footerElementSpacing),
if (warnings.isNotEmpty) ...[
_WarningButton(onTap: onWarningsTap),
const SizedBox(width: 6),
const SizedBox(width: _footerElementSpacing),
],
_SettingsButton(onTap: onSettingsTap),
],
@@ -176,10 +212,11 @@ class BottomStatsBar extends ConsumerWidget {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
height: _footerElementHeight,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -240,8 +277,8 @@ class _AltitudeProfileToggleButton extends StatelessWidget {
return GestureDetector(
onTap: onTap,
child: Container(
width: 34,
height: 34,
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
@@ -270,8 +307,8 @@ class _WarningButton extends StatelessWidget {
return GestureDetector(
onTap: onTap,
child: Container(
width: 34,
height: 34,
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE2574A).withValues(alpha: 0.18),
@@ -295,8 +332,8 @@ class _SettingsButton extends StatelessWidget {
return GestureDetector(
onTap: onTap,
child: Container(
width: 34,
height: 34,
width: _footerElementHeight,
height: _footerElementHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
@@ -307,6 +344,100 @@ class _SettingsButton extends StatelessWidget {
}
}
/// 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.
PaperPlaneIcon(size: 15, color: stateColor),
const SizedBox(width: 4),
Text(
_stateLabels[status.state]!,
style: TextStyle(
color: stateColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
const SizedBox(width: 10),
Icon(Icons.signal_cellular_alt,
size: 15, color: _signalColors[status.connectionQuality]),
const SizedBox(width: 8),
Icon(Icons.satellite_alt,
size: 14, color: _signalColors[status.positionQuality]),
],
),
),
),
);
}
}
class _NameChip extends StatelessWidget {
const _NameChip({
required this.label,
@@ -328,10 +459,12 @@ class _NameChip extends StatelessWidget {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
height: _footerElementHeight,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(_footerElementHeight / 2),
),
child: Text(
label,
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/mission_stats.dart';
import '../../domain/mission/mission_warnings.dart';
import '../../domain/telemetry/drone_status.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../providers/altitude_profile_provider.dart';
import 'battery_indicator_bar.dart';
@@ -23,6 +24,7 @@ class MissionFooterBar extends ConsumerWidget {
required this.totalDistanceM,
required this.totalDurationSec,
this.flyMissionStatus,
this.droneStatus,
required this.warnings,
this.showWarningsBanner = true,
this.batteryPercent,
@@ -43,6 +45,10 @@ class MissionFooterBar extends ConsumerWidget {
/// (Wegpunktzahl/Gesamtdistanz/-dauer) durch aktiven Wegpunkt sowie
/// Distanz/Zeit dorthin. Bleibt im Plan-Modus null.
final FlyMissionStatus? flyMissionStatus;
/// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille
/// den zustand der drohne") - siehe BottomStatsBar.droneStatus.
final DroneStatus? droneStatus;
final List<MissionWarning> warnings;
/// Blendet das Text-Warnbanner ueber der Fusszeile aus (Doku: "Banner mit
@@ -105,6 +111,7 @@ class MissionFooterBar extends ConsumerWidget {
totalDistanceM: totalDistanceM,
totalDurationSec: totalDurationSec,
flyMissionStatus: flyMissionStatus,
droneStatus: droneStatus,
onTap: onDetailsTap,
warnings: warnings,
onWarningsTap: onWarningsTap,
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
/// Einfarbig gefuellte Papierflieger-Silhouette (design/B_paperplane.svg,
/// dieselben Koordinaten wie DroneMarkerIcon/DroneFollowIcon) in beliebiger
/// Farbe - fuer kleine Statusanzeigen, in denen die Farbe selbst die
/// Information traegt (siehe _DroneStatusPill in bottom_stats_bar.dart),
/// statt der zweifarbigen bzw. fest pinken Varianten der beiden anderen
/// Verwendungsstellen.
class PaperPlaneIcon extends StatelessWidget {
const PaperPlaneIcon({super.key, required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CustomPaint(painter: _PaperPlaneFillPainter(color: color)),
);
}
}
class _PaperPlaneFillPainter extends CustomPainter {
_PaperPlaneFillPainter({required this.color});
final Color color;
// Gleiche Koordinaten wie DroneMarkerIcon._PaperPlanePainter (viewBox
// 0 0 200 200, design/B_paperplane.svg): Spitze oben, zwei Fluegel unten,
// Heckkerbe dazwischen.
static const _viewBoxSize = 200.0;
static const _nose = Offset(100, 22);
static const _rightWing = Offset(164, 172);
static const _tailNotch = Offset(100, 138);
static const _leftWing = Offset(36, 172);
@override
void paint(Canvas canvas, Size size) {
final scale = size.width / _viewBoxSize;
Offset s(Offset p) => p * scale;
final outline = Path()
..moveTo(s(_nose).dx, s(_nose).dy)
..lineTo(s(_rightWing).dx, s(_rightWing).dy)
..lineTo(s(_tailNotch).dx, s(_tailNotch).dy)
..lineTo(s(_leftWing).dx, s(_leftWing).dy)
..close();
canvas.drawPath(outline, Paint()..color = color);
}
@override
bool shouldRepaint(covariant _PaperPlaneFillPainter oldDelegate) =>
oldDelegate.color != color;
}