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>
418 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|