Fills in MspFlightControllerLink.uploadMission(), which previously just threw UnimplementedError, plus a new verifyMission() (both now on the generic FlightControllerLink interface, protocol-neutral by signature - MAVLink/ArduPilot get their own implementation later without touching callers). All iNAV-specific encoding lives in the new msp_waypoint_codec.dart: - encodeMspSetWaypoint(): the 21-byte MSP_SET_WP payload. Action/P1/P2/P3 byte layout was checked against the actual iNAV 9.1.0 source (navigation.c/navigation.h), not guessed - notably our generic `loiter` action has no configurable duration, so it maps to NAV_WP_ACTION_HOLD_TIME with the max representable p1 (int16 max, not 0xFFFF - that would read as -1 and end the hold immediately instead of never). - parseMspWpGetInfo(): decodes MSP_WP_GETINFO's validity/count fields, used by verifyMission() to confirm the FC actually accepted the full mission (Doku 2.2/4.5 Ready-to-Fly-Gate: "upload + verified"). uploadMission() sends one MSP_SET_WP per waypoint in order (iNAV has no batch command - WP#1 resets the FC's mission list, every next number must follow immediately, only the last carries NAV_WP_FLAG_LAST) and rejects missions above NAV_MAX_WAYPOINTS upfront instead of silently truncating. MissionSyncService now calls the real verifyMission() instead of always confirming, throwing MissionVerificationException when the FC doesn't confirm the mission. FlyScreen's footer swaps the warnings button for a send button (Doku: "ersetze den warnings button mit einem wp send button", pink horizontal PaperPlaneIcon, matching the existing paper-plane drone iconography) - warnings/event log stay reachable via the drone status pill's Warnings tab. BottomStatsBar/MissionFooterBar gained onSendTap/sending in place of the old forceShowWarningsButton. Verified end to end on the Pixel_10a emulator: tapping send with WLAN as the active connection type triggers the real WifiNetworkSpecifier flow through MspFlightControllerLink (correctly reports "no devices found" - expected, no real mLRS bridge on the emulator); the actual MSP_SET_WP/ MSP_WP_GETINFO wire behavior is covered by tests against a fake FC responder over LoopbackTransport instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
579 lines
22 KiB
Dart
579 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_warnings_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]. Warnungen/Ereignis-Log
|
|
/// bleiben im Fly-Modus weiterhin ueber die Drohnen-Status-Pille
|
|
/// erreichbar (dort als eigener Tab), siehe DroneStatusWarningsPanel.
|
|
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 DroneStatusWarningsPanel.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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|