Files
dmc/app/lib/ui/providers/mission_meta_provider.dart
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

186 lines
7.7 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../transport/mock/mock_flight_controller_link.dart';
import 'current_mission_provider.dart';
import 'database_provider.dart';
import 'follow_drone_provider.dart';
import 'map_controller_provider.dart';
import 'telemetry_provider.dart';
import 'terrain_provider.dart';
/// Metadaten der aktuell in Bearbeitung befindlichen Mission - ergaenzt
/// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die
/// Datenbank-id, sobald die Mission mindestens einmal gespeichert wurde.
class MissionMeta {
const MissionMeta({this.id, this.name = 'New mission', this.placeName, this.switchSeq = 0});
/// Null, solange die Mission noch nie (auto-)gespeichert wurde.
final int? id;
final String name;
final String? placeName;
/// Zaehlt nur bei einem echten Missionswechsel hoch (startNew/
/// startNewFromPlace/loadMission), NICHT bei der id-Vergabe durch den
/// ersten Autosave einer neuen Mission (siehe _flushNow()) oder bei
/// restoreLastSession() (App-Start, kein Nutzerwechsel). FlyScreen
/// vergleicht darauf, um einen echten Missionswechsel waehrend des
/// Fly-Modus zu erkennen - sowohl fuer das System-Messages-Log (Doku:
/// "Wechsel der Mission ... nur im fly modus loggen") als auch fuer den
/// automatischen Missions-Upload (Doku: "automatisch die Wegpunktliste
/// übertragen, wenn die Mission gewechselt wird im fly modus"). Ein
/// simpler id-Vergleich waere durch die Autosave-id-Vergabe falsch positiv.
final int switchSeq;
MissionMeta copyWith({int? id, String? name, String? placeName, int? switchSeq}) {
return MissionMeta(
id: id ?? this.id,
name: name ?? this.name,
placeName: placeName ?? this.placeName,
switchSeq: switchSeq ?? this.switchSeq,
);
}
}
/// Haelt Name/id der aktuellen Mission und autosaved sie ueber
/// MissionRepository (Doku 7.5), sobald sich die Wegpunktliste aendert -
/// analog scheduleAutosave()/flushPendingAutosave() im HTML-Demonstrator
/// (dort 800ms debounce, hier identisch uebernommen).
class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
Timer? _autosaveTimer;
bool _suppressAutosave = false;
@override
MissionMeta build() {
ref.listen(currentMissionProvider, (previous, next) {
if (!_suppressAutosave) _scheduleAutosave();
});
ref.onDispose(() => _autosaveTimer?.cancel());
return const MissionMeta();
}
/// Laedt die zuletzt bearbeitete Mission (falls vorhanden) beim App-Start
/// wieder, damit ein Autosave-Draft einen Neustart ueberlebt (Doku 7.5).
Future<void> restoreLastSession() async {
final settings = await ref.read(appSettingsRepositoryProvider).load();
final missionId = settings.currentMissionId;
if (missionId == null) return;
final loaded = await ref.read(missionRepositoryProvider).load(missionId);
if (loaded == null) return;
_suppressAutosave = true;
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
_suppressAutosave = false;
state = MissionMeta(id: missionId, name: loaded.name, placeName: loaded.placeName);
ref.read(terrainProvider.notifier).restore(loaded.terrainProfile);
_fitMapToWaypoints(loaded.waypoints);
}
/// Analog startNewMission() im HTML-Demonstrator: sichert zuerst noch
/// ausstehende Aenderungen der bisherigen Mission, dann leerer Neustart.
Future<void> startNew() => _startNew(const MissionMeta());
/// Analog setMissionFromPlace() im HTML-Demonstrator: eine Ortssuche (oder
/// GPS-Ortung) beginnt immer eine neue, leere Mission, benannt nach dem
/// gefundenen Ort - nicht nur ein Kartensprung.
Future<void> startNewFromPlace(String label) =>
_startNew(MissionMeta(name: label, placeName: label));
Future<void> _startNew(MissionMeta newState) async {
await _flushNow();
_suppressAutosave = true;
ref.read(currentMissionProvider.notifier).replaceAll(const []);
_suppressAutosave = false;
state = newState.copyWith(switchSeq: state.switchSeq + 1);
ref.read(terrainProvider.notifier).restore(null);
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
}
Future<void> loadMission(int id) async {
await _flushNow();
final loaded = await ref.read(missionRepositoryProvider).load(id);
if (loaded == null) return;
_suppressAutosave = true;
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
_suppressAutosave = false;
state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName)
.copyWith(switchSeq: state.switchSeq + 1);
ref.read(terrainProvider.notifier).restore(loaded.terrainProfile);
_fitMapToWaypoints(loaded.waypoints);
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
}
/// Zentriert die Karte auf die geladene Mission (Doku 3.6) - ohne dies
/// bliebe die Kamera an der zuletzt betrachteten Stelle stehen, waehrend
/// eine voellig andernorts gelegene Mission angezeigt wird. Analog
/// fitToWaypoints() im HTML-Demonstrator bzw. _onFitPressed() in
/// MapSearchControls, hier aber automatisch statt nutzerausgeloest -
/// nutzt dieselbe Padding-Logik wie der manuelle Fit-Button
/// (fitMapControllerToWaypoints), damit beide nicht wieder auseinander-
/// laufen koennen.
///
/// Beendet dabei ebenfalls den Kartenfolgemodus (followDroneProvider,
/// Doku 3.11, siehe fitMapToWaypoints in map_controller_provider.dart) -
/// das Laden einer anderen Mission waehrend des Verfolgens soll auf der
/// neuen Mission stehen bleiben, nicht sofort wieder zur Drohne springen.
///
/// Solange keine echte Verbindung (WLAN/MSP) aktiv ist - also
/// flightControllerLinkProvider noch der Mock ist - setzt dies zusaetzlich
/// die simulierte Drohnenposition auf den ersten Wegpunkt der neu
/// geladenen Mission zurueck (Doku: "beim Laden einer Mission Mock-
/// Drohnenposition auf ersten Wegpunkt setzen"), damit die Live-Details-
/// Ansicht sofort zur neuen Route passt statt an der vorherigen (oder dem
/// Berlin-Fallback) haengen zu bleiben.
void _fitMapToWaypoints(List<Waypoint> waypoints) {
ref.read(followDroneProvider.notifier).set(false);
fitMapControllerToWaypoints(ref.read(mapControllerProvider), waypoints);
final link = ref.read(flightControllerLinkProvider);
if (link is MockFlightControllerLink) {
link.resetPosition(waypoints);
}
}
/// Benennt eine gespeicherte Mission um (Doku 3.6: nur ueber die
/// Missionsliste moeglich, dort hat jede Zeile immer bereits eine id).
/// Aktualisiert zusaetzlich den eigenen Zustand, falls [id] die gerade
/// aktive Mission ist.
Future<void> renameMission(int id, String name) async {
await ref.read(missionRepositoryProvider).rename(id, name);
if (state.id == id) {
state = state.copyWith(name: name);
}
}
void _scheduleAutosave() {
if (ref.read(currentMissionProvider).isEmpty) return;
_autosaveTimer?.cancel();
_autosaveTimer = Timer(const Duration(milliseconds: 800), () {
unawaited(_flushNow());
});
}
Future<void> _flushNow() async {
_autosaveTimer?.cancel();
_autosaveTimer = null;
final waypoints = ref.read(currentMissionProvider);
if (waypoints.isEmpty) return;
final id = await ref.read(missionRepositoryProvider).upsert(
id: state.id,
name: state.name,
placeName: state.placeName,
waypoints: waypoints,
);
if (state.id == null) {
state = state.copyWith(id: id);
}
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
}
}
final currentMissionMetaProvider =
NotifierProvider<CurrentMissionMetaNotifier, MissionMeta>(
CurrentMissionMetaNotifier.new);