Files
dmc/app/lib/ui/screens/fly/fly_screen.dart
T
Constantin LeueandClaude Sonnet 5 b68c16d8e6 Log mission name on send, log mission/drone-profile changes, auto-send on mission switch
Include the mission name in the "Mission sent"/"Mission upload failed"
System Messages (was just the waypoint count before).

Add MissionMeta.switchSeq, bumped only by an actual mission switch
(startNew/startNewFromPlace/loadMission) - not by the id a brand-new
mission gets from its first autosave, and not by restoreLastSession() on
app start. FlyScreen compares it to detect a genuine switch and reacts
two ways: logs "Mission changed to ..." and automatically re-uploads the
new route to the flight controller, reusing the same send path as the
manual send button (same _sending guard, same success/failure snackbar
and log entry).

Mission-change and drone-profile-change logging intentionally live in
FlyScreen's ref.listen callbacks, not in mission_meta_provider.dart /
active_drone_profile_provider.dart themselves - those providers have no
notion of the app mode (Plan vs Fly, tracked separately by AppModeCubit),
and logging there would record a change regardless of mode. Since
FlyScreen only exists while Fly mode is active, scoping the listeners
there means switching missions or drone profiles from Plan mode produces
no System Messages entries, and only a mission switch (not a drone
profile switch) triggers the automatic re-upload, matching what was
asked for.

Also splits systemMessageLogProvider (the plain message list + log(), no
telemetry dependency) from the new systemMessageAutoLogProvider (the
Connected/lost/battery/failsafe/connection-type auto-detection, which
does watch telemetryProvider) - discovered while wiring the mission-change
logging that logging a plain message from Plan-mode code was forcing the
entire telemetry/transport stack to spin up as a side effect, which broke
an unrelated Plan-mode test (UdpTransport threw on a double-disconnect
during teardown). Keeping the two concerns apart means calling log() for
a one-off message never has that side effect.

Verified end to end on the Pixel_10a emulator: switching to an empty
mission while in Fly mode correctly showed "No waypoints to send" and
logged "Mission changed to ..." automatically, without touching the send
button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:04:35 +02:00

418 lines
18 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart';
import '../../../app_mode/app_mode_cubit.dart';
import '../../../domain/mission/drone_profile.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/telemetry/system_message.dart';
import '../../../domain/waypoint/flat_waypoint_list.dart';
import '../../../domain/wind/wind_math.dart';
import '../../../services/mission_sync_service.dart';
import '../../../transport/flight_controller_link.dart';
import '../../providers/active_drone_profile_provider.dart';
import '../../providers/current_mission_provider.dart';
import '../../providers/follow_drone_provider.dart';
import '../../providers/map_controller_provider.dart';
import '../../providers/mission_meta_provider.dart';
import '../../providers/system_message_log_provider.dart';
import '../../providers/telemetry_provider.dart';
import '../../providers/terrain_provider.dart';
import '../settings/settings_screen.dart';
import '../../widgets/drone_marker_icon.dart';
import '../../widgets/drone_status_messages_panel.dart';
import '../../widgets/mission_footer_bar.dart';
import '../../widgets/mission_map.dart';
import '../../widgets/waypoint_list_panel.dart';
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
/// beobachtet statt neu geplant. Der Kartencontroller ist derselbe
/// app-lebenslange Provider wie im Plan-Modus (Doku: mapControllerProvider),
/// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen
/// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung),
/// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier.
class FlyScreen extends ConsumerStatefulWidget {
const FlyScreen({super.key});
@override
ConsumerState<FlyScreen> createState() => _FlyScreenState();
}
class _FlyScreenState extends ConsumerState<FlyScreen> {
late final MapController _mapController;
StreamSubscription<MapEvent>? _mapEventSub;
bool _sending = false;
@override
void initState() {
super.initState();
_mapController = ref.read(mapControllerProvider);
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
}
@override
void dispose() {
_mapEventSub?.cancel();
super.dispose();
}
/// Jede manuelle Kartengeste (Pan/Pinch/Fling, ...) bricht den ueber den
/// GPS-Knopf in der Kopfleiste (FlyMapControls) aktivierten Folgemodus ab
/// - nur Bewegungen mit source == mapController sind programmatisch
/// (durch die Telemetrie-Nachfuehrung unten selbst oder andere
/// Kartensprmuenge ausgeloest) und sollen ihn nicht beenden.
void _onMapEvent(MapEvent event) {
if (ref.read(followDroneProvider) &&
event.source != MapEventSource.mapController) {
ref.read(followDroneProvider.notifier).set(false);
}
}
void _onCenterOnWaypoint(int index) {
final waypoints = ref.read(currentMissionProvider);
if (index < 0 || index >= waypoints.length) return;
final wp = waypoints[index];
// Programmatischer Sprung (source == mapController) - _onMapEvent
// erkennt das nicht als manuelle Geste, der Folgemodus muss also
// explizit beendet werden (sonst zieht der naechste Telemetrie-Frame
// die Kamera sofort wieder vom antippten Wegpunkt weg).
ref.read(followDroneProvider.notifier).set(false);
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
}
void _openWaypointList({PanelTab initialTab = PanelTab.list}) {
Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: true,
transitionDuration: const Duration(milliseconds: 150),
reverseTransitionDuration: const Duration(milliseconds: 150),
pageBuilder: (routeContext, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: WaypointListPanel(
activeIndex: -1,
initialTab: initialTab,
onClose: () => Navigator.of(routeContext).pop(),
onCenterOnWaypoint: (index) {
_onCenterOnWaypoint(index);
Navigator.of(routeContext).pop();
},
onDelete: (index) =>
ref.read(currentMissionProvider.notifier).removeAt(index),
onAltitudeChanged: (index, value) => ref
.read(currentMissionProvider.notifier)
.setAltitude(index, value),
onSpeedChanged: (index, value) =>
ref.read(currentMissionProvider.notifier).setSpeed(index, value),
onCatchRadiusChanged: (index, value) => ref
.read(currentMissionProvider.notifier)
.setCatchRadius(index, value),
onActionChanged: (index, action) =>
ref.read(currentMissionProvider.notifier).setAction(index, action),
),
);
},
),
);
}
void _openSystemMessagesPanel() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => const DroneStatusMessagesPanel.messages(),
));
}
/// Laedt die aktuelle Mission auf den Flightcontroller (Doku 2.2/4.5:
/// Connect -> Upload -> Verify) - MissionSyncService kennt selbst kein
/// UI, deshalb hier Ladezustand/Fehleranzeige. uploadMission() der
/// jeweiligen FlightControllerLink-Implementierung verweigert den Versand
/// bereits selbst im armed-Zustand (Doku 4.5); dieser Handler prueft nur
/// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen
/// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag
/// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte
/// übertragen werden") inklusive Missionsname. Wird sowohl vom Sende-
/// Knopf als auch automatisch bei einem Missionswechsel aufgerufen (siehe
/// den ref.listen(currentMissionMetaProvider, ...) unten) - der
/// _sending-Schutz verhindert dabei ein doppeltes Senden, falls beides
/// ueberlappt.
Future<void> _onSendTap() async {
if (_sending) return;
final waypoints = ref.read(currentMissionProvider);
final missionName = ref.read(currentMissionMetaProvider).name;
final messenger = ScaffoldMessenger.of(context);
if (waypoints.isEmpty) {
messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send')));
return;
}
setState(() => _sending = true);
final log = ref.read(systemMessageLogProvider.notifier);
try {
final service = MissionSyncService(
ref.read(flightControllerLinkProvider),
context.read<AppModeCubit>(),
);
await service.syncAndUpload(FlatWaypointList(waypoints));
messenger.showSnackBar(const SnackBar(content: Text('Mission sent')));
log.log('Mission sent: "$missionName" (${waypoints.length} waypoints)');
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Send failed: $e')));
log.log('Mission upload failed: "$missionName": $e',
severity: SystemMessageSeverity.critical);
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider);
final droneProfile = ref.watch(activeDroneProfileProvider);
final telemetry = ref.watch(telemetryProvider).value;
final followDrone = ref.watch(followDroneProvider);
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
// die Dauer des Fly-Modus - siehe systemMessageAutoLogProvider-Doku,
// warum das bewusst NICHT systemMessageLogProvider selbst ist (der
// bliebe sonst auch im Plan-Modus dauerhaft an telemetryProvider
// gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der
// Beobachtungs-Seiteneffekt.
ref.watch(systemMessageAutoLogProvider);
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
// programmatisch nachziehen (source == mapController, siehe
// _onMapEvent - bricht den Modus dadurch nicht selbst wieder ab).
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
if (!followDrone) return;
final frame = next.value;
if (frame != null && frame.hasFix) {
_mapController.move(LatLng(frame.lat, frame.lon), _mapController.camera.zoom);
}
});
// Ein Missionswechsel waehrend des Fly-Modus (z.B. ueber die Mission-
// Pille in der Fusszeile, die auch dort erreichbar bleibt) wird geloggt
// und laedt die neue Route automatisch auf den Flightcontroller (Doku:
// "Wechsel der Mission ... nur im fly modus loggen", "automatisch die
// Wegpunktliste übertragen, wenn die Mission gewechselt wird im fly
// modus") - beides sitzt bewusst hier statt in
// mission_meta_provider.dart, das den App-Modus (AppModeCubit,
// flutter_bloc) nicht kennt; dieser ref.listen existiert dagegen nur,
// waehrend FlyScreen gemountet ist. switchSeq statt eines simplen
// id-Vergleichs, weil der erste Autosave einer neuen Mission ebenfalls
// deren id setzt, ohne dass die Mission dabei "gewechselt" wurde (siehe
// MissionMeta-Doku).
ref.listen<MissionMeta>(currentMissionMetaProvider, (previous, next) {
if (previous != null && next.switchSeq != previous.switchSeq) {
ref.read(systemMessageLogProvider.notifier).log('Mission changed to "${next.name}"');
_onSendTap();
}
});
// Drohnenprofilwechsel waehrend des Fly-Modus (Doku: "Wechsel ... des
// Drohnenprofils ... nur im fly modus loggen") - anders als beim
// Missionswechsel bewusst OHNE automatischen Neuversand (Doku sieht das
// explizit nur fuer den Missionswechsel vor).
ref.listen<DroneProfile>(activeDroneProfileProvider, (previous, next) {
if (previous != null && previous.id != next.id) {
ref.read(systemMessageLogProvider.notifier).log('Drone profile changed to "${next.name}"');
}
});
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: droneProfile.minTurnRadius,
maxClimbRate: droneProfile.maxClimbRate,
maxDescentRate: droneProfile.maxDescentRate,
);
final missionStats = computeMissionStats(waypoints);
final safeEdgeInset = math.max(
MediaQuery.of(context).padding.left,
MediaQuery.of(context).padding.right,
);
// Gleiche Reservierung fuer die Fusszeile wie die Rad-Readouts im
// Plan-Modus (PlanScreen: wheelBottomInset), damit die Alt/Speed-
// Anzeige zwischen Kopf- und Fusszeile vertikal zentriert erscheint,
// statt am Kartenrand zu kleben.
final footerBottomInset = math.max(
MediaQuery.of(context).size.height * 0.06,
124.0,
);
final terrainProfile = ref.watch(terrainProvider).profile;
final warnings = computeMissionWarnings(
waypoints: waypoints,
routeGeometry: routeGeometry,
legWindBad: computeLegWindBad(waypoints, droneProfile.maxSpeed),
droneProfile: droneProfile,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
terrain: terrainProfile,
);
// Vor der ersten Telemetrie (Verbindungsaufbau laeuft noch, oder Mock
// hat noch nicht getickt) und ohne GPS-Fix (TelemetryFrame wird bewusst
// nicht auf einen Fix gegated, siehe dortige Doku - ohne Fix meldet der
// FC aber oft (0,0) oder eine veraltete Position) auf dem ersten
// Wegpunkt statt an einer erfundenen Stelle anzeigen.
final droneMarker = telemetry != null && telemetry.hasFix
? Marker(
point: LatLng(telemetry.lat, telemetry.lon),
width: 40,
height: 40,
alignment: Alignment.center,
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
)
: waypoints.isEmpty
? null
: Marker(
point: LatLng(waypoints.first.lat, waypoints.first.lon),
width: 40,
height: 40,
alignment: Alignment.center,
child: const DroneMarkerIcon(),
);
// Gestrichelte Linie von der Drohne zum aktiven Wegpunkt (Doku 3.4:
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
// rubberBandLine), hier aber Drohne -> naechster Wegpunkt statt letzter
// Wegpunkt -> Kartenmitte.
List<LatLng>? rubberBand;
final activeWpIndex = telemetry?.activeWaypointIndex;
if (telemetry != null &&
telemetry.hasFix &&
activeWpIndex != null &&
activeWpIndex >= 0 &&
activeWpIndex < waypoints.length) {
final target = waypoints[activeWpIndex];
rubberBand = [
LatLng(telemetry.lat, telemetry.lon),
LatLng(target.lat, target.lon),
];
}
// Live-Missionsstatus fuer die Details-Pille (Doku: "aktiver Wegpunkt
// 3/23, Distanz und Zeit zum naechsten Wegpunkt" statt der statischen
// Plan-Werte) - siehe FlyMissionStatus-Klassendoku dazu, warum das aus
// Position+Wegpunktkoordinaten berechnet wird statt direkt von MSP
// abgefragt.
final flyMissionStatus = computeFlyMissionStatus(
waypoints: waypoints,
activeWaypointIndex: activeWpIndex,
currentLat: telemetry != null && telemetry.hasFix ? telemetry.lat : null,
currentLon: telemetry != null && telemetry.hasFix ? telemetry.lon : null,
currentSpeedMs: telemetry?.speedMs,
);
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
// drone pille den zustand der drohne") - das Aussehen des Fly-Modus darf
// nicht am Vorhandensein eines Telemetrie-Frames haengen (sonst faellt
// BottomStatsBar auf den Drohnennamen-Chip zurueck, den Plan-Modus-Look).
// Ohne Frame (Verbindungsaufbau laeuft noch) liefern die Platzhalterwerte
// ueber computeDroneStatus zuverlaessig idle/rot/rot - eine ehrliche
// "keine Daten"-Anzeige statt eines unterschiedlichen Widget-Typs.
final droneStatus = computeDroneStatus(
armed: telemetry?.armed ?? false,
hasFix: telemetry?.hasFix ?? false,
numSat: telemetry?.numSat ?? 0,
hdop: telemetry?.hdop ?? 99.0,
linkQuality: telemetry?.linkQuality ?? 0,
snrDb: telemetry?.snrDb ?? -99,
navMode: telemetry?.navMode ?? 0,
);
return Stack(
children: [
MissionMap(
mapController: _mapController,
routeSegments: routeGeometry.segments,
rubberBandLine: rubberBand,
waypointMarkers: [
for (var i = 0; i < waypoints.length; i++)
CircleMarker(
point: LatLng(waypoints[i].lat, waypoints[i].lon),
radius: 6,
color: i < routeGeometry.vertexBad.length && routeGeometry.vertexBad[i]
? const Color(0xFFE2574A)
: const Color(0xFF2B2B28),
borderStrokeWidth: 1.5,
borderColor: Colors.white,
),
],
droneMarker: droneMarker,
),
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
// SET-Zeile, weil es im Waypoint-Fly-Modus kein editierbares
// Rad-Widget (curAlt/curSpeed) gibt, dessen Wert sinnvoll waere.
Positioned(
top: 56,
bottom: footerBottomInset,
left: 14 + safeEdgeInset,
child: Center(child: _liveReadout('Alt', telemetry?.altitudeM, 'm')),
),
Positioned(
top: 56,
bottom: footerBottomInset,
right: 14 + safeEdgeInset,
child: Center(child: _liveReadout('Speed', telemetry?.speedMs, 'm/s')),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MissionFooterBar(
waypoints: waypoints,
legClimbBad: routeGeometry.legClimbBad,
vertexBad: routeGeometry.vertexBad,
activeIndex: -1,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
flyMissionStatus: flyMissionStatus,
droneStatus: droneStatus,
warnings: warnings,
showWarningsBanner: false,
batteryPercent: telemetry?.batteryPercent,
onSendTap: _onSendTap,
sending: _sending,
onDetailsTap: _openWaypointList,
onWarningsTap: _openSystemMessagesPanel,
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SettingsScreen(),
)),
),
),
],
);
}
Widget _liveReadout(String label, double? value, String unit) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$label: ${value != null ? '${value.round()} $unit' : '—'}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
);
}
}