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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b09d00ab9f
commit
b68c16d8e6
@@ -36,6 +36,11 @@ class ActiveDroneProfileNotifier extends Notifier<DroneProfile> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loggt den Wechsel NICHT selbst (Doku: "Wechsel der Mission oder des
|
||||||
|
/// Drohnenprofils ... nur im fly modus loggen") - dieser Provider kennt
|
||||||
|
/// den App-Modus (AppModeCubit, flutter_bloc) nicht. FlyScreen beobachtet
|
||||||
|
/// stattdessen selbst per ref.listen(activeDroneProfileProvider, ...) und
|
||||||
|
/// loggt dort, existiert also nur, waehrend der Fly-Modus aktiv ist.
|
||||||
Future<void> select(DroneProfile profile) async {
|
Future<void> select(DroneProfile profile) async {
|
||||||
state = profile;
|
state = profile;
|
||||||
await ref.read(appSettingsRepositoryProvider).setActiveDroneProfileId(profile.id);
|
await ref.read(appSettingsRepositoryProvider).setActiveDroneProfileId(profile.id);
|
||||||
|
|||||||
@@ -15,18 +15,31 @@ import 'terrain_provider.dart';
|
|||||||
/// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die
|
/// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die
|
||||||
/// Datenbank-id, sobald die Mission mindestens einmal gespeichert wurde.
|
/// Datenbank-id, sobald die Mission mindestens einmal gespeichert wurde.
|
||||||
class MissionMeta {
|
class MissionMeta {
|
||||||
const MissionMeta({this.id, this.name = 'New mission', this.placeName});
|
const MissionMeta({this.id, this.name = 'New mission', this.placeName, this.switchSeq = 0});
|
||||||
|
|
||||||
/// Null, solange die Mission noch nie (auto-)gespeichert wurde.
|
/// Null, solange die Mission noch nie (auto-)gespeichert wurde.
|
||||||
final int? id;
|
final int? id;
|
||||||
final String name;
|
final String name;
|
||||||
final String? placeName;
|
final String? placeName;
|
||||||
|
|
||||||
MissionMeta copyWith({int? id, String? name, 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(
|
return MissionMeta(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
placeName: placeName ?? this.placeName,
|
placeName: placeName ?? this.placeName,
|
||||||
|
switchSeq: switchSeq ?? this.switchSeq,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +93,7 @@ class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
|
|||||||
_suppressAutosave = true;
|
_suppressAutosave = true;
|
||||||
ref.read(currentMissionProvider.notifier).replaceAll(const []);
|
ref.read(currentMissionProvider.notifier).replaceAll(const []);
|
||||||
_suppressAutosave = false;
|
_suppressAutosave = false;
|
||||||
state = newState;
|
state = newState.copyWith(switchSeq: state.switchSeq + 1);
|
||||||
ref.read(terrainProvider.notifier).restore(null);
|
ref.read(terrainProvider.notifier).restore(null);
|
||||||
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
|
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
|
||||||
}
|
}
|
||||||
@@ -93,7 +106,8 @@ class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
|
|||||||
_suppressAutosave = true;
|
_suppressAutosave = true;
|
||||||
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
|
ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints);
|
||||||
_suppressAutosave = false;
|
_suppressAutosave = false;
|
||||||
state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName);
|
state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName)
|
||||||
|
.copyWith(switchSeq: state.switchSeq + 1);
|
||||||
ref.read(terrainProvider.notifier).restore(loaded.terrainProfile);
|
ref.read(terrainProvider.notifier).restore(loaded.terrainProfile);
|
||||||
_fitMapToWaypoints(loaded.waypoints);
|
_fitMapToWaypoints(loaded.waypoints);
|
||||||
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
|
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id);
|
||||||
|
|||||||
@@ -6,54 +6,84 @@ import '../../transport/flight_controller_link.dart';
|
|||||||
import 'connection_settings_provider.dart';
|
import 'connection_settings_provider.dart';
|
||||||
import 'telemetry_provider.dart';
|
import 'telemetry_provider.dart';
|
||||||
|
|
||||||
/// Fuehrt das System-Messages-Log fuer den gleichnamigen Tab im
|
/// Haelt die Liste der System Messages plus das manuelle [log()] (Doku:
|
||||||
/// Drohnen-Status-Menue (Doku: "aendere das warnings Menü in System
|
/// "aendere das warnings Menü in System Messages"). Bewusst schlank, ohne
|
||||||
/// Messages ... Verbindung hergestellt/verloren, Verbindungstyp geaendert,
|
/// jede Telemetrie-/Verbindungsbeobachtung: mission_meta_provider.dart und
|
||||||
/// Wegpunkte uebertragen") - beobachtet [telemetryProvider] und
|
/// active_drone_profile_provider.dart rufen log() auch aus dem Plan-Modus
|
||||||
/// [connectionSettingsProvider] dauerhaft (bewusst KEIN autoDispose, siehe
|
/// heraus auf (Missions-/Drohnenprofilwechsel) - wuerde diese Klasse selbst
|
||||||
/// telemetry_provider.dart-Doku zur analogen Begruendung bei
|
/// telemetryProvider beobachten, risse schon ein simpler Missionswechsel im
|
||||||
/// flightControllerLinkProvider), damit ein Ereignis auch dann protokolliert
|
/// Plan-Modus ungewollt den kompletten Telemetrie-/Transport-Stack (inkl.
|
||||||
/// wird, wenn die System-Messages-Ansicht selbst gerade nicht geoeffnet ist.
|
/// echtem MSP/UDP-Transport) mit hoch. Die automatische Erkennung
|
||||||
/// Beginnt zu beobachten, sobald irgendein Widget diesen Provider erstmals
|
/// (Connected/Lost/Verbindungstyp-Wechsel) sitzt deshalb separat in
|
||||||
/// liest (siehe FlyScreen) - vor diesem Zeitpunkt liegende Ereignisse
|
/// [systemMessageAutoLogProvider], die ausschliesslich der Fly-Modus
|
||||||
/// erscheinen nicht im Log, das ist fuer ein Live-Log erwartbar.
|
/// beobachtet.
|
||||||
class SystemMessageLogNotifier extends Notifier<List<SystemMessage>> {
|
class SystemMessageLogNotifier extends Notifier<List<SystemMessage>> {
|
||||||
TelemetryFrame? _previousFrame;
|
|
||||||
bool _hadData = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<SystemMessage> build() {
|
List<SystemMessage> build() => [];
|
||||||
|
|
||||||
|
/// Fuegt einen Eintrag manuell hinzu (Doku: "Wegpunkte uebertragen",
|
||||||
|
/// "Wechsel der Mission oder des Drohnenprofils") - fuer Ereignisse, die
|
||||||
|
/// vom Aufloeser einer konkreten Aktion gemeldet werden, statt aus
|
||||||
|
/// Telemetrie/Einstellungen automatisch erkannt zu werden.
|
||||||
|
void log(String message, {SystemMessageSeverity severity = SystemMessageSeverity.info}) {
|
||||||
|
state = [
|
||||||
|
SystemMessage(timestamp: DateTime.now(), message: message, severity: severity),
|
||||||
|
...state,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() => state = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final systemMessageLogProvider =
|
||||||
|
NotifierProvider<SystemMessageLogNotifier, List<SystemMessage>>(
|
||||||
|
SystemMessageLogNotifier.new);
|
||||||
|
|
||||||
|
/// Automatische Erkennung von Connected/Connection-lost/Batterie-/Failsafe-/
|
||||||
|
/// Verbindungstyp-Aenderungen (Doku: "wenn die Verbindung hergestellt
|
||||||
|
/// wird/verloren geht, der verbindungstyp geaendert wird") - getrennt von
|
||||||
|
/// [systemMessageLogProvider] selbst (siehe dessen Doku), damit Plan-Modus-
|
||||||
|
/// Code, der nur log() fuer eine manuelle Meldung braucht, nicht versehentlich
|
||||||
|
/// telemetryProvider mit hochzieht. autoDispose: nur beobachtet, waehrend
|
||||||
|
/// irgendwer (FlyScreen) diesen Provider liest - verlaesst der Nutzer den
|
||||||
|
/// Fly-Modus, wird die Beobachtung wieder abgebaut wie bei telemetryProvider
|
||||||
|
/// selbst.
|
||||||
|
final systemMessageAutoLogProvider = Provider.autoDispose<void>((ref) {
|
||||||
|
TelemetryFrame? previousFrame;
|
||||||
|
var hadData = false;
|
||||||
|
|
||||||
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
|
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
|
||||||
next.when(
|
next.when(
|
||||||
data: (frame) {
|
data: (frame) {
|
||||||
|
final log = ref.read(systemMessageLogProvider.notifier);
|
||||||
// Flankengetriggert wie "Connection lost" unten, nur umgekehrt -
|
// Flankengetriggert wie "Connection lost" unten, nur umgekehrt -
|
||||||
// anders als dort ist hier kein Sonderfall fuer die allererste
|
// anders als dort ist hier kein Sonderfall fuer die allererste
|
||||||
// Verbindung noetig: der Uebergang "noch keine Daten" -> "erste
|
// Verbindung noetig: der Uebergang "noch keine Daten" -> "erste
|
||||||
// Daten" ist immer ein echtes "Connected", auch beim allerersten
|
// Daten" ist immer ein echtes "Connected", auch beim allerersten Mal.
|
||||||
// Mal.
|
if (!hadData) {
|
||||||
if (!_hadData) {
|
log.log('Connected');
|
||||||
_log('Connected', SystemMessageSeverity.info);
|
|
||||||
}
|
}
|
||||||
final events = detectSystemMessages(
|
final events = detectSystemMessages(
|
||||||
previous: _previousFrame,
|
previous: previousFrame,
|
||||||
current: frame,
|
current: frame,
|
||||||
now: DateTime.now(),
|
now: DateTime.now(),
|
||||||
);
|
);
|
||||||
_previousFrame = frame;
|
previousFrame = frame;
|
||||||
_hadData = true;
|
hadData = true;
|
||||||
if (events.isNotEmpty) {
|
for (final event in events) {
|
||||||
state = [...events.reversed, ...state];
|
log.log(event.message, severity: event.severity);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error, stackTrace) {
|
error: (error, stackTrace) {
|
||||||
// Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde
|
// Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde
|
||||||
// schon das (normale) Ausbleiben der allerersten Verbindung als
|
// schon das (normale) Ausbleiben der allerersten Verbindung als
|
||||||
// "Connection lost" auftauchen.
|
// "Connection lost" auftauchen.
|
||||||
if (_hadData) {
|
if (hadData) {
|
||||||
_log('Connection lost', SystemMessageSeverity.critical);
|
ref.read(systemMessageLogProvider.notifier)
|
||||||
|
.log('Connection lost', severity: SystemMessageSeverity.critical);
|
||||||
}
|
}
|
||||||
_previousFrame = null;
|
previousFrame = null;
|
||||||
_hadData = false;
|
hadData = false;
|
||||||
},
|
},
|
||||||
loading: () {},
|
loading: () {},
|
||||||
);
|
);
|
||||||
@@ -73,38 +103,14 @@ class SystemMessageLogNotifier extends Notifier<List<SystemMessage>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type != previousType) {
|
if (type != previousType) {
|
||||||
_log('Connection type changed to ${_connectionTypeLabel(type)}',
|
ref.read(systemMessageLogProvider.notifier)
|
||||||
SystemMessageSeverity.info);
|
.log('Connection type changed to ${_connectionTypeLabel(type)}');
|
||||||
previousType = type;
|
previousType = type;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return [];
|
String _connectionTypeLabel(ConnectionType type) => switch (type) {
|
||||||
}
|
|
||||||
|
|
||||||
String _connectionTypeLabel(ConnectionType type) => switch (type) {
|
|
||||||
ConnectionType.wifi => 'WiFi mLRS UDP',
|
ConnectionType.wifi => 'WiFi mLRS UDP',
|
||||||
ConnectionType.mock => 'Mock (Test)',
|
ConnectionType.mock => 'Mock (Test)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Fuegt einen Eintrag manuell hinzu (Doku: "Wegpunkte uebertragen") - fuer
|
|
||||||
/// Ereignisse, die nicht aus der Telemetrie/den Einstellungen ableitbar
|
|
||||||
/// sind, sondern vom Aufloeser einer konkreten Aktion (z.B. FlyScreen nach
|
|
||||||
/// einem Missions-Upload) gemeldet werden.
|
|
||||||
void log(String message, {SystemMessageSeverity severity = SystemMessageSeverity.info}) {
|
|
||||||
_log(message, severity);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _log(String message, SystemMessageSeverity severity) {
|
|
||||||
state = [
|
|
||||||
SystemMessage(timestamp: DateTime.now(), message: message, severity: severity),
|
|
||||||
...state,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void clear() => state = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final systemMessageLogProvider =
|
|
||||||
NotifierProvider<SystemMessageLogNotifier, List<SystemMessage>>(
|
|
||||||
SystemMessageLogNotifier.new);
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../../../app_mode/app_mode_cubit.dart';
|
import '../../../app_mode/app_mode_cubit.dart';
|
||||||
|
import '../../../domain/mission/drone_profile.dart';
|
||||||
import '../../../domain/mission/mission_stats.dart';
|
import '../../../domain/mission/mission_stats.dart';
|
||||||
import '../../../domain/mission/mission_warnings.dart';
|
import '../../../domain/mission/mission_warnings.dart';
|
||||||
import '../../../domain/mission/route_geometry.dart';
|
import '../../../domain/mission/route_geometry.dart';
|
||||||
@@ -21,6 +22,7 @@ import '../../providers/active_drone_profile_provider.dart';
|
|||||||
import '../../providers/current_mission_provider.dart';
|
import '../../providers/current_mission_provider.dart';
|
||||||
import '../../providers/follow_drone_provider.dart';
|
import '../../providers/follow_drone_provider.dart';
|
||||||
import '../../providers/map_controller_provider.dart';
|
import '../../providers/map_controller_provider.dart';
|
||||||
|
import '../../providers/mission_meta_provider.dart';
|
||||||
import '../../providers/system_message_log_provider.dart';
|
import '../../providers/system_message_log_provider.dart';
|
||||||
import '../../providers/telemetry_provider.dart';
|
import '../../providers/telemetry_provider.dart';
|
||||||
import '../../providers/terrain_provider.dart';
|
import '../../providers/terrain_provider.dart';
|
||||||
@@ -137,9 +139,15 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
/// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen
|
/// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen
|
||||||
/// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag
|
/// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag
|
||||||
/// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte
|
/// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte
|
||||||
/// übertragen werden").
|
/// ü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 {
|
Future<void> _onSendTap() async {
|
||||||
|
if (_sending) return;
|
||||||
final waypoints = ref.read(currentMissionProvider);
|
final waypoints = ref.read(currentMissionProvider);
|
||||||
|
final missionName = ref.read(currentMissionMetaProvider).name;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (waypoints.isEmpty) {
|
if (waypoints.isEmpty) {
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send')));
|
messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send')));
|
||||||
@@ -155,10 +163,11 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
);
|
);
|
||||||
await service.syncAndUpload(FlatWaypointList(waypoints));
|
await service.syncAndUpload(FlatWaypointList(waypoints));
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('Mission sent')));
|
messenger.showSnackBar(const SnackBar(content: Text('Mission sent')));
|
||||||
log.log('Mission sent (${waypoints.length} waypoints)');
|
log.log('Mission sent: "$missionName" (${waypoints.length} waypoints)');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
messenger.showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
messenger.showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
||||||
log.log('Mission upload failed: $e', severity: SystemMessageSeverity.critical);
|
log.log('Mission upload failed: "$missionName": $e',
|
||||||
|
severity: SystemMessageSeverity.critical);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
@@ -170,16 +179,15 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
final droneProfile = ref.watch(activeDroneProfileProvider);
|
final droneProfile = ref.watch(activeDroneProfileProvider);
|
||||||
final telemetry = ref.watch(telemetryProvider).value;
|
final telemetry = ref.watch(telemetryProvider).value;
|
||||||
final followDrone = ref.watch(followDroneProvider);
|
final followDrone = ref.watch(followDroneProvider);
|
||||||
// Beobachtet das System-Messages-Log dauerhaft ab dem ersten Eintritt in
|
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
|
||||||
// den Fly-Modus (Doku: "log von kritischen ereignissen mit time stamp",
|
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
||||||
// erweitert um Verbindungs-/Uebertragungsereignisse) - haelt
|
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
||||||
// system_message_log_provider.dart's Notifier am Leben, solange dieser
|
// die Dauer des Fly-Modus - siehe systemMessageAutoLogProvider-Doku,
|
||||||
// Screen existiert, siehe dessen Klassendoku. Der Rueckgabewert selbst
|
// warum das bewusst NICHT systemMessageLogProvider selbst ist (der
|
||||||
// wird hier nicht mehr gebraucht (der Warnungs-Knopf, der ihn frueher
|
// bliebe sonst auch im Plan-Modus dauerhaft an telemetryProvider
|
||||||
// fuer forceShowWarningsButton auswertete, ist im Fly-Modus durch den
|
// gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der
|
||||||
// Sende-Knopf ersetzt, siehe onSendTap oben) - System Messages bleiben
|
// Beobachtungs-Seiteneffekt.
|
||||||
// ueber die Drohnen-Status-Pille erreichbar.
|
ref.watch(systemMessageAutoLogProvider);
|
||||||
ref.watch(systemMessageLogProvider);
|
|
||||||
|
|
||||||
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
|
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
|
||||||
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
|
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
|
||||||
@@ -193,6 +201,35 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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(
|
final routeGeometry = buildRouteGeometry(
|
||||||
waypoints,
|
waypoints,
|
||||||
minTurnRadius: droneProfile.minTurnRadius,
|
minTurnRadius: droneProfile.minTurnRadius,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import 'package:dmc_app/services/database/app_database.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/database_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/mission_meta_provider.dart';
|
||||||
|
|
||||||
|
ProviderContainer _container() => ProviderContainer(overrides: [
|
||||||
|
appDatabaseProvider
|
||||||
|
.overrideWithValue(AppDatabase.forTesting(NativeDatabase.memory())),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// MissionMeta.switchSeq ist das Signal, mit dem FlyScreen einen echten
|
||||||
|
/// Missionswechsel erkennt (System-Messages-Log + automatischer Upload,
|
||||||
|
/// beides bewusst nur im Fly-Modus - siehe FlyScreen-Doku). Das Loggen
|
||||||
|
/// selbst passiert dort, nicht hier: dieser Provider kennt den App-Modus
|
||||||
|
/// nicht und darf deshalb ausserhalb des Fly-Modus (z.B. beim Planen) nicht
|
||||||
|
/// unbedingt in den System-Messages-Log schreiben oder gar telemetryProvider
|
||||||
|
/// mit hochziehen.
|
||||||
|
void main() {
|
||||||
|
test('startNewFromPlace() erhoeht switchSeq', () async {
|
||||||
|
final container = _container();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(currentMissionMetaProvider.notifier).startNewFromPlace('Rotterdam');
|
||||||
|
|
||||||
|
expect(container.read(currentMissionMetaProvider).switchSeq, 1);
|
||||||
|
expect(container.read(currentMissionMetaProvider).name, 'Rotterdam');
|
||||||
|
});
|
||||||
|
|
||||||
|
// loadMission() ruft zusaetzlich _fitMapToWaypoints() auf, das eine
|
||||||
|
// tatsaechlich gerenderte FlutterMap braucht (MapController.fitCamera) -
|
||||||
|
// in einem reinen Notifier-Test ohne Widget-Baum nicht moeglich. Wird
|
||||||
|
// stattdessen vom widget_test.dart-Test "Missionswechsel im Fly-Modus
|
||||||
|
// sendet die neue Route automatisch an den Flightcontroller" abgedeckt,
|
||||||
|
// der loadMission() innerhalb einer echten AppShell/MissionMap aufruft.
|
||||||
|
|
||||||
|
test(
|
||||||
|
'der erste Autosave einer neuen Mission erhoeht switchSeq NICHT '
|
||||||
|
'(id-Vergabe durch Autosave ist kein Wechsel)',
|
||||||
|
() async {
|
||||||
|
final container = _container();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container.read(currentMissionMetaProvider.notifier).startNewFromPlace('Berlin');
|
||||||
|
final switchSeqAfterStart = container.read(currentMissionMetaProvider).switchSeq;
|
||||||
|
|
||||||
|
container.read(currentMissionProvider.notifier).addWaypoint(
|
||||||
|
const Waypoint(lat: 1, lon: 1, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
|
||||||
|
);
|
||||||
|
// Autosave debounced ueber 800ms (Doku 7.5) - echt abwarten statt zu
|
||||||
|
// mocken, damit tatsaechlich der reale _flushNow()-Pfad getestet wird.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 900));
|
||||||
|
|
||||||
|
final meta = container.read(currentMissionMetaProvider);
|
||||||
|
expect(meta.id, isNotNull, reason: 'Autosave sollte trotzdem eine id vergeben haben');
|
||||||
|
expect(meta.switchSeq, switchSeqAfterStart);
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(seconds: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ void main() {
|
|||||||
.overrideWith((ref) async => _settings(ConnectionType.mock)),
|
.overrideWith((ref) async => _settings(ConnectionType.mock)),
|
||||||
]);
|
]);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
container.listen(systemMessageLogProvider, (previous, next) {});
|
container.listen(systemMessageAutoLogProvider, (previous, next) {});
|
||||||
|
|
||||||
controller.add(_frame());
|
controller.add(_frame());
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
@@ -90,7 +90,7 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
container.listen(systemMessageLogProvider, (previous, next) {});
|
container.listen(systemMessageAutoLogProvider, (previous, next) {});
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
expect(container.read(systemMessageLogProvider), isEmpty);
|
expect(container.read(systemMessageLogProvider), isEmpty);
|
||||||
@@ -105,7 +105,7 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
container.listen(systemMessageLogProvider, (previous, next) {});
|
container.listen(systemMessageAutoLogProvider, (previous, next) {});
|
||||||
await container.read(connectionSettingsProvider.future);
|
await container.read(connectionSettingsProvider.future);
|
||||||
expect(container.read(systemMessageLogProvider), isEmpty);
|
expect(container.read(systemMessageLogProvider), isEmpty);
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ import 'package:dmc_app/services/sharing/drone_share_codec.dart';
|
|||||||
import 'package:dmc_app/services/sharing/mission_share_codec.dart';
|
import 'package:dmc_app/services/sharing/mission_share_codec.dart';
|
||||||
import 'package:dmc_app/services/sharing/sharing_service.dart';
|
import 'package:dmc_app/services/sharing/sharing_service.dart';
|
||||||
import 'package:dmc_app/ui/app_shell.dart';
|
import 'package:dmc_app/ui/app_shell.dart';
|
||||||
|
import 'package:dmc_app/ui/screens/fly/fly_screen.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/active_drone_profile_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/database_provider.dart';
|
import 'package:dmc_app/ui/providers/database_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/geocoding_provider.dart';
|
import 'package:dmc_app/ui/providers/geocoding_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/mission_meta_provider.dart';
|
import 'package:dmc_app/ui/providers/mission_meta_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/sharing_provider.dart';
|
import 'package:dmc_app/ui/providers/sharing_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/system_message_log_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/telemetry_provider.dart';
|
import 'package:dmc_app/ui/providers/telemetry_provider.dart';
|
||||||
import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart';
|
import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart';
|
||||||
import 'package:dmc_app/ui/widgets/drone_follow_icon.dart';
|
import 'package:dmc_app/ui/widgets/drone_follow_icon.dart';
|
||||||
@@ -671,6 +674,121 @@ void main() {
|
|||||||
expect(camera.zoom, 14);
|
expect(camera.zoom, 14);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Missionswechsel im Fly-Modus sendet die neue Route automatisch an '
|
||||||
|
'den Flightcontroller', (tester) async {
|
||||||
|
final container = _testContainer();
|
||||||
|
final appModeCubit = AppModeCubit();
|
||||||
|
addTearDown(() {
|
||||||
|
container.dispose();
|
||||||
|
appModeCubit.close();
|
||||||
|
});
|
||||||
|
final missionBId = await container.read(missionRepositoryProvider).upsert(
|
||||||
|
name: 'Mission B',
|
||||||
|
waypoints: const [
|
||||||
|
Waypoint(lat: 10, lon: 10, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider<AppModeCubit>.value(
|
||||||
|
value: appModeCubit,
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
appModeCubit.toFly();
|
||||||
|
// Zwei Frames noetig, bis AppShells context.select(AppModeCubit) auf den
|
||||||
|
// Moduswechsel reagiert und FlyScreen tatsaechlich mountet (ein einzelner
|
||||||
|
// pump() reicht hier nicht, anders als beim Antippen des Fly-Buttons in
|
||||||
|
// anderen Tests, wo der Tap selbst schon einen Zwischenschritt einbringt).
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(FlyScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await container.read(currentMissionMetaProvider.notifier).loadMission(missionBId);
|
||||||
|
// syncAndUpload() durchlaeuft mehrere awaits (connect/upload/verify) auf
|
||||||
|
// dem Mock, alle ohne echte Timer - ein paar Frames reichen zum
|
||||||
|
// Durchlaufen, bewusst kein pumpAndSettle() (siehe andere Fly-Modus-
|
||||||
|
// Tests: der periodische Mock-Telemetrie-Timer wuerde das nie "settlen"
|
||||||
|
// lassen).
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(appModeCubit.state.missionUploaded, isTrue);
|
||||||
|
expect(appModeCubit.state.missionVerified, isTrue);
|
||||||
|
expect(find.textContaining('Mission sent'), findsOneWidget);
|
||||||
|
|
||||||
|
final log = container.read(systemMessageLogProvider);
|
||||||
|
expect(log.map((e) => e.message), contains('Mission changed to "Mission B"'));
|
||||||
|
expect(
|
||||||
|
log.map((e) => e.message),
|
||||||
|
contains(predicate<String>((m) => m.startsWith('Mission sent: "Mission B"'))),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _stopTelemetry(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Missionswechsel im Plan-Modus wird NICHT im System-Messages-Log erfasst '
|
||||||
|
'(nur im Fly-Modus, Doku: "nur im fly modus loggen")', (tester) async {
|
||||||
|
final container = _testContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (_) => AppModeCubit(),
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await container.read(currentMissionMetaProvider.notifier).startNewFromPlace('Wien');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(container.read(systemMessageLogProvider), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Drohnenprofilwechsel im Fly-Modus wird im System-Messages-Log erfasst',
|
||||||
|
(tester) async {
|
||||||
|
final container = _testContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final otherProfile = DroneProfile.t1Ranger().copyWith(id: 99, name: 'Racer');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (_) => AppModeCubit(),
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Fly'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(FlyScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await container.read(activeDroneProfileProvider.notifier).select(otherProfile);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final log = container.read(systemMessageLogProvider);
|
||||||
|
expect(log.map((e) => e.message), contains('Drone profile changed to "Racer"'));
|
||||||
|
|
||||||
|
await _stopTelemetry(container);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'Wegpunktliste scrollt beim Oeffnen automatisch zum aktiven Wegpunkt',
|
'Wegpunktliste scrollt beim Oeffnen automatisch zum aktiven Wegpunkt',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user