Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2581bdaaef | ||
|
|
eca1c647b3 | ||
|
|
6b7977eca1 | ||
|
|
afc380cabc | ||
|
|
fdefa3e257 | ||
|
|
5fd5d0117a | ||
|
|
c9f16b13f8 | ||
|
|
d8a0809935 | ||
|
|
eb76d4deb1 | ||
|
|
03c24b830e |
@@ -165,6 +165,8 @@ Enthält u. a.:
|
|||||||
| 4.22 | **Mission-Domain-Layer + Protokoll-Adapter als eigenständiges reines Dart-Package (Flutter-Ziel)** | Isoliert unit-testbar ohne Emulator; wiederverwendbar für z. B. CLI-Tools oder Backend-Simulatoren |
|
| 4.22 | **Mission-Domain-Layer + Protokoll-Adapter als eigenständiges reines Dart-Package (Flutter-Ziel)** | Isoliert unit-testbar ohne Emulator; wiederverwendbar für z. B. CLI-Tools oder Backend-Simulatoren |
|
||||||
| 4.23 | **`go_router` für Navigation (Flutter-Ziel)** | Typisierte Übergänge, passend zum expliziten Mode-Wechsel-Trigger der State-Machine |
|
| 4.23 | **`go_router` für Navigation (Flutter-Ziel)** | Typisierte Übergänge, passend zum expliziten Mode-Wechsel-Trigger der State-Machine |
|
||||||
| 4.24 | **SSID-Anzeige in den Settings aufgegeben, Verbindungspille zeigt nur generisches "Connected"** | Auf echter Hardware (Pixel-Gerät, Android 16) liefert `NetworkCapabilities.getTransportInfo()` konsequent ein redigiertes `WifiInfo` (SSID `<unknown ssid>`, BSSID maskiert) — bestätigt per natives `Log.d` in `MlrsNetworkPlugin.onAvailable()`/`onCapabilitiesChanged()`, gegengeprüft mit `adb shell dumpsys wifi` (Shell-Kontext kennt und ordnet die echte SSID korrekt der App zu, das App-API liefert sie aber nicht). Die im Code angenommene "Ausnahme für selbst angefragte `WifiNetworkSpecifier`-Netze ohne `ACCESS_FINE_LOCATION`" gilt offenbar nur für `WifiManager.getConnectionInfo()`, nicht für diesen `NetworkCapabilities`-Weg über `ConnectivityManager.NetworkCallback`. Statt `ACCESS_FINE_LOCATION` (inkl. Laufzeit-Prompt) nur für diese kosmetische Anzeige hinzuzufügen, hat der Nutzer sich bewusst dagegen entschieden — die zugrundeliegende SSID-Merk-Logik (`UdpTransport.connectedSsid`/`_rememberSsid`, `preferredSsid` für stilles Reconnect) bleibt bestehen, liefert im Regelfall aber `null`/keinen Effekt |
|
| 4.24 | **SSID-Anzeige in den Settings aufgegeben, Verbindungspille zeigt nur generisches "Connected"** | Auf echter Hardware (Pixel-Gerät, Android 16) liefert `NetworkCapabilities.getTransportInfo()` konsequent ein redigiertes `WifiInfo` (SSID `<unknown ssid>`, BSSID maskiert) — bestätigt per natives `Log.d` in `MlrsNetworkPlugin.onAvailable()`/`onCapabilitiesChanged()`, gegengeprüft mit `adb shell dumpsys wifi` (Shell-Kontext kennt und ordnet die echte SSID korrekt der App zu, das App-API liefert sie aber nicht). Die im Code angenommene "Ausnahme für selbst angefragte `WifiNetworkSpecifier`-Netze ohne `ACCESS_FINE_LOCATION`" gilt offenbar nur für `WifiManager.getConnectionInfo()`, nicht für diesen `NetworkCapabilities`-Weg über `ConnectivityManager.NetworkCallback`. Statt `ACCESS_FINE_LOCATION` (inkl. Laufzeit-Prompt) nur für diese kosmetische Anzeige hinzuzufügen, hat der Nutzer sich bewusst dagegen entschieden — die zugrundeliegende SSID-Merk-Logik (`UdpTransport.connectedSsid`/`_rememberSsid`, `preferredSsid` für stilles Reconnect) bleibt bestehen, liefert im Regelfall aber `null`/keinen Effekt |
|
||||||
|
| 4.25 | **Homepoint-Abfrage (`MSP_WP`/0x76, WP#0) gezielt statt kontinuierlich, nur bei Connect/GPS-Fix/Armen** | `getWaypoint()` in `navigation.c` liefert für WP#0 den Homepoint (`GPS_home.lat/lon/alt`), aber (0,0,0) statt eines Fehlers, solange `STATE(GPS_FIX_HOME)` nicht gesetzt ist — als "kein Homepoint" behandelt (`parseMspHomePoint`). Der FC selbst friert den Homepoint nicht beim ersten GPS-Fix ein, sondern lässt ihn der Drohne folgen, solange sie disarmed ist und noch nie armed war (`updateHomePosition()`), und sperrt ihn erst beim ersten Armen (iNAV-Default `reset_home_type = FIRST_ARM`, `settings.yaml`) — kontinuierliches Polling wäre also die meiste Zeit sinnlos; `home_point_provider.dart` fragt stattdessen gezielt bei genau diesen drei Momenten neu ab |
|
||||||
|
| 4.26 | **Manuelle "Fetch wind"/"Validate"-Knöpfe entfernt, Wind pro Wegpunkt automatisch abgerufen** | Statt eines expliziten Knopfs holt `HeaderWindNotifier.ensurePerWaypointWind()` (`wind_provider.dart`) den Wind jetzt automatisch beim Öffnen der Wegpunktliste oder Aktivieren der Kopfleisten-Windanzeige — abgeglichen über eine Lat/Lon-Signatur der Wegpunktliste, damit spätere Höhen-/Geschwindigkeits-Änderungen keinen erneuten Request auslösen, wohl aber eine geänderte Wegpunktliste selbst (`ref.listen(currentMissionProvider, ...)` in derselben Notifier-Klasse). Das bislang separate Wind-Validierungspanel (`WindValidatePanel`, `WindService.fetchValidation`) hatte außer dem entfernten "Validate"-Knopf keinen weiteren Aufrufer und wurde komplett mitentfernt, statt als toter Code liegen zu bleiben. Nebenbei behoben: die Pro-Wegpunkt-Windanzeige auf der Karte (`MissionMap.windMarkers`) war nie in `fly_screen.dart` verdrahtet, nur in `plan_screen.dart` — im Fly-Modus blieb sie bislang wirkungslos, obwohl die Kopfleisten-Pille dort ebenfalls sichtbar ist. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -98,3 +98,54 @@ FlyMissionStatus? computeFlyMissionStatus({
|
|||||||
etaToActiveWaypointSec: eta,
|
etaToActiveWaypointSec: eta,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `MW_GPS_MODE_RTH` (siehe MspNavMode._modeNames in msp_telemetry_codec.dart).
|
||||||
|
const navModeReturnToHome = 2;
|
||||||
|
|
||||||
|
/// Ziel des Gummiband-Pfeils von der Drohne zum aktuellen Flugziel im
|
||||||
|
/// Fly-Modus (Doku: MissionMap.rubberBandLine) - normalerweise der aktive
|
||||||
|
/// Missionswegpunkt, waehrend Return-to-Home ([navModeReturnToHome])
|
||||||
|
/// stattdessen [homePoint].
|
||||||
|
///
|
||||||
|
/// iNAV friert `activeWaypointIndex` beim RTH-Eintritt auf dem zuletzt
|
||||||
|
/// aktiven Missionswegpunkt ein, statt ihn auf den Homepoint zu setzen
|
||||||
|
/// (`navigation.c`: `NAV_Status.activeWpIndex` wird unbedingt aus
|
||||||
|
/// `posControl.activeWaypointIndex` abgeleitet, keiner der
|
||||||
|
/// `navOnEnteringState_NAV_STATE_RTH_*`-Handler fasst diesen Wert an) - ein
|
||||||
|
/// Gummiband zu diesem (ggf. laengst passierten) Wegpunkt waere im RTH-Fall
|
||||||
|
/// irrefuehrend. Da [navMode] bei jedem neuen Telemetrie-Frame neu geprueft
|
||||||
|
/// wird, schaltet ein Abbruch von RTH zurueck in den Wegpunktmodus das
|
||||||
|
/// Ergebnis von selbst wieder auf den Missionswegpunkt zurueck.
|
||||||
|
///
|
||||||
|
/// Liefert null, wenn kein sinnvolles Ziel bekannt ist (RTH ohne bereits
|
||||||
|
/// abgefragten Homepoint, oder [activeWaypointIndex] ausserhalb der Liste).
|
||||||
|
LatLng? computeRubberBandTarget({
|
||||||
|
required int navMode,
|
||||||
|
required int? activeWaypointIndex,
|
||||||
|
required List<Waypoint> waypoints,
|
||||||
|
LatLng? homePoint,
|
||||||
|
}) {
|
||||||
|
if (navMode == navModeReturnToHome) {
|
||||||
|
return homePoint;
|
||||||
|
}
|
||||||
|
if (activeWaypointIndex == null ||
|
||||||
|
activeWaypointIndex < 0 ||
|
||||||
|
activeWaypointIndex >= waypoints.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final target = waypoints[activeWaypointIndex];
|
||||||
|
return LatLng(target.lat, target.lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernung von der aktuellen Position zum Homepoint in Metern (Doku:
|
||||||
|
/// "Entfernung zum Home Punkt als letztes Element in der Details-Pille im
|
||||||
|
/// Fly-Modus anzeigen") - null, solange eine der beiden Positionen nicht
|
||||||
|
/// bekannt ist (kein GPS-Fix bzw. Homepoint noch nicht abgefragt, siehe
|
||||||
|
/// home_point_provider.dart).
|
||||||
|
double? computeDistanceToHomeM({
|
||||||
|
required LatLng? currentPosition,
|
||||||
|
required LatLng? homePoint,
|
||||||
|
}) {
|
||||||
|
if (currentPosition == null || homePoint == null) return null;
|
||||||
|
return _distance(currentPosition, homePoint);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,64 +15,6 @@ class WindCombinedLevels {
|
|||||||
final double elevationM;
|
final double elevationM;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WindValidationFixedReading {
|
|
||||||
const WindValidationFixedReading({
|
|
||||||
required this.heightAglM,
|
|
||||||
this.speedMs,
|
|
||||||
this.dirFromDeg,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int heightAglM;
|
|
||||||
final double? speedMs;
|
|
||||||
final double? dirFromDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
class WindValidationPressureReading {
|
|
||||||
const WindValidationPressureReading({
|
|
||||||
required this.pressureHpa,
|
|
||||||
this.heightAmslM,
|
|
||||||
this.speedMs,
|
|
||||||
this.dirFromDeg,
|
|
||||||
required this.excluded,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int pressureHpa;
|
|
||||||
final double? heightAmslM;
|
|
||||||
final double? speedMs;
|
|
||||||
final double? dirFromDeg;
|
|
||||||
|
|
||||||
/// True, wenn dieser Stuetzpunkt unvollstaendig ist oder unterhalb der
|
|
||||||
/// Bodenhoehe liegt und daher von der Interpolation ausgeschlossen wurde.
|
|
||||||
final bool excluded;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ergebnis des Wind-Validierungspanels (Doku 3.8): rohe Messwerte auf
|
|
||||||
/// festen Nabenhoehen und Druckflaechen, damit nachvollziehbar bleibt, wie
|
|
||||||
/// der interpolierte Wert an [interpolatedAt120] zustande kam.
|
|
||||||
class WindValidationResult {
|
|
||||||
const WindValidationResult({
|
|
||||||
required this.lat,
|
|
||||||
required this.lon,
|
|
||||||
required this.elevationM,
|
|
||||||
required this.fixedRequestFailed,
|
|
||||||
required this.fixedReadings,
|
|
||||||
this.gustMs,
|
|
||||||
required this.pressureRequestFailed,
|
|
||||||
required this.pressureReadings,
|
|
||||||
this.interpolatedAt120,
|
|
||||||
});
|
|
||||||
|
|
||||||
final double lat;
|
|
||||||
final double lon;
|
|
||||||
final double elevationM;
|
|
||||||
final bool fixedRequestFailed;
|
|
||||||
final List<WindValidationFixedReading> fixedReadings;
|
|
||||||
final double? gustMs;
|
|
||||||
final bool pressureRequestFailed;
|
|
||||||
final List<WindValidationPressureReading> pressureReadings;
|
|
||||||
final WindSample? interpolatedAt120;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte,
|
/// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte,
|
||||||
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
|
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
|
||||||
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
|
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
|
||||||
@@ -261,98 +203,4 @@ class WindService {
|
|||||||
windElevationM: combined.elevationM,
|
windElevationM: combined.elevationM,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detaillierte Rohdaten fuer das Wind-Validierungspanel: einzelne
|
|
||||||
/// Messwerte je Nabenhoehe/Druckflaeche, damit nachvollziehbar bleibt, wie
|
|
||||||
/// der interpolierte 120-m-Wert zustande kam (Doku 3.8/4.9: Boeen nur
|
|
||||||
/// hier, nicht in der Wegpunktanzeige).
|
|
||||||
Future<WindValidationResult> fetchValidation(double lat, double lon) async {
|
|
||||||
final lats = lat.toStringAsFixed(5);
|
|
||||||
final lons = lon.toStringAsFixed(5);
|
|
||||||
final results = await Future.wait([
|
|
||||||
_fetchJson(_fixedUri(lats, lons)),
|
|
||||||
_fetchJson(_pressureUri(lats, lons)),
|
|
||||||
]);
|
|
||||||
final fixedRes = _asResultList(results[0], 1)[0];
|
|
||||||
final pressureRes = _asResultList(results[1], 1)[0];
|
|
||||||
|
|
||||||
final elevation = ((fixedRes?['elevation'] as num?) ??
|
|
||||||
(pressureRes?['elevation'] as num?))
|
|
||||||
?.toDouble() ??
|
|
||||||
0.0;
|
|
||||||
|
|
||||||
var fixedReadings = <WindValidationFixedReading>[];
|
|
||||||
double? gust;
|
|
||||||
final fHourly = fixedRes?['hourly'] as Map<String, dynamic>?;
|
|
||||||
final fTime = fHourly?['time'] as List<dynamic>?;
|
|
||||||
final fixedFailed = fHourly == null || fTime == null || fTime.isEmpty;
|
|
||||||
if (!fixedFailed) {
|
|
||||||
final idx = _nearestHourIndex(fTime);
|
|
||||||
fixedReadings = [
|
|
||||||
for (final h in _fixedHeights)
|
|
||||||
WindValidationFixedReading(
|
|
||||||
heightAglM: h,
|
|
||||||
speedMs: ((fHourly['wind_speed_${h}m'] as List?)?[idx] as num?)
|
|
||||||
?.toDouble(),
|
|
||||||
dirFromDeg: ((fHourly['wind_direction_${h}m'] as List?)?[idx] as num?)
|
|
||||||
?.toDouble(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
gust = ((fHourly['wind_gusts_10m'] as List?)?[idx] as num?)?.toDouble();
|
|
||||||
}
|
|
||||||
|
|
||||||
var pressureReadings = <WindValidationPressureReading>[];
|
|
||||||
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
|
|
||||||
final pTime = pHourly?['time'] as List<dynamic>?;
|
|
||||||
final pressureFailed = pHourly == null || pTime == null || pTime.isEmpty;
|
|
||||||
if (!pressureFailed) {
|
|
||||||
final idx = _nearestHourIndex(pTime);
|
|
||||||
pressureReadings = [
|
|
||||||
for (final p in _pressureLevels)
|
|
||||||
_pressureReadingAt(pHourly, idx, p, elevation),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
final combined = buildCombinedWindLevels(pressureRes, fixedRes);
|
|
||||||
final interpolated = combined.levels.isEmpty
|
|
||||||
? null
|
|
||||||
: interpolateWindAtHeight(combined.levels, elevation + 120);
|
|
||||||
|
|
||||||
return WindValidationResult(
|
|
||||||
lat: lat,
|
|
||||||
lon: lon,
|
|
||||||
elevationM: elevation,
|
|
||||||
fixedRequestFailed: fixedFailed,
|
|
||||||
fixedReadings: fixedReadings,
|
|
||||||
gustMs: gust,
|
|
||||||
pressureRequestFailed: pressureFailed,
|
|
||||||
pressureReadings: pressureReadings,
|
|
||||||
interpolatedAt120: interpolated,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
WindValidationPressureReading _pressureReadingAt(
|
|
||||||
Map<String, dynamic> hourly,
|
|
||||||
int idx,
|
|
||||||
int pressureHpa,
|
|
||||||
double elevationM,
|
|
||||||
) {
|
|
||||||
final h = (hourly['geopotential_height_${pressureHpa}hPa'] as List?)?[idx];
|
|
||||||
final s = (hourly['wind_speed_${pressureHpa}hPa'] as List?)?[idx];
|
|
||||||
final d = (hourly['wind_direction_${pressureHpa}hPa'] as List?)?[idx];
|
|
||||||
final heightAmslM = h is num ? h.toDouble() : null;
|
|
||||||
final speedMs = s is num ? s.toDouble() : null;
|
|
||||||
final dirFromDeg = d is num ? d.toDouble() : null;
|
|
||||||
final excluded = heightAmslM == null ||
|
|
||||||
speedMs == null ||
|
|
||||||
dirFromDeg == null ||
|
|
||||||
heightAmslM < elevationM;
|
|
||||||
return WindValidationPressureReading(
|
|
||||||
pressureHpa: pressureHpa,
|
|
||||||
heightAmslM: heightAmslM,
|
|
||||||
speedMs: speedMs,
|
|
||||||
dirFromDeg: dirFromDeg,
|
|
||||||
excluded: excluded,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,24 @@ class TelemetryFrame {
|
|||||||
final int? activeWaypointIndex;
|
final int? activeWaypointIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vom Flightcontroller gemeldeter Homepoint (Doku: "den auf der Drohne
|
||||||
|
/// gelockten Homepoint abfragen") - anders als [TelemetryFrame] NICHT Teil
|
||||||
|
/// des kontinuierlichen Telemetrie-Stroms, sondern gezielt per
|
||||||
|
/// [FlightControllerLink.readHomePoint] abgefragt (siehe dortige Doku und
|
||||||
|
/// ui/providers/home_point_provider.dart fuer die drei Momente, zu denen
|
||||||
|
/// das tatsaechlich noetig ist).
|
||||||
|
class HomePoint {
|
||||||
|
const HomePoint({required this.lat, required this.lon, required this.altitudeM});
|
||||||
|
|
||||||
|
final double lat;
|
||||||
|
final double lon;
|
||||||
|
|
||||||
|
/// Referenzhoehe des Homepoints in Metern (bei MSP: `GPS_home.alt` in cm,
|
||||||
|
/// bereits umgerechnet) - relativ zum selben Nullpunkt wie
|
||||||
|
/// TelemetryFrame.altitudeM, keine absolute Hoehe ueber Meeresspiegel.
|
||||||
|
final double altitudeM;
|
||||||
|
}
|
||||||
|
|
||||||
/// Zentrale Abstraktion fuer alle Flightcontroller-Interaktionen (Doku 3.1).
|
/// Zentrale Abstraktion fuer alle Flightcontroller-Interaktionen (Doku 3.1).
|
||||||
/// Implementierungen: MspFlightControllerLink (iNAV), MavlinkFlightControllerLink
|
/// Implementierungen: MspFlightControllerLink (iNAV), MavlinkFlightControllerLink
|
||||||
/// (ArduPilot, Post-MVP), MockFlightControllerLink (Tests ohne Hardware).
|
/// (ArduPilot, Post-MVP), MockFlightControllerLink (Tests ohne Hardware).
|
||||||
@@ -171,4 +189,13 @@ abstract class FlightControllerLink {
|
|||||||
Stream<TelemetryFrame> subscribeTelemetry();
|
Stream<TelemetryFrame> subscribeTelemetry();
|
||||||
|
|
||||||
Future<int?> readActiveWaypointIndex();
|
Future<int?> readActiveWaypointIndex();
|
||||||
|
|
||||||
|
/// Fragt den aktuell auf dem Flightcontroller gespeicherten Homepoint ab,
|
||||||
|
/// oder `null`, wenn dort noch keiner gesetzt ist (bei MSP: FC meldet vor
|
||||||
|
/// dem ersten Fix (0,0,0) statt eines Fehlers, siehe
|
||||||
|
/// `MspFlightControllerLink.readHomePoint`-Doku). Bewusst ein einzelner
|
||||||
|
/// gezielter Abruf statt eines Streams/Poll-Feldes - der Homepoint
|
||||||
|
/// aendert sich ausserhalb weniger Trigger-Momente ohnehin nicht (siehe
|
||||||
|
/// ui/providers/home_point_provider.dart).
|
||||||
|
Future<HomePoint?> readHomePoint();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,4 +222,24 @@ class MockFlightControllerLink implements FlightControllerLink {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<int?> readActiveWaypointIndex() async => _activeWaypointIndex;
|
Future<int?> readActiveWaypointIndex() async => _activeWaypointIndex;
|
||||||
|
|
||||||
|
/// Liefert immer einen Wert (nahe dem Ankerpunkt, siehe Klassendoku) statt
|
||||||
|
/// wie der echte FC vor dem ersten Fix `null` - fuer UI-Entwicklung ohne
|
||||||
|
/// Hardware (Doku 9) ist ein sofort sichtbarer Homepoint-Marker
|
||||||
|
/// hilfreicher als ein realistisch leerer Zustand.
|
||||||
|
///
|
||||||
|
/// Bewusst um [_homeOffsetM] Meter suedlich des Ankerpunkts versetzt statt
|
||||||
|
/// exakt darauf: der Drohnen-Marker (siehe connect()) startet ebenfalls am
|
||||||
|
/// Ankerpunkt und drueckt sich nur innerhalb von [_driftRadiusM] (10 m)
|
||||||
|
/// davon weg - ohne Versatz lag der Homepoint-Marker im Mock-Test also
|
||||||
|
/// praktisch immer direkt unter dem Drohnen-Marker und war nie als
|
||||||
|
/// eigenes Icon zu sehen.
|
||||||
|
static const _homeOffsetM = 25.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomePoint?> readHomePoint() async => HomePoint(
|
||||||
|
lat: _anchorLat - _homeOffsetM / _metersPerDegreeLat,
|
||||||
|
lon: _anchorLon,
|
||||||
|
altitudeM: 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ abstract final class MspCommands {
|
|||||||
/// msp_waypoint_codec.dart. (`fc_msp.c`, `case MSP_WP_GETINFO`)
|
/// msp_waypoint_codec.dart. (`fc_msp.c`, `case MSP_WP_GETINFO`)
|
||||||
static const int wpGetInfo = 20;
|
static const int wpGetInfo = 20;
|
||||||
|
|
||||||
|
/// Request-Payload (1 Byte): u8 wp_no. Response (21 Byte): u8 wp_no, u8
|
||||||
|
/// action, i32 lat(1e-7 deg), i32 lon(1e-7 deg), i32 alt(cm), i16 p1, i16
|
||||||
|
/// p2, i16 p3, u8 flag (`fc_msp.c`, `mspFcWaypointOutCommand()`, `case
|
||||||
|
/// MSP_WP`). Kein Batch-Kommando - ein Request pro Wegpunktnummer, analog
|
||||||
|
/// zu [setWp]. `wp_no` kennt laut `navigation.c` `getWaypoint()` zwei
|
||||||
|
/// Sonderwerte neben echten Missions-Wegpunktnummern: 0 = Homepoint
|
||||||
|
/// (`GPS_home.lat/lon/alt`, nur gueltig wenn `STATE(GPS_FIX_HOME)` -
|
||||||
|
/// sonst liefert der FC (0,0,0) statt eines Fehlers, siehe
|
||||||
|
/// msp_waypoint_codec.dart `parseMspHomePoint`), 255 = aktuelle
|
||||||
|
/// Ist-Position. Der Homepoint selbst wird vom FC NICHT beim ersten
|
||||||
|
/// GPS-Fix eingefroren, sondern folgt der Drohne kontinuierlich, solange
|
||||||
|
/// sie disarmed ist und noch nie armed war (`updateHomePosition()`) -
|
||||||
|
/// gesperrt wird er erst beim ersten Armen (iNAV-Default
|
||||||
|
/// `reset_home_type = FIRST_ARM`, `settings.yaml`).
|
||||||
|
static const int getWp = 118;
|
||||||
|
|
||||||
/// Request-Payload (21 Byte, siehe msp_waypoint_codec.dart
|
/// Request-Payload (21 Byte, siehe msp_waypoint_codec.dart
|
||||||
/// `encodeMspSetWaypoint`): u8 wp_no, u8 action, i32 lat(1e-7 deg),
|
/// `encodeMspSetWaypoint`): u8 wp_no, u8 action, i32 lat(1e-7 deg),
|
||||||
/// i32 lon(1e-7 deg), i32 alt(cm), i16 p1, i16 p2, i16 p3, u8 flag. Nur
|
/// i32 lon(1e-7 deg), i32 alt(cm), i16 p1, i16 p2, i16 p3, u8 flag. Nur
|
||||||
|
|||||||
@@ -142,4 +142,21 @@ class MspFlightControllerLink implements FlightControllerLink {
|
|||||||
await client.request(MspCommands.navStatus),
|
await client.request(MspCommands.navStatus),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fragt WP#0 (Homepoint, siehe `MspCommands.getWp`-Doku) gezielt per
|
||||||
|
/// `MSP_WP` ab - bewusst kein Teil von [MspTelemetryPoller]s Zyklus (Doku:
|
||||||
|
/// "Homepoint muss nicht kontinuierlich abgefragt werden"), Aufrufer ist
|
||||||
|
/// home_point_provider.dart.
|
||||||
|
@override
|
||||||
|
Future<HomePoint?> readHomePoint() async {
|
||||||
|
final client = _client;
|
||||||
|
if (client == null) {
|
||||||
|
throw StateError('readHomePoint() vor connect() aufgerufen.');
|
||||||
|
}
|
||||||
|
final payload = await client.request(
|
||||||
|
MspCommands.getWp,
|
||||||
|
payload: encodeMspGetWaypointRequest(mspWpNumberHome),
|
||||||
|
);
|
||||||
|
return parseMspHomePoint(payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import '../flight_controller_link.dart';
|
||||||
|
|
||||||
/// `navWaypointActions_e` (Doku Kommunikationsschicht v2 Abschnitt 6,
|
/// `navWaypointActions_e` (Doku Kommunikationsschicht v2 Abschnitt 6,
|
||||||
/// geprueft gegen den iNAV-9.1.0-Quellcode: `navigation.h`). Nur die vier
|
/// geprueft gegen den iNAV-9.1.0-Quellcode: `navigation.h`). Nur die vier
|
||||||
@@ -136,3 +137,30 @@ MspWpGetInfo parseMspWpGetInfo(Uint8List payload) {
|
|||||||
waypointCount: payload[3],
|
waypointCount: payload[3],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sonderwert fuer [encodeMspGetWaypointRequest]/`MspCommands.getWp`: WP#0
|
||||||
|
/// ist laut `navigation.c` `getWaypoint()` der Homepoint, kein echter
|
||||||
|
/// Missions-Wegpunkt.
|
||||||
|
const int mspWpNumberHome = 0;
|
||||||
|
|
||||||
|
/// Kodiert den 1-Byte-Request-Payload fuer `MspCommands.getWp` (`MSP_WP`).
|
||||||
|
Uint8List encodeMspGetWaypointRequest(int wpNumber) =>
|
||||||
|
Uint8List.fromList([wpNumber]);
|
||||||
|
|
||||||
|
/// Parst die 21-Byte-Antwort auf `MspCommands.getWp` fuer `wp_no ==
|
||||||
|
/// [mspWpNumberHome]` (siehe dortige Doku zu `getWaypoint()`/`GPS_home`).
|
||||||
|
///
|
||||||
|
/// Liefert `null`, wenn der FC noch keinen Homepoint gesetzt hat: dafuer
|
||||||
|
/// gibt es kein eigenes Flag in der Antwort selbst, `getWaypoint()`
|
||||||
|
/// initialisiert `lat`/`lon`/`alt` aber generell auf 0 und ueberschreibt
|
||||||
|
/// das fuer WP#0 nur, wenn `STATE(GPS_FIX_HOME)` gesetzt ist - (0,0) ist
|
||||||
|
/// also der FC-eigene "kein Homepoint"-Wert, nicht eine echte Koordinate
|
||||||
|
/// am Nullmeridian/Aequator.
|
||||||
|
HomePoint? parseMspHomePoint(Uint8List payload) {
|
||||||
|
final d = ByteData.sublistView(payload);
|
||||||
|
final lat = d.getInt32(2, Endian.little) / 1e7;
|
||||||
|
final lon = d.getInt32(6, Endian.little) / 1e7;
|
||||||
|
if (lat == 0 && lon == 0) return null;
|
||||||
|
final altCm = d.getInt32(10, Endian.little);
|
||||||
|
return HomePoint(lat: lat, lon: lon, altitudeM: altCm / 100);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ class UdpTransport implements LinkTransport {
|
|||||||
|
|
||||||
UdpTransport({
|
UdpTransport({
|
||||||
required this._getHost,
|
required this._getHost,
|
||||||
required Future<int> Function() getPort,
|
required this._getPort,
|
||||||
required this._getSsidPrefix,
|
required this._getSsidPrefix,
|
||||||
required this._getRememberedSsid,
|
required this._getRememberedSsid,
|
||||||
required this._rememberSsid,
|
required this._rememberSsid,
|
||||||
MlrsNetworkController? networkController,
|
MlrsNetworkController? networkController,
|
||||||
}) : _getPort = getPort,
|
}) : _networkController =
|
||||||
_networkController =
|
|
||||||
networkController ?? MethodChannelMlrsNetworkController();
|
networkController ?? MethodChannelMlrsNetworkController();
|
||||||
|
|
||||||
final Future<String?> Function() _getHost;
|
final Future<String?> Function() _getHost;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../transport/flight_controller_link.dart';
|
||||||
|
import 'telemetry_provider.dart';
|
||||||
|
|
||||||
|
/// Zuletzt vom Flightcontroller abgefragter Homepoint (Doku: "den auf der
|
||||||
|
/// Drohne gelockten Homepoint abfragen") - reine Ablage, analog
|
||||||
|
/// systemMessageLogProvider. Die eigentliche Abfrage-Logik (wann neu
|
||||||
|
/// nachgefragt wird) sitzt getrennt in [homePointAutoRefreshProvider], aus
|
||||||
|
/// demselben Grund wie dort: nur der Fly-Modus soll dafuer den Telemetrie-/
|
||||||
|
/// Transport-Stack beobachten.
|
||||||
|
class HomePointNotifier extends Notifier<HomePoint?> {
|
||||||
|
@override
|
||||||
|
HomePoint? build() => null;
|
||||||
|
|
||||||
|
void set(HomePoint? point) => state = point;
|
||||||
|
}
|
||||||
|
|
||||||
|
final homePointProvider =
|
||||||
|
NotifierProvider<HomePointNotifier, HomePoint?>(HomePointNotifier.new);
|
||||||
|
|
||||||
|
/// Frischt [homePointProvider] gezielt bei drei Momenten auf, statt
|
||||||
|
/// kontinuierlich (Doku: "der Homepoint muss nicht kontinuierlich abgefragt
|
||||||
|
/// werden, aber an diesen drei Stellen macht es Sinn"):
|
||||||
|
/// - Verbindungsaufbau/-wiederaufbau: der FC koennte seit dem letzten Mal
|
||||||
|
/// einen anderen Homepoint gesetzt haben (z.B. nach einem Akkuwechsel).
|
||||||
|
/// - GPS-Fix-Erwerb: vorher konnte der FC ohnehin noch keinen gueltigen
|
||||||
|
/// Homepoint gesetzt haben (siehe `MspCommands.getWp`-Doku:
|
||||||
|
/// `STATE(GPS_FIX_HOME)`).
|
||||||
|
/// - Armen: laut iNAV-Default (`reset_home_type = FIRST_ARM`,
|
||||||
|
/// `navigation.c`/`settings.yaml`) friert genau dieser Moment den bis
|
||||||
|
/// dahin der Drohne folgenden Homepoint ein - vorherige Abfragen koennten
|
||||||
|
/// also noch veraltet/in Bewegung gewesen sein.
|
||||||
|
///
|
||||||
|
/// autoDispose wie systemMessageAutoLogProvider (dortige Doku): nur aktiv,
|
||||||
|
/// waehrend irgendwer (FlyScreen) diesen Provider beobachtet, damit ein
|
||||||
|
/// simpler Missionswechsel im Plan-Modus nicht ungewollt den kompletten
|
||||||
|
/// Telemetrie-/Transport-Stack mit hochreisst.
|
||||||
|
final homePointAutoRefreshProvider = Provider.autoDispose<void>((ref) {
|
||||||
|
TelemetryFrame? previousFrame;
|
||||||
|
var hadData = false;
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final link = ref.read(flightControllerLinkProvider);
|
||||||
|
try {
|
||||||
|
ref.read(homePointProvider.notifier).set(await link.readHomePoint());
|
||||||
|
} catch (_) {
|
||||||
|
// Einzelne fehlgeschlagene Abfrage ist kein Beinbruch - der naechste
|
||||||
|
// Trigger (naechster Fix/Armen/Reconnect) versucht es erneut.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
|
||||||
|
next.whenData((frame) {
|
||||||
|
final justConnected = !hadData;
|
||||||
|
final fixJustAcquired =
|
||||||
|
previousFrame != null && !previousFrame!.hasFix && frame.hasFix;
|
||||||
|
final justArmed = previousFrame != null && !previousFrame!.armed && frame.armed;
|
||||||
|
previousFrame = frame;
|
||||||
|
hadData = true;
|
||||||
|
if (justConnected || fixJustAcquired || justArmed) {
|
||||||
|
unawaited(refresh());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (next is AsyncError) {
|
||||||
|
previousFrame = null;
|
||||||
|
hadData = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../../domain/wind/wind_math.dart';
|
import '../../domain/wind/wind_math.dart';
|
||||||
import '../../services/wind/wind_service.dart';
|
import '../../services/wind/wind_service.dart';
|
||||||
import 'current_mission_provider.dart';
|
import 'current_mission_provider.dart';
|
||||||
@@ -37,10 +40,38 @@ class HeaderWindState {
|
|||||||
|
|
||||||
/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem
|
/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem
|
||||||
/// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog
|
/// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog
|
||||||
/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator.
|
/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator. Verwaltet
|
||||||
|
/// ausserdem den automatischen Pro-Wegpunkt-Windabruf (siehe
|
||||||
|
/// [ensurePerWaypointWind]-Doku) - beides teilt sich denselben
|
||||||
|
/// [windServiceProvider] und lebt bewusst in derselben, nicht-autoDispose
|
||||||
|
/// Notifier-Klasse, damit der Abgleich mit der Wegpunktliste app-weit
|
||||||
|
/// weiterlaeuft, unabhaengig davon, welche der beiden Oberflaechen
|
||||||
|
/// (Wegpunktliste, Kopfleisten-Pille) ihn zuletzt ausgeloest hat.
|
||||||
class HeaderWindNotifier extends Notifier<HeaderWindState> {
|
class HeaderWindNotifier extends Notifier<HeaderWindState> {
|
||||||
|
/// Wurde der Pro-Wegpunkt-Windabruf schon mindestens einmal ausgeloest
|
||||||
|
/// (Doku: "automatisch abrufen, wenn das menue geoeffnet wird oder die
|
||||||
|
/// windanzeige ... aktiviert wird")? Erst danach reagiert [build] auf
|
||||||
|
/// spaetere Aenderungen der Wegpunktliste automatisch.
|
||||||
|
bool _perWaypointActivated = false;
|
||||||
|
|
||||||
|
/// Lat/Lon-Signatur der Wegpunktliste, fuer die der zuletzt bekannte
|
||||||
|
/// Windstand gilt (entweder frisch abgerufen oder als bereits vorhanden
|
||||||
|
/// erkannt, siehe [ensurePerWaypointWind]) - Aenderungen an anderen
|
||||||
|
/// Wegpunkt-Feldern (Hoehe/Geschwindigkeit/Aktion) loesen bewusst KEINEN
|
||||||
|
/// erneuten Abruf aus (Doku: "nur noch aktualisieren, wenn sich die
|
||||||
|
/// wegpunktliste geaendert hat").
|
||||||
|
List<(double, double)>? _lastSignature;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HeaderWindState build() => const HeaderWindState();
|
HeaderWindState build() {
|
||||||
|
ref.listen<List<Waypoint>>(currentMissionProvider, (previous, next) {
|
||||||
|
if (!_perWaypointActivated) return;
|
||||||
|
final signature = _signatureOf(next);
|
||||||
|
if (_signatureEquals(signature, _lastSignature)) return;
|
||||||
|
unawaited(_fetchPerWaypointWind(next, signature));
|
||||||
|
});
|
||||||
|
return const HeaderWindState();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final waypoints = ref.read(currentMissionProvider);
|
final waypoints = ref.read(currentMissionProvider);
|
||||||
@@ -65,27 +96,64 @@ class HeaderWindNotifier extends Notifier<HeaderWindState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void toggleShowPerWaypoint() {
|
void toggleShowPerWaypoint() {
|
||||||
state = state.copyWith(showPerWaypoint: !state.showPerWaypoint);
|
final turningOn = !state.showPerWaypoint;
|
||||||
|
state = state.copyWith(showPerWaypoint: turningOn);
|
||||||
|
// Nur beim Einschalten abrufen (Doku: "wenn ... die windanzeige ...
|
||||||
|
// aktiviert wird") - das Ausschalten blendet die Marker nur aus, die
|
||||||
|
// Winddaten selbst bleiben gueltig.
|
||||||
|
if (turningOn) unawaited(ensurePerWaypointWind());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt Wind fuer alle Wegpunkte, falls fuer die aktuelle Wegpunktliste
|
||||||
|
/// noch keiner bekannt ist (Doku: "wind automatisch abrufen, wenn das
|
||||||
|
/// menue geoeffnet wird oder die windanzeige ... aktiviert wird") - vom
|
||||||
|
/// Wegpunktlisten-Panel beim Oeffnen und von [toggleShowPerWaypoint]
|
||||||
|
/// aufgerufen. Wiederholte Aufrufe fuer dieselbe (unveraenderte)
|
||||||
|
/// Wegpunktliste loesen keinen erneuten Request aus; danach uebernimmt
|
||||||
|
/// der Listener in [build] die Aktualisierung bei Aenderungen der Liste.
|
||||||
|
///
|
||||||
|
/// Hat die Wegpunktliste bereits fuer alle Eintraege Winddaten (z.B. aus
|
||||||
|
/// einer gespeicherten/importierten Mission geladen), zaehlt das ebenso
|
||||||
|
/// als "aktuell" wie ein frischer Abruf - kein unnoetiger Request nur
|
||||||
|
/// wegen des Menue-Oeffnens.
|
||||||
|
Future<void> ensurePerWaypointWind() async {
|
||||||
|
_perWaypointActivated = true;
|
||||||
|
final waypoints = ref.read(currentMissionProvider);
|
||||||
|
if (waypoints.isEmpty) return;
|
||||||
|
final signature = _signatureOf(waypoints);
|
||||||
|
if (_signatureEquals(signature, _lastSignature)) return;
|
||||||
|
if (waypoints.every((w) => w.windSpeedMs != null)) {
|
||||||
|
_lastSignature = signature;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _fetchPerWaypointWind(waypoints, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPerWaypointWind(
|
||||||
|
List<Waypoint> waypoints,
|
||||||
|
List<(double, double)> signature,
|
||||||
|
) async {
|
||||||
|
// Vor dem eigentlichen Request gesetzt (nicht danach): fetchWindForWaypoints()
|
||||||
|
// aendert nur die Windfelder, niemals lat/lon - die Signatur der
|
||||||
|
// zurueckgeschriebenen Liste bleibt also identisch und der Listener in
|
||||||
|
// [build] erkennt den eigenen replaceAll()-Aufruf unten korrekt als
|
||||||
|
// "keine Aenderung", statt sich selbst erneut auszuloesen.
|
||||||
|
_lastSignature = signature;
|
||||||
|
final updated = await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
|
||||||
|
ref.read(currentMissionProvider.notifier).replaceAll(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(double, double)> _signatureOf(List<Waypoint> waypoints) =>
|
||||||
|
[for (final w in waypoints) (w.lat, w.lon)];
|
||||||
|
|
||||||
|
bool _signatureEquals(List<(double, double)> a, List<(double, double)>? b) {
|
||||||
|
if (b == null || a.length != b.length) return false;
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final headerWindProvider =
|
final headerWindProvider =
|
||||||
NotifierProvider<HeaderWindNotifier, HeaderWindState>(HeaderWindNotifier.new);
|
NotifierProvider<HeaderWindNotifier, HeaderWindState>(HeaderWindNotifier.new);
|
||||||
|
|
||||||
/// Referenzort fuer Windabfragen ohne festen Bezugspunkt (Wind-
|
|
||||||
/// Validierungspanel aus der Wegpunktliste): erster Wegpunkt, sonst
|
|
||||||
/// aktuelles Kartenzentrum - analog headerWindLocation() im HTML-
|
|
||||||
/// Demonstrator. Null, wenn die Karte noch nicht gemountet/gelayoutet ist.
|
|
||||||
(double, double)? windReferenceLocation(WidgetRef ref) {
|
|
||||||
final waypoints = ref.read(currentMissionProvider);
|
|
||||||
if (waypoints.isNotEmpty) {
|
|
||||||
final first = waypoints.first;
|
|
||||||
return (first.lat, first.lon);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final center = ref.read(mapControllerProvider).camera.center;
|
|
||||||
return (center.latitude, center.longitude);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,17 +21,21 @@ import '../../../transport/flight_controller_link.dart';
|
|||||||
import '../../providers/active_drone_profile_provider.dart';
|
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/home_point_provider.dart';
|
||||||
import '../../providers/map_controller_provider.dart';
|
import '../../providers/map_controller_provider.dart';
|
||||||
import '../../providers/mission_meta_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';
|
||||||
|
import '../../providers/wind_provider.dart';
|
||||||
import '../settings/settings_screen.dart';
|
import '../settings/settings_screen.dart';
|
||||||
import '../../widgets/drone_marker_icon.dart';
|
import '../../widgets/drone_marker_icon.dart';
|
||||||
import '../../widgets/drone_status_messages_panel.dart';
|
import '../../widgets/drone_status_messages_panel.dart';
|
||||||
|
import '../../widgets/home_point_icon.dart';
|
||||||
import '../../widgets/mission_footer_bar.dart';
|
import '../../widgets/mission_footer_bar.dart';
|
||||||
import '../../widgets/mission_map.dart';
|
import '../../widgets/mission_map.dart';
|
||||||
import '../../widgets/waypoint_list_panel.dart';
|
import '../../widgets/waypoint_list_panel.dart';
|
||||||
|
import '../../widgets/wind_marker_pill.dart';
|
||||||
|
|
||||||
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
|
/// 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
|
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
|
||||||
@@ -180,6 +184,11 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
final telemetryAsync = ref.watch(telemetryProvider);
|
final telemetryAsync = ref.watch(telemetryProvider);
|
||||||
final telemetry = telemetryAsync.value;
|
final telemetry = telemetryAsync.value;
|
||||||
final followDrone = ref.watch(followDroneProvider);
|
final followDrone = ref.watch(followDroneProvider);
|
||||||
|
// Wind-Marker pro Wegpunkt (Doku: "windanzeige pro wegpunkt im
|
||||||
|
// flugmodus aktivieren, das funktioniert momentan nicht") - dieselbe
|
||||||
|
// Kopfleisten-Pille/derselbe Zustand wie im Plan-Modus (PlanScreen),
|
||||||
|
// hier bisher schlicht nie an MissionMap durchgereicht.
|
||||||
|
final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint;
|
||||||
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
|
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
|
||||||
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
||||||
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
||||||
@@ -189,6 +198,13 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
// gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der
|
// gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der
|
||||||
// Beobachtungs-Seiteneffekt.
|
// Beobachtungs-Seiteneffekt.
|
||||||
ref.watch(systemMessageAutoLogProvider);
|
ref.watch(systemMessageAutoLogProvider);
|
||||||
|
// Homepoint-Abfrage bei Verbindungsaufbau/GPS-Fix/Armen (Doku: "den auf
|
||||||
|
// der Drohne gelockten Homepoint abfragen ... an diesen drei Stellen
|
||||||
|
// macht es Sinn") - analog systemMessageAutoLogProvider nur der
|
||||||
|
// Beobachtungs-Seiteneffekt, der eigentliche Wert kommt gleich aus
|
||||||
|
// homePointProvider.
|
||||||
|
ref.watch(homePointAutoRefreshProvider);
|
||||||
|
final homePoint = ref.watch(homePointProvider);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -284,23 +300,30 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
child: const DroneMarkerIcon(),
|
child: const DroneMarkerIcon(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Gestrichelte Linie von der Drohne zum aktiven Wegpunkt (Doku 3.4:
|
// Gestrichelte Linie von der Drohne zum aktuellen Flugziel (Doku 3.4:
|
||||||
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
|
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
|
||||||
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
|
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
|
||||||
// rubberBandLine), hier aber Drohne -> naechster Wegpunkt statt letzter
|
// rubberBandLine), hier aber Drohne -> Flugziel statt letzter Wegpunkt
|
||||||
// Wegpunkt -> Kartenmitte.
|
// -> Kartenmitte. Das Ziel selbst (Missionswegpunkt oder bei Return-to-
|
||||||
List<LatLng>? rubberBand;
|
// Home der Homepoint) kommt aus computeRubberBandTarget, siehe dortige
|
||||||
|
// Doku fuer den RTH-Sonderfall.
|
||||||
final activeWpIndex = telemetry?.activeWaypointIndex;
|
final activeWpIndex = telemetry?.activeWaypointIndex;
|
||||||
if (telemetry != null &&
|
final homePointLatLng =
|
||||||
telemetry.hasFix &&
|
homePoint == null ? null : LatLng(homePoint.lat, homePoint.lon);
|
||||||
activeWpIndex != null &&
|
final currentPosition = telemetry != null && telemetry.hasFix
|
||||||
activeWpIndex >= 0 &&
|
? LatLng(telemetry.lat, telemetry.lon)
|
||||||
activeWpIndex < waypoints.length) {
|
: null;
|
||||||
final target = waypoints[activeWpIndex];
|
List<LatLng>? rubberBand;
|
||||||
rubberBand = [
|
if (currentPosition != null && telemetry != null) {
|
||||||
LatLng(telemetry.lat, telemetry.lon),
|
final target = computeRubberBandTarget(
|
||||||
LatLng(target.lat, target.lon),
|
navMode: telemetry.navMode,
|
||||||
];
|
activeWaypointIndex: activeWpIndex,
|
||||||
|
waypoints: waypoints,
|
||||||
|
homePoint: homePointLatLng,
|
||||||
|
);
|
||||||
|
if (target != null) {
|
||||||
|
rubberBand = [currentPosition, target];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live-Missionsstatus fuer die Details-Pille (Doku: "aktiver Wegpunkt
|
// Live-Missionsstatus fuer die Details-Pille (Doku: "aktiver Wegpunkt
|
||||||
@@ -311,11 +334,19 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
final flyMissionStatus = computeFlyMissionStatus(
|
final flyMissionStatus = computeFlyMissionStatus(
|
||||||
waypoints: waypoints,
|
waypoints: waypoints,
|
||||||
activeWaypointIndex: activeWpIndex,
|
activeWaypointIndex: activeWpIndex,
|
||||||
currentLat: telemetry != null && telemetry.hasFix ? telemetry.lat : null,
|
currentLat: currentPosition?.latitude,
|
||||||
currentLon: telemetry != null && telemetry.hasFix ? telemetry.lon : null,
|
currentLon: currentPosition?.longitude,
|
||||||
currentSpeedMs: telemetry?.speedMs,
|
currentSpeedMs: telemetry?.speedMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Entfernung zum Homepoint als letztes Element der Details-Pille (Doku:
|
||||||
|
// "zeige die Entfernung zum Home Punkt als letztes element in der
|
||||||
|
// details pille im fly modus an").
|
||||||
|
final distanceToHomeM = computeDistanceToHomeM(
|
||||||
|
currentPosition: currentPosition,
|
||||||
|
homePoint: homePointLatLng,
|
||||||
|
);
|
||||||
|
|
||||||
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
|
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
|
||||||
// drone pille den zustand der drohne") - das Aussehen des Fly-Modus darf
|
// drone pille den zustand der drohne") - das Aussehen des Fly-Modus darf
|
||||||
// nicht am Vorhandensein eines Telemetrie-Frames haengen (sonst faellt
|
// nicht am Vorhandensein eines Telemetrie-Frames haengen (sonst faellt
|
||||||
@@ -343,6 +374,20 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
navMode: telemetry?.navMode ?? 0,
|
navMode: telemetry?.navMode ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Homepoint-Marker (Doku: "benutze dieses Icon, um den Homepoint
|
||||||
|
// anzuzeigen") - null solange homePointAutoRefreshProvider noch keinen
|
||||||
|
// der drei Trigger-Momente gesehen hat bzw. der FC (noch) keinen
|
||||||
|
// Homepoint gesetzt hat (siehe MspCommands.getWp/parseMspHomePoint-Doku).
|
||||||
|
final homePointMarker = homePoint == null
|
||||||
|
? null
|
||||||
|
: Marker(
|
||||||
|
point: LatLng(homePoint.lat, homePoint.lon),
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const HomePointIcon(),
|
||||||
|
);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
MissionMap(
|
MissionMap(
|
||||||
@@ -362,6 +407,22 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
droneMarker: droneMarker,
|
droneMarker: droneMarker,
|
||||||
|
homePointMarker: homePointMarker,
|
||||||
|
windMarkers: [
|
||||||
|
if (showPerWaypointWind)
|
||||||
|
for (final wp in waypoints)
|
||||||
|
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
|
||||||
|
Marker(
|
||||||
|
point: LatLng(wp.lat, wp.lon),
|
||||||
|
width: windMarkerWidth(wp.windSpeedMs!),
|
||||||
|
height: 22,
|
||||||
|
alignment: const Alignment(1.1, -1.5),
|
||||||
|
child: WindMarkerPill(
|
||||||
|
speedMs: wp.windSpeedMs!,
|
||||||
|
dirFromDeg: wp.windDirFromDeg!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
|
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
|
||||||
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
|
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
|
||||||
@@ -404,6 +465,7 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
totalDistanceM: missionStats.distanceM,
|
totalDistanceM: missionStats.distanceM,
|
||||||
totalDurationSec: missionStats.durationSec,
|
totalDurationSec: missionStats.durationSec,
|
||||||
flyMissionStatus: flyMissionStatus,
|
flyMissionStatus: flyMissionStatus,
|
||||||
|
distanceToHomeM: distanceToHomeM,
|
||||||
droneStatus: droneStatus,
|
droneStatus: droneStatus,
|
||||||
warnings: warnings,
|
warnings: warnings,
|
||||||
showWarningsBanner: false,
|
showWarningsBanner: false,
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
|
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
|
||||||
Marker(
|
Marker(
|
||||||
point: LatLng(wp.lat, wp.lon),
|
point: LatLng(wp.lat, wp.lon),
|
||||||
width: _windMarkerWidth(wp.windSpeedMs!),
|
width: windMarkerWidth(wp.windSpeedMs!),
|
||||||
height: 22,
|
height: 22,
|
||||||
alignment: const Alignment(1.1, -1.5),
|
alignment: const Alignment(1.1, -1.5),
|
||||||
child: WindMarkerPill(
|
child: WindMarkerPill(
|
||||||
@@ -556,19 +556,4 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
borderColor: Colors.white,
|
borderColor: Colors.white,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// flutter_map's Marker verlangt eine feste Breite (kein intrinsisches
|
|
||||||
/// Sizing wie bei normalen Widgets) - eine einzelne fuer alle Faelle
|
|
||||||
/// grosszuegig bemessene Breite liesse bei kurzen Werten (z.B. "1 m/s")
|
|
||||||
/// sichtbaren Leerraum rechts neben dem Text. Stattdessen je Ziffernzahl
|
|
||||||
/// berechnet (per Widget-Messung ermittelt: +11px/Ziffer, siehe
|
|
||||||
/// WindMarkerPill).
|
|
||||||
double _windMarkerWidth(double speedMs) {
|
|
||||||
final digits = speedMs.round().abs().toString().length;
|
|
||||||
// +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/
|
|
||||||
// OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet
|
|
||||||
// als eine isolierte Messung - ohne Puffer fuehrte das zu einem
|
|
||||||
// 0.25px-Overflow.
|
|
||||||
return 81.0 + digits * 11.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../providers/active_drone_profile_provider.dart';
|
|||||||
import '../providers/altitude_profile_provider.dart';
|
import '../providers/altitude_profile_provider.dart';
|
||||||
import '../providers/mission_meta_provider.dart';
|
import '../providers/mission_meta_provider.dart';
|
||||||
import 'drone_status_messages_panel.dart';
|
import 'drone_status_messages_panel.dart';
|
||||||
|
import 'home_point_icon.dart';
|
||||||
import 'missions_drones_panel.dart';
|
import 'missions_drones_panel.dart';
|
||||||
import 'mountain_peak_icon.dart';
|
import 'mountain_peak_icon.dart';
|
||||||
import 'paper_plane_icon.dart';
|
import 'paper_plane_icon.dart';
|
||||||
@@ -54,6 +55,7 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
required this.totalDistanceM,
|
required this.totalDistanceM,
|
||||||
required this.totalDurationSec,
|
required this.totalDurationSec,
|
||||||
this.flyMissionStatus,
|
this.flyMissionStatus,
|
||||||
|
this.distanceToHomeM,
|
||||||
this.droneStatus,
|
this.droneStatus,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onSettingsTap,
|
required this.onSettingsTap,
|
||||||
@@ -71,6 +73,13 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
/// die statischen Felder in der Details-Pille.
|
/// die statischen Felder in der Details-Pille.
|
||||||
final FlyMissionStatus? flyMissionStatus;
|
final FlyMissionStatus? flyMissionStatus;
|
||||||
|
|
||||||
|
/// Nur im Fly-Modus gesetzt (Doku: "zeige die Entfernung zum Home Punkt
|
||||||
|
/// als letztes element in der details pille im fly modus an") - zeigt
|
||||||
|
/// dort ein zusaetzliches, viertes Feld nach Wegpunkt/Distanz/Zeit. Bleibt
|
||||||
|
/// null (kein zusaetzliches Feld), solange kein Homepoint bekannt ist
|
||||||
|
/// oder [flyMissionStatus] selbst null ist (Plan-Modus).
|
||||||
|
final double? distanceToHomeM;
|
||||||
|
|
||||||
/// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille
|
/// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille
|
||||||
/// den zustand der drohne") - ersetzt dort den Drohnennamen-Chip durch
|
/// den zustand der drohne") - ersetzt dort den Drohnennamen-Chip durch
|
||||||
/// die 3-Icon-Statusanzeige (Verbindung/Position/Zustand).
|
/// die 3-Icon-Statusanzeige (Verbindung/Position/Zustand).
|
||||||
@@ -291,6 +300,26 @@ class BottomStatsBar extends ConsumerWidget {
|
|||||||
fontSize: _footerFontSize,
|
fontSize: _footerFontSize,
|
||||||
fontWeight: _footerFontWeight,
|
fontWeight: _footerFontWeight,
|
||||||
)),
|
)),
|
||||||
|
// Entfernung zum Homepoint als viertes, letztes Feld (Doku:
|
||||||
|
// "als letztes element in der details pille im fly modus") -
|
||||||
|
// nur im Fly-Modus und nur, sobald ein Homepoint bekannt ist
|
||||||
|
// (siehe home_point_provider.dart), sonst kein zusaetzliches
|
||||||
|
// Feld statt eines erfundenen "—"-Platzhalters.
|
||||||
|
if (flyStatus != null && distanceToHomeM != null) ...[
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const HomePointIcon(
|
||||||
|
size: _footerIconSize,
|
||||||
|
color: Colors.white,
|
||||||
|
strokeColor: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${distanceToHomeM!.round()} m',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: _footerFontSize,
|
||||||
|
fontWeight: _footerFontWeight,
|
||||||
|
)),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,35 +38,25 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xEB080808),
|
color: const Color(0xEB080808),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
// Kein eigener Titel-Header mehr (Nutzerwunsch: mehr Platz fuer den
|
||||||
|
// Inhalt) - der Schliessen-Knopf schwebt stattdessen ueber der
|
||||||
|
// Tab-Zeile, die dafuer rechts Platz frei laesst (siehe unten),
|
||||||
|
// statt eine ganze Zeile fuer Titel+Knopf zu opfern.
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
// Rechts Platz fuer den schwebenden Schliessen-Knopf, damit
|
||||||
|
// "System Messages" nicht darunter verschwindet.
|
||||||
|
padding: const EdgeInsets.only(right: 48),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Drone Status & System Messages',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
_tabButton('Status', _PanelTab.status),
|
_tabButton('Status', _PanelTab.status),
|
||||||
_tabButton('System Messages', _PanelTab.messages),
|
_tabButton('System Messages', _PanelTab.messages),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Divider(color: Colors.white24, height: 1),
|
const Divider(color: Colors.white24, height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: switch (_tab) {
|
child: switch (_tab) {
|
||||||
@@ -76,6 +66,16 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,11 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => setState(() => _tab = tab),
|
onTap: () => setState(() => _tab = tab),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
// 18 statt vorher 10 (Nutzerwunsch: Tabs wirkten seit dem
|
||||||
|
// Wegfall der Titelzeile zu gequetscht) - zusammen mit der
|
||||||
|
// jetzt am Panel-Oberrand liegenden SafeArea braucht die Zeile
|
||||||
|
// mehr eigene Hoehe, um nicht gedrungen zu wirken.
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
@@ -144,15 +148,11 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
// Wertkachel bieten wuerde.
|
// Wertkachel bieten wuerde.
|
||||||
_sensorStatusRow(frame),
|
_sensorStatusRow(frame),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
// Flugmodus zeigt bei fehlender Nav-Uebersteuerung "Idle" (siehe
|
||||||
|
// MspNavMode._modeNames) - das heisst nur "kein GPS-Nav-Modus
|
||||||
|
// aktiv", nicht zwingend "steht am Boden" (reine Angle/Horizon/
|
||||||
|
// Acro-Fluglage ist ueber MSP_NAV_STATUS nicht sichtbar).
|
||||||
..._statusRows([
|
..._statusRows([
|
||||||
// Arming + erste 3 Temperaturen + Flugmodus als eigene Zeile
|
|
||||||
// direkt unter der Sensor-Status-Zeile (Doku), noch vor den
|
|
||||||
// farbcodierten Feldern. Flugmodus zeigt bei fehlender
|
|
||||||
// Nav-Uebersteuerung "Idle" (siehe MspNavMode._modeNames) - das
|
|
||||||
// heisst nur "kein GPS-Nav-Modus aktiv", nicht zwingend "steht am
|
|
||||||
// Boden" (reine Angle/Horizon/Acro-Fluglage ist ueber
|
|
||||||
// MSP_NAV_STATUS nicht sichtbar).
|
|
||||||
[
|
|
||||||
_statusRow(
|
_statusRow(
|
||||||
'Arming',
|
'Arming',
|
||||||
frame == null ? '—' : (frame.armed ? 'Armed' : 'Disarmed'),
|
frame == null ? '—' : (frame.armed ? 'Armed' : 'Disarmed'),
|
||||||
@@ -160,13 +160,10 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
),
|
),
|
||||||
_statusRow('Temperatures', _formatTemperatures(frame)),
|
_statusRow('Temperatures', _formatTemperatures(frame)),
|
||||||
_statusRow('Flight mode', frame != null ? frame.flightMode : '—'),
|
_statusRow('Flight mode', frame != null ? frame.flightMode : '—'),
|
||||||
],
|
|
||||||
// Farbcodierte Felder (Doku: "drone status felder im menue auch
|
// Farbcodierte Felder (Doku: "drone status felder im menue auch
|
||||||
// farblich kodieren", Schwellwerte je Drohnenprofil - siehe
|
// farblich kodieren", Schwellwerte je Drohnenprofil - siehe
|
||||||
// computeTelemetryFieldStatus), informelle Felder ohne Ampel-
|
// computeTelemetryFieldStatus), informelle Felder ohne Ampel-
|
||||||
// Bewertung (Koordinaten/Heading/Alt/Speed/Vertical speed)
|
// Bewertung (Koordinaten/Heading/Alt/Speed/Vertical speed) danach.
|
||||||
// danach.
|
|
||||||
[
|
|
||||||
_statusRow(
|
_statusRow(
|
||||||
'GPS fix type',
|
'GPS fix type',
|
||||||
_formatFixType(frame),
|
_formatFixType(frame),
|
||||||
@@ -178,8 +175,6 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
frame != null && frame.hasFix ? frame.hdop.toStringAsFixed(2) : '—',
|
frame != null && frame.hasFix ? frame.hdop.toStringAsFixed(2) : '—',
|
||||||
level: fieldStatus.hdop,
|
level: fieldStatus.hdop,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
[
|
|
||||||
_statusRow(
|
_statusRow(
|
||||||
'Current draw',
|
'Current draw',
|
||||||
frame != null ? '${frame.currentA.toStringAsFixed(1)} A' : '—',
|
frame != null ? '${frame.currentA.toStringAsFixed(1)} A' : '—',
|
||||||
@@ -190,12 +185,8 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
frame != null ? '${frame.batteryVoltage.toStringAsFixed(2)} V' : '—',
|
frame != null ? '${frame.batteryVoltage.toStringAsFixed(2)} V' : '—',
|
||||||
level: fieldStatus.batteryVoltage,
|
level: fieldStatus.batteryVoltage,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
[
|
|
||||||
_statusRow('RX quality', frame != null ? '${frame.linkQuality}%' : '—', level: fieldStatus.rxQuality),
|
_statusRow('RX quality', frame != null ? '${frame.linkQuality}%' : '—', level: fieldStatus.rxQuality),
|
||||||
_statusRow('RX SNR', frame != null ? '${frame.snrDb} dB' : '—', level: fieldStatus.rxSnr),
|
_statusRow('RX SNR', frame != null ? '${frame.snrDb} dB' : '—', level: fieldStatus.rxSnr),
|
||||||
],
|
|
||||||
[
|
|
||||||
_statusRow(
|
_statusRow(
|
||||||
'GPS coordinates',
|
'GPS coordinates',
|
||||||
frame != null && frame.hasFix
|
frame != null && frame.hasFix
|
||||||
@@ -203,38 +194,34 @@ class _DroneStatusMessagesPanelState extends ConsumerState<DroneStatusMessagesPa
|
|||||||
: '—',
|
: '—',
|
||||||
),
|
),
|
||||||
_statusRow('Heading', frame != null ? '${frame.headingDeg.round()}°' : '—'),
|
_statusRow('Heading', frame != null ? '${frame.headingDeg.round()}°' : '—'),
|
||||||
],
|
|
||||||
[
|
|
||||||
_statusRow('Altitude', _formatAltitude(frame)),
|
_statusRow('Altitude', _formatAltitude(frame)),
|
||||||
_statusRow('Speed', frame != null ? '${frame.speedMs.toStringAsFixed(1)} m/s' : '—'),
|
_statusRow('Speed', frame != null ? '${frame.speedMs.toStringAsFixed(1)} m/s' : '—'),
|
||||||
],
|
|
||||||
// Ans Ende der Liste (Doku: "ans ende der liste noch die
|
// Ans Ende der Liste (Doku: "ans ende der liste noch die
|
||||||
// steig/sinkrate hinzufuegen") - eigene Zeile, da kein Partnerfeld
|
// steig/sinkrate hinzufuegen").
|
||||||
// mehr uebrig ist.
|
_statusRow('Vertical speed', _formatVerticalSpeed(frame)),
|
||||||
[_statusRow('Vertical speed', _formatVerticalSpeed(frame))],
|
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordnet die Statuszeilen in explizit vorgegebenen Gruppen an (Doku: "2
|
/// Ordnet die Statuszeilen zu je [perRow] pro Zeile an (Doku: "standardmaessig
|
||||||
/// felder pro zeile im drone status menue erlauben um platz effizienter zu
|
/// drei felder pro zeile im drone status menue anzeigen") - bei einer nicht
|
||||||
/// nutzen", inzwischen mit einzelnen 3er-Zeilen wie Arming/Temperatures/
|
/// durch [perRow] teilbaren Feldanzahl bleibt die letzte Zeile schmaler,
|
||||||
/// Flight mode) - anders als eine reine Zweier-Paarbildung braucht das
|
/// statt sie kuenstlich mit leeren Kacheln aufzufuellen.
|
||||||
/// gemischte 2er/3er-Layout eine explizite Gruppierung pro Zeile statt
|
List<Widget> _statusRows(List<Widget> tiles, {int perRow = 3}) {
|
||||||
/// automatischem Chunking.
|
final rows = <Widget>[];
|
||||||
List<Widget> _statusRows(List<List<Widget>> rows) {
|
for (var i = 0; i < tiles.length; i += perRow) {
|
||||||
return [
|
final chunk = tiles.skip(i).take(perRow).toList();
|
||||||
for (final tiles in rows)
|
rows.add(Row(
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < tiles.length; i++) ...[
|
for (var j = 0; j < chunk.length; j++) ...[
|
||||||
if (i > 0) const SizedBox(width: 8),
|
if (j > 0) const SizedBox(width: 8),
|
||||||
Expanded(child: tiles[i]),
|
Expanded(child: chunk[j]),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
));
|
||||||
];
|
}
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _signalColors = {
|
static const _signalColors = {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Homepoint-Pentagon-Haus-Form aus design/homepoint-pentagon.svg - nativ
|
||||||
|
/// als [CustomPainter] statt als SVG-Asset nachgebaut (Format bewusst
|
||||||
|
/// geaendert, dasselbe Muster wie DroneMarkerIcon/PaperPlaneIcon: ein
|
||||||
|
/// einzelnes Polygon lohnt keine zusaetzliche SVG-Renderer-Abhaengigkeit).
|
||||||
|
///
|
||||||
|
/// Zwei Anwendungsfaelle mit unterschiedlichem Stil teilen sich dieselbe
|
||||||
|
/// Geometrie: als Kartenmarker im Fly-Modus (Doku: "den auf der Drohne
|
||||||
|
/// gelockten Homepoint abfragen ... benutze dieses Icon") mit dunkler
|
||||||
|
/// Fuellung + weissem Rand wie die anderen Kartenmarker, und als schlichtes
|
||||||
|
/// einfarbiges Inline-Icon in der Details-Pille (Doku: "gleiche Pentagon
|
||||||
|
/// Form, aber im Style von den anderen icons") ohne Rand, passend zu den
|
||||||
|
/// dortigen Material-Icons (flag/straighten/access_time). [strokeColor]
|
||||||
|
/// null waehlt Letzteres.
|
||||||
|
class HomePointIcon extends StatelessWidget {
|
||||||
|
const HomePointIcon({
|
||||||
|
super.key,
|
||||||
|
this.size = 32,
|
||||||
|
this.color = const Color(0xFF161616),
|
||||||
|
this.strokeColor = Colors.white,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double size;
|
||||||
|
final Color color;
|
||||||
|
final Color? strokeColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
size: Size.square(size),
|
||||||
|
painter: _HomePointPainter(fill: color, stroke: strokeColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePointPainter extends CustomPainter {
|
||||||
|
const _HomePointPainter({required this.fill, required this.stroke});
|
||||||
|
|
||||||
|
final Color fill;
|
||||||
|
final Color? stroke;
|
||||||
|
|
||||||
|
// 1:1 aus design/homepoint-pentagon.svg uebernommene Koordinaten (viewBox
|
||||||
|
// 0 0 72 72): Haus-Silhouette (Fuenfeck) mit spitzem Dach.
|
||||||
|
static const _viewBoxSize = 72.0;
|
||||||
|
static const _points = [
|
||||||
|
Offset(20, 52),
|
||||||
|
Offset(20, 34),
|
||||||
|
Offset(36, 16),
|
||||||
|
Offset(52, 34),
|
||||||
|
Offset(52, 52),
|
||||||
|
];
|
||||||
|
static const _strokeWidth = 3.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final scale = size.width / _viewBoxSize;
|
||||||
|
Offset s(Offset p) => p * scale;
|
||||||
|
|
||||||
|
final path = Path()..moveTo(s(_points.first).dx, s(_points.first).dy);
|
||||||
|
for (final point in _points.skip(1)) {
|
||||||
|
path.lineTo(s(point).dx, s(point).dy);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
canvas.drawPath(path, Paint()..color = fill);
|
||||||
|
final strokeColor = stroke;
|
||||||
|
if (strokeColor != null) {
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = strokeColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = _strokeWidth * scale
|
||||||
|
..strokeJoin = StrokeJoin.round,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _HomePointPainter oldDelegate) =>
|
||||||
|
oldDelegate.fill != fill || oldDelegate.stroke != stroke;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class MissionFooterBar extends ConsumerWidget {
|
|||||||
required this.totalDistanceM,
|
required this.totalDistanceM,
|
||||||
required this.totalDurationSec,
|
required this.totalDurationSec,
|
||||||
this.flyMissionStatus,
|
this.flyMissionStatus,
|
||||||
|
this.distanceToHomeM,
|
||||||
this.droneStatus,
|
this.droneStatus,
|
||||||
required this.warnings,
|
required this.warnings,
|
||||||
this.showWarningsBanner = true,
|
this.showWarningsBanner = true,
|
||||||
@@ -48,6 +49,11 @@ class MissionFooterBar extends ConsumerWidget {
|
|||||||
/// Distanz/Zeit dorthin. Bleibt im Plan-Modus null.
|
/// Distanz/Zeit dorthin. Bleibt im Plan-Modus null.
|
||||||
final FlyMissionStatus? flyMissionStatus;
|
final FlyMissionStatus? flyMissionStatus;
|
||||||
|
|
||||||
|
/// Nur im Fly-Modus gesetzt (Doku: "zeige die Entfernung zum Home Punkt
|
||||||
|
/// als letztes element in der details pille im fly modus an") - siehe
|
||||||
|
/// BottomStatsBar.distanceToHomeM.
|
||||||
|
final double? distanceToHomeM;
|
||||||
|
|
||||||
/// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille
|
/// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille
|
||||||
/// den zustand der drohne") - siehe BottomStatsBar.droneStatus. FlyScreen
|
/// den zustand der drohne") - siehe BottomStatsBar.droneStatus. FlyScreen
|
||||||
/// setzt hier immer einen Wert (auch ohne Telemetrie-Frame, siehe dortige
|
/// setzt hier immer einen Wert (auch ohne Telemetrie-Frame, siehe dortige
|
||||||
@@ -127,6 +133,7 @@ class MissionFooterBar extends ConsumerWidget {
|
|||||||
totalDistanceM: totalDistanceM,
|
totalDistanceM: totalDistanceM,
|
||||||
totalDurationSec: totalDurationSec,
|
totalDurationSec: totalDurationSec,
|
||||||
flyMissionStatus: flyMissionStatus,
|
flyMissionStatus: flyMissionStatus,
|
||||||
|
distanceToHomeM: distanceToHomeM,
|
||||||
droneStatus: droneStatus,
|
droneStatus: droneStatus,
|
||||||
onTap: onDetailsTap,
|
onTap: onDetailsTap,
|
||||||
warnings: warnings,
|
warnings: warnings,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class MissionMap extends StatelessWidget {
|
|||||||
this.routeSegments = const [],
|
this.routeSegments = const [],
|
||||||
this.rubberBandLine,
|
this.rubberBandLine,
|
||||||
this.droneMarker,
|
this.droneMarker,
|
||||||
|
this.homePointMarker,
|
||||||
this.onMapReady,
|
this.onMapReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +34,11 @@ class MissionMap extends StatelessWidget {
|
|||||||
/// keine Telemetrie eingetroffen ist.
|
/// keine Telemetrie eingetroffen ist.
|
||||||
final Marker? droneMarker;
|
final Marker? droneMarker;
|
||||||
|
|
||||||
|
/// Homepoint-Marker im Fly-Modus (Doku: "den auf der Drohne gelockten
|
||||||
|
/// Homepoint abfragen"), null solange noch keiner abgefragt wurde/der FC
|
||||||
|
/// noch keinen gesetzt hat - siehe home_point_provider.dart.
|
||||||
|
final Marker? homePointMarker;
|
||||||
|
|
||||||
/// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot
|
/// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot
|
||||||
/// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius.
|
/// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius.
|
||||||
final List<RouteSegment> routeSegments;
|
final List<RouteSegment> routeSegments;
|
||||||
@@ -95,6 +101,7 @@ class MissionMap extends StatelessWidget {
|
|||||||
CircleLayer(circles: waypointMarkers),
|
CircleLayer(circles: waypointMarkers),
|
||||||
MarkerLayer(markers: [
|
MarkerLayer(markers: [
|
||||||
...windMarkers,
|
...windMarkers,
|
||||||
|
?homePointMarker,
|
||||||
?droneMarker,
|
?droneMarker,
|
||||||
]),
|
]),
|
||||||
const _CompactAttribution(),
|
const _CompactAttribution(),
|
||||||
|
|||||||
@@ -36,35 +36,26 @@ class _MissionsDronesPanelState extends ConsumerState<MissionsDronesPanel> {
|
|||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xEB080808),
|
color: const Color(0xEB080808),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
// Kein eigener Titel-Header mehr (Doku: analog zum Drone-Status-
|
||||||
|
// Panel, mehr Platz fuer den Inhalt) - der Schliessen-Knopf
|
||||||
|
// schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz
|
||||||
|
// frei laesst (siehe unten), statt eine ganze Zeile fuer
|
||||||
|
// Titel+Knopf zu opfern.
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
// Rechts Platz fuer den schwebenden Schliessen-Knopf, damit
|
||||||
|
// "Drones" nicht darunter verschwindet.
|
||||||
|
padding: const EdgeInsets.only(right: 48),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Missions & Drones',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
_tabButton('Missions', _ManagementTab.missions),
|
_tabButton('Missions', _ManagementTab.missions),
|
||||||
_tabButton('Drones', _ManagementTab.drones),
|
_tabButton('Drones', _ManagementTab.drones),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Divider(color: Colors.white24, height: 1),
|
const Divider(color: Colors.white24, height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: switch (_tab) {
|
child: switch (_tab) {
|
||||||
@@ -74,6 +65,16 @@ class _MissionsDronesPanelState extends ConsumerState<MissionsDronesPanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,9 @@ class _MissionsDronesPanelState extends ConsumerState<MissionsDronesPanel> {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => setState(() => _tab = tab),
|
onTap: () => setState(() => _tab = tab),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
// 18 statt vorher 10 (analog Drone-Status-Panel: Tabs wirkten
|
||||||
|
// seit dem Wegfall der Titelzeile zu gequetscht).
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import '../providers/wind_provider.dart';
|
|||||||
import 'full_value_chart.dart';
|
import 'full_value_chart.dart';
|
||||||
import 'waypoint_chip.dart';
|
import 'waypoint_chip.dart';
|
||||||
import 'wind_arrow_icon.dart';
|
import 'wind_arrow_icon.dart';
|
||||||
import 'wind_validate_panel.dart';
|
|
||||||
|
|
||||||
/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public,
|
/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public,
|
||||||
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
|
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
|
||||||
@@ -86,6 +85,12 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tab = widget.initialTab;
|
_tab = widget.initialTab;
|
||||||
|
// Wind pro Wegpunkt automatisch abrufen, sobald das Panel geoeffnet
|
||||||
|
// wird (Doku: "wind automatisch abrufen, wenn das menue geoeffnet
|
||||||
|
// wird") - ersetzt den vorherigen manuellen "Fetch wind"-Knopf.
|
||||||
|
// ensurePerWaypointWind() selbst entscheidet, ob dafuer ueberhaupt ein
|
||||||
|
// Request noetig ist (siehe dortige Doku).
|
||||||
|
ref.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToActive();
|
_scrollToActive();
|
||||||
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
|
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
|
||||||
@@ -120,39 +125,6 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator:
|
|
||||||
/// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck.
|
|
||||||
Future<void> _onFetchWind() async {
|
|
||||||
final waypoints = ref.read(currentMissionProvider);
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
if (waypoints.isEmpty) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(content: Text('No waypoints to fetch wind for')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('Fetching wind…')));
|
|
||||||
final updated =
|
|
||||||
await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
|
|
||||||
ref.read(currentMissionProvider.notifier).replaceAll(updated);
|
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('Wind data updated')));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Oeffnet das Wind-Validierungspanel fuer den aktuellen Referenzort
|
|
||||||
/// (erster Wegpunkt, sonst Kartenzentrum - Doku 3.8).
|
|
||||||
void _onValidateWind() {
|
|
||||||
final location = windReferenceLocation(ref);
|
|
||||||
if (location == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('No location available yet')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
|
||||||
builder: (_) => WindValidatePanel(lat: location.$1, lon: location.$2),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das
|
/// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das
|
||||||
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
||||||
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
||||||
@@ -186,49 +158,27 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xEB080808),
|
color: const Color(0xEB080808),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
// Kein eigener Titel-Header mehr (Doku: "nach dem neuen
|
||||||
|
// Designschema anpassen und ueberschrift entfernen", analog Drone-
|
||||||
|
// Status- und Missions-/Drohnen-Panel) - der Schliessen-Knopf
|
||||||
|
// schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz
|
||||||
|
// frei laesst (siehe unten).
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
// Rechts Platz fuer den schwebenden Schliessen-Knopf,
|
||||||
|
// damit "Speed" nicht darunter verschwindet.
|
||||||
|
padding: const EdgeInsets.only(right: 48),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Waypoints',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_HeaderActionButton(
|
|
||||||
icon: Icons.air,
|
|
||||||
label: 'Fetch wind',
|
|
||||||
tint: const Color(0xFF4D9DFF),
|
|
||||||
onTap: _onFetchWind,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_HeaderActionButton(
|
|
||||||
icon: Icons.rule,
|
|
||||||
label: 'Validate',
|
|
||||||
tint: const Color(0xFFC8A04D),
|
|
||||||
onTap: _onValidateWind,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: widget.onClose,
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
_tabButton('List', PanelTab.list),
|
_tabButton('List', PanelTab.list),
|
||||||
_tabButton('Altitude', PanelTab.altitude),
|
_tabButton('Altitude', PanelTab.altitude),
|
||||||
_tabButton('Speed', PanelTab.speed),
|
_tabButton('Speed', PanelTab.speed),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Divider(color: Colors.white24, height: 1),
|
const Divider(color: Colors.white24, height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: switch (_tab) {
|
child: switch (_tab) {
|
||||||
@@ -266,6 +216,16 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,7 +246,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
// 18 statt vorher 10 (analog Drone-Status-/Missions-Panel: Tabs
|
||||||
|
// wirkten seit dem Wegfall der Titelzeile zu gequetscht).
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
@@ -542,49 +504,3 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
_ => '—',
|
_ => '—',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kleiner getoenter Aktions-Button im Panel-Header (HTML-Demonstrator:
|
|
||||||
/// #windFetchBtn/#windValidateBtn).
|
|
||||||
class _HeaderActionButton extends StatelessWidget {
|
|
||||||
const _HeaderActionButton({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.tint,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final Color tint;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: tint.withValues(alpha: 0.18),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: tint.withValues(alpha: 0.5)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 15, color: Colors.white),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'wind_arrow_icon.dart';
|
import 'wind_arrow_icon.dart';
|
||||||
|
|
||||||
|
/// Breite des Marker-Rahmens fuer [WindMarkerPill] bei gegebener
|
||||||
|
/// Windgeschwindigkeit - geteilt zwischen Plan- und Fly-Modus, die beide
|
||||||
|
/// eigene Wind-Marker auf der Karte platzieren (MissionMap.windMarkers).
|
||||||
|
/// Haengt von der Ziffernzahl der gerundeten Geschwindigkeit ab, damit z.B.
|
||||||
|
/// "12 m/s" genauso wenig abgeschnitten wird wie "5 m/s".
|
||||||
|
double windMarkerWidth(double speedMs) {
|
||||||
|
final digits = speedMs.round().abs().toString().length;
|
||||||
|
// +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/
|
||||||
|
// OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet als
|
||||||
|
// eine isolierte Messung - ohne Puffer fuehrte das zu einem
|
||||||
|
// 0.25px-Overflow.
|
||||||
|
return 81.0 + digits * 11.0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Kleine Wind-Anzeige direkt an einem Wegpunkt auf der Karte (HTML-
|
/// Kleine Wind-Anzeige direkt an einem Wegpunkt auf der Karte (HTML-
|
||||||
/// Demonstrator: divIcon in redrawMapLayers(), sichtbar wenn
|
/// Demonstrator: divIcon in redrawMapLayers(), sichtbar wenn
|
||||||
/// showPerWaypointWind aktiv ist und fuer den Wegpunkt Wind geladen wurde).
|
/// showPerWaypointWind aktiv ist und fuer den Wegpunkt Wind geladen wurde).
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../services/wind/wind_service.dart';
|
|
||||||
import '../providers/wind_provider.dart';
|
|
||||||
|
|
||||||
/// Zeigt die Rohdaten hinter dem interpolierten Wind an einem Ort:
|
|
||||||
/// gemessene Werte je fester Nabenhoehe (inkl. Boeen) und je Druckflaeche,
|
|
||||||
/// sowie das daraus interpolierte Ergebnis auf 120 m AGL (Doku 3.8/4.9:
|
|
||||||
/// Boeen nur hier, nicht in der Wegpunktanzeige). Fullscreen-Route wie
|
|
||||||
/// WaypointListPanel (HTML-Demonstrator: #windValidatePanel, ebenfalls
|
|
||||||
/// .panel).
|
|
||||||
class WindValidatePanel extends ConsumerStatefulWidget {
|
|
||||||
const WindValidatePanel({super.key, required this.lat, required this.lon});
|
|
||||||
|
|
||||||
final double lat;
|
|
||||||
final double lon;
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<WindValidatePanel> createState() => _WindValidatePanelState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WindValidatePanelState extends ConsumerState<WindValidatePanel> {
|
|
||||||
late final Future<WindValidationResult> _future;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_future = ref
|
|
||||||
.read(windServiceProvider)
|
|
||||||
.fetchValidation(widget.lat, widget.lon);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const _labelStyle = TextStyle(color: Colors.white, fontSize: 14, height: 1.6);
|
|
||||||
static const _errorStyle = TextStyle(color: Color(0xFFFF8A7A), fontSize: 14, height: 1.6);
|
|
||||||
static const _excludedStyle = TextStyle(
|
|
||||||
color: Colors.white38,
|
|
||||||
fontSize: 14,
|
|
||||||
height: 1.6,
|
|
||||||
decoration: TextDecoration.lineThrough,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Material(
|
|
||||||
color: const Color(0xEB080808),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Wind Validation',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.white24, height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: FutureBuilder<WindValidationResult>(
|
|
||||||
future: _future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(color: Color(0xFF4D9DFF)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _body(snapshot.data!);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _body(WindValidationResult result) {
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Location: ${result.lat.toStringAsFixed(5)}, ${result.lon.toStringAsFixed(5)} '
|
|
||||||
'· ground elevation ${result.elevationM.round()} m ASL',
|
|
||||||
style: _labelStyle,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text('Fixed hub heights', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
if (result.fixedRequestFailed)
|
|
||||||
const Text('Request failed', style: _errorStyle)
|
|
||||||
else ...[
|
|
||||||
for (final r in result.fixedReadings)
|
|
||||||
Text(
|
|
||||||
'${r.heightAglM} m AGL: '
|
|
||||||
'${r.speedMs != null ? '${r.speedMs!.toStringAsFixed(1)} m/s' : 'n/a'}'
|
|
||||||
'${r.dirFromDeg != null ? ', ${r.dirFromDeg!.round()}°' : ''}',
|
|
||||||
style: _labelStyle,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Gusts (10 m AGL): ${result.gustMs != null ? '${result.gustMs!.toStringAsFixed(1)} m/s' : 'n/a'}',
|
|
||||||
style: _labelStyle,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Pressure levels', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)),
|
|
||||||
const Text(
|
|
||||||
'discarded if below ground or incomplete',
|
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
if (result.pressureRequestFailed)
|
|
||||||
const Text('Request failed', style: _errorStyle)
|
|
||||||
else
|
|
||||||
for (final r in result.pressureReadings)
|
|
||||||
Text(
|
|
||||||
'${r.pressureHpa} hPa: '
|
|
||||||
'${r.heightAmslM != null ? '${r.heightAmslM!.round()} m ASL' : '?'}, '
|
|
||||||
'${r.speedMs != null ? '${r.speedMs!.toStringAsFixed(1)} m/s' : 'n/a'}'
|
|
||||||
'${r.excluded ? (r.heightAmslM != null && r.heightAmslM! < result.elevationM ? ' (below ground, excluded)' : ' (incomplete, excluded)') : ''}',
|
|
||||||
style: r.excluded ? _excludedStyle : _labelStyle,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(top: 12),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
border: Border(top: BorderSide(color: Colors.white24)),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Interpolated & referenced at 120 m AGL '
|
|
||||||
'(= ${(result.elevationM + 120).round()} m ASL, using pressure levels + fixed heights combined): '
|
|
||||||
'${result.interpolatedAt120 != null ? '${result.interpolatedAt120!.speedMs.toStringAsFixed(1)} m/s, ${result.interpolatedAt120!.dirFromDeg.round()}°' : 'n/a (missing data at boundary level)'}',
|
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14, height: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dmc_app/domain/mission/mission_stats.dart';
|
import 'package:dmc_app/domain/mission/mission_stats.dart';
|
||||||
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
Waypoint _wp(double lat, double lon, {double speed = 15}) =>
|
Waypoint _wp(double lat, double lon, {double speed = 15}) =>
|
||||||
Waypoint(lat: lat, lon: lon, altitudeM: 60, speedMs: speed, catchRadiusM: 60);
|
Waypoint(lat: lat, lon: lon, altitudeM: 60, speedMs: speed, catchRadiusM: 60);
|
||||||
@@ -110,4 +111,105 @@ void main() {
|
|||||||
expect(status!.etaToActiveWaypointSec, isNull);
|
expect(status!.etaToActiveWaypointSec, isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('computeRubberBandTarget', () {
|
||||||
|
final waypoints = [_wp(0, 0), _wp(0, 0.01), _wp(0, 0.02)];
|
||||||
|
const home = LatLng(1, 1);
|
||||||
|
|
||||||
|
test('zeigt normalerweise auf den aktiven Missionswegpunkt', () {
|
||||||
|
final target = computeRubberBandTarget(
|
||||||
|
navMode: 3, // Waypoint mission
|
||||||
|
activeWaypointIndex: 1,
|
||||||
|
waypoints: waypoints,
|
||||||
|
homePoint: home,
|
||||||
|
);
|
||||||
|
expect(target, const LatLng(0, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zeigt bei Return-to-Home (navMode 2) auf den Homepoint statt auf '
|
||||||
|
'den (ggf. laengst passierten) eingefrorenen Missionswegpunkt', () {
|
||||||
|
final target = computeRubberBandTarget(
|
||||||
|
navMode: navModeReturnToHome,
|
||||||
|
activeWaypointIndex: 1,
|
||||||
|
waypoints: waypoints,
|
||||||
|
homePoint: home,
|
||||||
|
);
|
||||||
|
expect(target, home);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('liefert null bei RTH ohne bekannten Homepoint statt eines '
|
||||||
|
'erfundenen Ziels', () {
|
||||||
|
final target = computeRubberBandTarget(
|
||||||
|
navMode: navModeReturnToHome,
|
||||||
|
activeWaypointIndex: 1,
|
||||||
|
waypoints: waypoints,
|
||||||
|
homePoint: null,
|
||||||
|
);
|
||||||
|
expect(target, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schaltet nach Abbruch von RTH (navMode wieder 3) automatisch '
|
||||||
|
'zurueck auf den Missionswegpunkt', () {
|
||||||
|
const rth = 2;
|
||||||
|
final duringRth = computeRubberBandTarget(
|
||||||
|
navMode: rth,
|
||||||
|
activeWaypointIndex: 1,
|
||||||
|
waypoints: waypoints,
|
||||||
|
homePoint: home,
|
||||||
|
);
|
||||||
|
final afterAbort = computeRubberBandTarget(
|
||||||
|
navMode: 3,
|
||||||
|
activeWaypointIndex: 1,
|
||||||
|
waypoints: waypoints,
|
||||||
|
homePoint: home,
|
||||||
|
);
|
||||||
|
expect(duringRth, home);
|
||||||
|
expect(afterAbort, const LatLng(0, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activeWaypointIndex null oder ausserhalb der Liste ergibt null '
|
||||||
|
'(ausserhalb RTH)', () {
|
||||||
|
expect(
|
||||||
|
computeRubberBandTarget(
|
||||||
|
navMode: 3,
|
||||||
|
activeWaypointIndex: null,
|
||||||
|
waypoints: waypoints,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
computeRubberBandTarget(
|
||||||
|
navMode: 3,
|
||||||
|
activeWaypointIndex: 3,
|
||||||
|
waypoints: waypoints,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('computeDistanceToHomeM', () {
|
||||||
|
test('berechnet die Distanz aus Grosskreis', () {
|
||||||
|
final distance = computeDistanceToHomeM(
|
||||||
|
currentPosition: const LatLng(0, 0),
|
||||||
|
homePoint: const LatLng(0, 0.01),
|
||||||
|
);
|
||||||
|
// 0.01 Grad Laengendifferenz am Aequator entspricht rund 1112 m.
|
||||||
|
expect(distance, closeTo(1112.6, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null ohne aktuelle Position (kein GPS-Fix)', () {
|
||||||
|
expect(
|
||||||
|
computeDistanceToHomeM(currentPosition: null, homePoint: const LatLng(0, 0)),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null ohne bekannten Homepoint', () {
|
||||||
|
expect(
|
||||||
|
computeDistanceToHomeM(currentPosition: const LatLng(0, 0), homePoint: null),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,79 +142,4 @@ void main() {
|
|||||||
expect(called, isFalse);
|
expect(called, isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('WindService.fetchValidation', () {
|
|
||||||
test('markiert unvollstaendige/unter Boden liegende Druckflaechen als excluded',
|
|
||||||
() async {
|
|
||||||
final client = MockClient((request) async {
|
|
||||||
if (_isPressureRequest(request)) {
|
|
||||||
return _json({
|
|
||||||
'elevation': 50.0,
|
|
||||||
'hourly': {
|
|
||||||
'time': ['2024-06-01T12:00'],
|
|
||||||
'wind_speed_1000hPa': [4.0],
|
|
||||||
'wind_direction_1000hPa': [300.0],
|
|
||||||
'geopotential_height_1000hPa': [5.0], // unter Bodenhoehe 50
|
|
||||||
'wind_speed_975hPa': [12.0],
|
|
||||||
'wind_direction_975hPa': [300.0],
|
|
||||||
'geopotential_height_975hPa': [800.0],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _json({
|
|
||||||
'elevation': 50.0,
|
|
||||||
'hourly': {
|
|
||||||
'time': ['2024-06-01T12:00'],
|
|
||||||
'wind_speed_10m': [1.0],
|
|
||||||
'wind_direction_10m': [100.0],
|
|
||||||
'wind_speed_80m': [9.0],
|
|
||||||
'wind_direction_80m': [250.0],
|
|
||||||
'wind_speed_120m': [10.0],
|
|
||||||
'wind_direction_120m': [260.0],
|
|
||||||
'wind_speed_180m': [11.0],
|
|
||||||
'wind_direction_180m': [270.0],
|
|
||||||
'wind_gusts_10m': [12.5],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
final service = WindService(client: client);
|
|
||||||
|
|
||||||
final result = await service.fetchValidation(52.0, 4.0);
|
|
||||||
|
|
||||||
expect(result.elevationM, closeTo(50.0, 0.001));
|
|
||||||
expect(result.fixedRequestFailed, isFalse);
|
|
||||||
expect(result.pressureRequestFailed, isFalse);
|
|
||||||
expect(result.gustMs, closeTo(12.5, 0.001));
|
|
||||||
|
|
||||||
final level1000 =
|
|
||||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 1000);
|
|
||||||
expect(level1000.excluded, isTrue,
|
|
||||||
reason: 'liegt unter der Bodenhoehe');
|
|
||||||
|
|
||||||
final level975 =
|
|
||||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 975);
|
|
||||||
expect(level975.excluded, isFalse);
|
|
||||||
|
|
||||||
final level950 =
|
|
||||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 950);
|
|
||||||
expect(level950.excluded, isTrue,
|
|
||||||
reason: 'keine Daten fuer diese Druckflaeche geliefert');
|
|
||||||
|
|
||||||
expect(result.interpolatedAt120, isNotNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('meldet fehlgeschlagenen Request statt zu crashen', () async {
|
|
||||||
final client = MockClient((request) async {
|
|
||||||
if (_isPressureRequest(request)) return http.Response('', 500);
|
|
||||||
return http.Response('', 500);
|
|
||||||
});
|
|
||||||
final service = WindService(client: client);
|
|
||||||
|
|
||||||
final result = await service.fetchValidation(52.0, 4.0);
|
|
||||||
|
|
||||||
expect(result.fixedRequestFailed, isTrue);
|
|
||||||
expect(result.pressureRequestFailed, isTrue);
|
|
||||||
expect(result.interpolatedAt120, isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ class _FakeFlightController {
|
|||||||
bool wpGetInfoValid = false;
|
bool wpGetInfoValid = false;
|
||||||
int wpGetInfoCount = 0;
|
int wpGetInfoCount = 0;
|
||||||
|
|
||||||
|
/// null simuliert "FC hat noch keinen Homepoint gesetzt" (`!STATE(
|
||||||
|
/// GPS_FIX_HOME)`, meldet dann (0,0,0) statt eines Fehlers).
|
||||||
|
({int lat, int lon, int altCm})? homePoint = (
|
||||||
|
lat: 525000000,
|
||||||
|
lon: 134000000,
|
||||||
|
altCm: 6000,
|
||||||
|
);
|
||||||
|
|
||||||
void _tick(Timer _) {
|
void _tick(Timer _) {
|
||||||
while (_answered < transport.sentData.length) {
|
while (_answered < transport.sentData.length) {
|
||||||
final request =
|
final request =
|
||||||
@@ -76,6 +84,17 @@ class _FakeFlightController {
|
|||||||
case MspCommands.wpGetInfo:
|
case MspCommands.wpGetInfo:
|
||||||
payload = Uint8List.fromList(
|
payload = Uint8List.fromList(
|
||||||
[0, 15, wpGetInfoValid ? 1 : 0, wpGetInfoCount]);
|
[0, 15, wpGetInfoValid ? 1 : 0, wpGetInfoCount]);
|
||||||
|
case MspCommands.getWp:
|
||||||
|
final wpNo = request.payload[0];
|
||||||
|
final point = homePoint;
|
||||||
|
final d = ByteData(21);
|
||||||
|
d.setUint8(0, wpNo);
|
||||||
|
if (wpNo == 0 && point != null) {
|
||||||
|
d.setInt32(2, point.lat, Endian.little);
|
||||||
|
d.setInt32(6, point.lon, Endian.little);
|
||||||
|
d.setInt32(10, point.altCm, Endian.little);
|
||||||
|
}
|
||||||
|
payload = d.buffer.asUint8List();
|
||||||
default:
|
default:
|
||||||
payload = Uint8List(0);
|
payload = Uint8List(0);
|
||||||
}
|
}
|
||||||
@@ -170,6 +189,43 @@ void main() {
|
|||||||
responder.cancel();
|
responder.cancel();
|
||||||
}, timeout: const Timeout(Duration(seconds: 5)));
|
}, timeout: const Timeout(Duration(seconds: 5)));
|
||||||
|
|
||||||
|
group('readHomePoint', () {
|
||||||
|
test('fragt MSP_WP mit wp_no=0 ab und dekodiert den Homepoint', () async {
|
||||||
|
final transport = LoopbackTransport();
|
||||||
|
final responder = _startFakeFlightController(transport);
|
||||||
|
final link = MspFlightControllerLink(transport: transport);
|
||||||
|
await link.connect();
|
||||||
|
|
||||||
|
final home = await link.readHomePoint();
|
||||||
|
|
||||||
|
expect(home, isNotNull);
|
||||||
|
expect(home!.lat, closeTo(52.5, 1e-6));
|
||||||
|
expect(home.lon, closeTo(13.4, 1e-6));
|
||||||
|
expect(home.altitudeM, closeTo(60.0, 1e-9));
|
||||||
|
|
||||||
|
await link.disconnect();
|
||||||
|
responder.cancel();
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 5)));
|
||||||
|
|
||||||
|
test('liefert null, wenn der FC noch keinen Homepoint gesetzt hat '
|
||||||
|
'(meldet (0,0,0) statt eines Fehlers)', () async {
|
||||||
|
final transport = LoopbackTransport();
|
||||||
|
final responder = _startFakeFlightController(transport)..homePoint = null;
|
||||||
|
final link = MspFlightControllerLink(transport: transport);
|
||||||
|
await link.connect();
|
||||||
|
|
||||||
|
expect(await link.readHomePoint(), isNull);
|
||||||
|
|
||||||
|
await link.disconnect();
|
||||||
|
responder.cancel();
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 5)));
|
||||||
|
|
||||||
|
test('readHomePoint() vor connect() wirft StateError', () {
|
||||||
|
final link = MspFlightControllerLink(transport: LoopbackTransport());
|
||||||
|
expect(() => link.readHomePoint(), throwsStateError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('subscribeTelemetry() vor connect() wirft StateError', () {
|
test('subscribeTelemetry() vor connect() wirft StateError', () {
|
||||||
final link = MspFlightControllerLink(transport: LoopbackTransport());
|
final link = MspFlightControllerLink(transport: LoopbackTransport());
|
||||||
expect(() => link.subscribeTelemetry(), throwsStateError);
|
expect(() => link.subscribeTelemetry(), throwsStateError);
|
||||||
|
|||||||
@@ -152,4 +152,68 @@ void main() {
|
|||||||
expect(info.isValid, isFalse);
|
expect(info.isValid, isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('encodeMspGetWaypointRequest', () {
|
||||||
|
test('kodiert wp_no als einzelnes Byte', () {
|
||||||
|
expect(encodeMspGetWaypointRequest(0), Uint8List.fromList([0]));
|
||||||
|
expect(encodeMspGetWaypointRequest(255), Uint8List.fromList([255]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mspWpNumberHome ist 0 (WP#0 = Homepoint laut getWaypoint())', () {
|
||||||
|
expect(mspWpNumberHome, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('parseMspHomePoint', () {
|
||||||
|
/// Baut die 21-Byte MSP_WP-Antwort wie der echte FC (`fc_msp.c`,
|
||||||
|
/// `mspFcWaypointOutCommand()`) - dient nur der Testverifikation.
|
||||||
|
Uint8List encodeFakeWpResponse({
|
||||||
|
int wpNo = 0,
|
||||||
|
int action = 0,
|
||||||
|
required int lat,
|
||||||
|
required int lon,
|
||||||
|
required int altCm,
|
||||||
|
}) {
|
||||||
|
final d = ByteData(21);
|
||||||
|
d.setUint8(0, wpNo);
|
||||||
|
d.setUint8(1, action);
|
||||||
|
d.setInt32(2, lat, Endian.little);
|
||||||
|
d.setInt32(6, lon, Endian.little);
|
||||||
|
d.setInt32(10, altCm, Endian.little);
|
||||||
|
return d.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dekodiert lat/lon (1e-7 deg) und Hoehe (cm -> m)', () {
|
||||||
|
final home = parseMspHomePoint(encodeFakeWpResponse(
|
||||||
|
lat: 525000000,
|
||||||
|
lon: 134000000,
|
||||||
|
altCm: 6050,
|
||||||
|
));
|
||||||
|
expect(home, isNotNull);
|
||||||
|
expect(home!.lat, closeTo(52.5, 1e-9));
|
||||||
|
expect(home.lon, closeTo(13.4, 1e-9));
|
||||||
|
expect(home.altitudeM, closeTo(60.5, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('liefert null bei (0,0) - FCs eigener "kein Homepoint gesetzt"-Wert '
|
||||||
|
'(STATE(GPS_FIX_HOME) noch nicht erreicht)', () {
|
||||||
|
final home = parseMspHomePoint(encodeFakeWpResponse(
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
altCm: 0,
|
||||||
|
));
|
||||||
|
expect(home, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lat == 0 alleine (echter Punkt auf dem Aequator) zaehlt NICHT als '
|
||||||
|
'unset - nur (0,0) gemeinsam ist der Sonderfall', () {
|
||||||
|
final home = parseMspHomePoint(encodeFakeWpResponse(
|
||||||
|
lat: 0,
|
||||||
|
lon: 134000000,
|
||||||
|
altCm: 0,
|
||||||
|
));
|
||||||
|
expect(home, isNotNull);
|
||||||
|
expect(home!.lon, closeTo(13.4, 1e-9));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
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/transport/flight_controller_link.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/home_point_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/telemetry_provider.dart';
|
||||||
|
|
||||||
|
/// Zaehlt readHomePoint()-Aufrufe statt sie tatsaechlich per MSP zu
|
||||||
|
/// beantworten - alle anderen Mitglieder sind fuer diesen Test irrelevant
|
||||||
|
/// und werfen bei Gebrauch bewusst.
|
||||||
|
class _FakeLink implements FlightControllerLink {
|
||||||
|
int readHomePointCalls = 0;
|
||||||
|
HomePoint? nextHomePoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomePoint?> readHomePoint() async {
|
||||||
|
readHomePointCalls++;
|
||||||
|
return nextHomePoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FcCapabilities get capabilities => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<void> connect() => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<void> uploadMission(FlatWaypointList mission) => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<bool> verifyMission(FlatWaypointList mission) => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<void> setFlightMode(FlightMode mode) => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<void> arm() => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Stream<TelemetryFrame> subscribeTelemetry() => throw UnimplementedError();
|
||||||
|
@override
|
||||||
|
Future<int?> readActiveWaypointIndex() => throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
TelemetryFrame _frame({bool hasFix = true, bool armed = false}) => TelemetryFrame(
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
hasFix: hasFix,
|
||||||
|
fixType: hasFix ? 2 : 0,
|
||||||
|
numSat: 10,
|
||||||
|
altitudeM: 100,
|
||||||
|
gpsAltitudeM: 100,
|
||||||
|
speedMs: 15,
|
||||||
|
headingDeg: 0,
|
||||||
|
armed: armed,
|
||||||
|
batteryPercent: 80,
|
||||||
|
batteryVoltage: 22.0,
|
||||||
|
currentA: 10.0,
|
||||||
|
linkQuality: 90,
|
||||||
|
snrDb: 5,
|
||||||
|
hdop: 1.0,
|
||||||
|
navMode: 0,
|
||||||
|
flightMode: 'Idle',
|
||||||
|
sensorStatusBits: 0,
|
||||||
|
temperaturesC: const [null, null, null],
|
||||||
|
verticalSpeedMs: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('fragt den Homepoint beim ersten Telemetrie-Frame ab (Verbindungsaufbau)',
|
||||||
|
() async {
|
||||||
|
final controller = StreamController<TelemetryFrame>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final link = _FakeLink()
|
||||||
|
..nextHomePoint = const HomePoint(lat: 52.5, lon: 13.4, altitudeM: 60);
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
telemetryProvider.overrideWith((ref) => controller.stream),
|
||||||
|
flightControllerLinkProvider.overrideWithValue(link),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.listen(homePointAutoRefreshProvider, (previous, next) {});
|
||||||
|
|
||||||
|
controller.add(_frame(hasFix: false));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(link.readHomePointCalls, 1);
|
||||||
|
expect(container.read(homePointProvider)?.lat, closeTo(52.5, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragt NICHT bei jedem Frame erneut ab, solange sich Fix-/Arm-Status '
|
||||||
|
'nicht aendern (keine kontinuierliche Abfrage)', () async {
|
||||||
|
final controller = StreamController<TelemetryFrame>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final link = _FakeLink();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
telemetryProvider.overrideWith((ref) => controller.stream),
|
||||||
|
flightControllerLinkProvider.overrideWithValue(link),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.listen(homePointAutoRefreshProvider, (previous, next) {});
|
||||||
|
|
||||||
|
controller.add(_frame());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
controller.add(_frame());
|
||||||
|
controller.add(_frame());
|
||||||
|
controller.add(_frame());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(link.readHomePointCalls, 1, reason: 'nur der erste Frame (Connect) loest aus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragt beim Uebergang auf einen GPS-Fix erneut ab', () async {
|
||||||
|
final controller = StreamController<TelemetryFrame>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final link = _FakeLink();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
telemetryProvider.overrideWith((ref) => controller.stream),
|
||||||
|
flightControllerLinkProvider.overrideWithValue(link),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.listen(homePointAutoRefreshProvider, (previous, next) {});
|
||||||
|
|
||||||
|
controller.add(_frame(hasFix: false));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 1, reason: 'erster Frame = Connect-Trigger');
|
||||||
|
|
||||||
|
controller.add(_frame(hasFix: true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 2, reason: 'Fix-Erwerb ist ein eigener Trigger');
|
||||||
|
|
||||||
|
controller.add(_frame(hasFix: true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 2, reason: 'weiterhin Fix loest nicht erneut aus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragt beim Uebergang auf armed erneut ab', () async {
|
||||||
|
final controller = StreamController<TelemetryFrame>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final link = _FakeLink();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
telemetryProvider.overrideWith((ref) => controller.stream),
|
||||||
|
flightControllerLinkProvider.overrideWithValue(link),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.listen(homePointAutoRefreshProvider, (previous, next) {});
|
||||||
|
|
||||||
|
controller.add(_frame(armed: false));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 1, reason: 'erster Frame = Connect-Trigger');
|
||||||
|
|
||||||
|
controller.add(_frame(armed: true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 2, reason: 'Armen ist ein eigener Trigger');
|
||||||
|
|
||||||
|
controller.add(_frame(armed: true));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 2, reason: 'weiterhin armed loest nicht erneut aus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragt nach einem Verbindungsverlust bei Wiederverbindung erneut ab',
|
||||||
|
() async {
|
||||||
|
final controller = StreamController<TelemetryFrame>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final link = _FakeLink();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
telemetryProvider.overrideWith((ref) => controller.stream),
|
||||||
|
flightControllerLinkProvider.overrideWithValue(link),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.listen(homePointAutoRefreshProvider, (previous, next) {});
|
||||||
|
|
||||||
|
controller.add(_frame());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 1);
|
||||||
|
|
||||||
|
controller.addError(StateError('link down'));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 1, reason: 'ein Fehler alleine fragt nichts ab');
|
||||||
|
|
||||||
|
controller.add(_frame());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(link.readHomePointCalls, 2, reason: 'erster Frame nach Reconnect ist wieder ein Trigger');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('speichert null, wenn der FC (noch) keinen Homepoint gesetzt hat', () async {
|
||||||
|
final controller = StreamController<TelemetryFrame>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final link = _FakeLink()..nextHomePoint = null;
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
telemetryProvider.overrideWith((ref) => controller.stream),
|
||||||
|
flightControllerLinkProvider.overrideWithValue(link),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.listen(homePointAutoRefreshProvider, (previous, next) {});
|
||||||
|
|
||||||
|
controller.add(_frame());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(container.read(homePointProvider), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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/wind/wind_service.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/providers/wind_provider.dart';
|
||||||
|
|
||||||
|
/// Zaehlt fetchWindForWaypoints()-Aufrufe statt echte HTTP-Requests zu
|
||||||
|
/// stellen - schreibt synthetische Winddaten zurueck, damit
|
||||||
|
/// ensurePerWaypointWind()s "bereits vorhanden"-Kurzschluss (siehe dortige
|
||||||
|
/// Doku) bei einem zweiten Aufruf greifen kann.
|
||||||
|
class _CountingWindService extends WindService {
|
||||||
|
int fetchCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Waypoint>> fetchWindForWaypoints(List<Waypoint> waypoints) async {
|
||||||
|
fetchCalls++;
|
||||||
|
return [
|
||||||
|
for (final w in waypoints) w.copyWith(windSpeedMs: 5, windDirFromDeg: 200),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Waypoint _wp(double lat, double lon, {double? windSpeedMs}) => Waypoint(
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
altitudeM: 60,
|
||||||
|
speedMs: 15,
|
||||||
|
catchRadiusM: 60,
|
||||||
|
windSpeedMs: windSpeedMs,
|
||||||
|
windDirFromDeg: windSpeedMs != null ? 180 : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('ensurePerWaypointWind() fetches once for a list without wind data', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||||
|
|
||||||
|
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||||
|
|
||||||
|
expect(service.fetchCalls, 1);
|
||||||
|
expect(container.read(currentMissionProvider).single.windSpeedMs, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensurePerWaypointWind() does not fetch when every waypoint already '
|
||||||
|
'has wind data (e.g. loaded from a saved mission)', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0, windSpeedMs: 3));
|
||||||
|
|
||||||
|
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||||
|
|
||||||
|
expect(service.fetchCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second ensurePerWaypointWind() call for the unchanged list does '
|
||||||
|
'not fetch again', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||||
|
|
||||||
|
final notifier = container.read(headerWindProvider.notifier);
|
||||||
|
await notifier.ensurePerWaypointWind();
|
||||||
|
await notifier.ensurePerWaypointWind();
|
||||||
|
|
||||||
|
expect(service.fetchCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleShowPerWaypoint() fetches when turning on, not when turning off', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||||
|
|
||||||
|
final notifier = container.read(headerWindProvider.notifier);
|
||||||
|
notifier.toggleShowPerWaypoint(); // an
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(container.read(headerWindProvider).showPerWaypoint, isTrue);
|
||||||
|
expect(service.fetchCalls, 1);
|
||||||
|
|
||||||
|
notifier.toggleShowPerWaypoint(); // aus
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(container.read(headerWindProvider).showPerWaypoint, isFalse);
|
||||||
|
expect(service.fetchCalls, 1, reason: 'Ausschalten loest keinen Abruf aus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after activation, changing the waypoint list (e.g. adding a point) '
|
||||||
|
'triggers an automatic refetch', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final missionNotifier = container.read(currentMissionProvider.notifier);
|
||||||
|
missionNotifier.addWaypoint(_wp(0, 0));
|
||||||
|
|
||||||
|
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||||
|
expect(service.fetchCalls, 1);
|
||||||
|
|
||||||
|
missionNotifier.addWaypoint(_wp(0, 0.01));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(service.fetchCalls, 2,
|
||||||
|
reason: 'die veraenderte Wegpunktliste loest automatisch neu aus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after activation, editing only altitude (position unchanged) does '
|
||||||
|
'NOT trigger a refetch', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final missionNotifier = container.read(currentMissionProvider.notifier);
|
||||||
|
missionNotifier.addWaypoint(_wp(0, 0));
|
||||||
|
|
||||||
|
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||||
|
expect(service.fetchCalls, 1);
|
||||||
|
|
||||||
|
missionNotifier.setAltitude(0, 150);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(service.fetchCalls, 1,
|
||||||
|
reason: 'nur die Position ist Teil der Aenderungs-Signatur, nicht die Hoehe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('before activation (menu never opened, pill never toggled), list '
|
||||||
|
'changes trigger no fetch at all', () async {
|
||||||
|
final service = _CountingWindService();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
windServiceProvider.overrideWithValue(service),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
// Beobachtet headerWindProvider, ohne ensurePerWaypointWind() oder
|
||||||
|
// toggleShowPerWaypoint() aufzurufen - build() registriert den Listener
|
||||||
|
// zwar, er soll aber inaktiv bleiben.
|
||||||
|
container.read(headerWindProvider);
|
||||||
|
|
||||||
|
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(service.fetchCalls, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import 'package:dmc_app/services/geocoding/nominatim_service.dart';
|
|||||||
import 'package:dmc_app/services/sharing/drone_share_codec.dart';
|
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/services/wind/wind_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/screens/fly/fly_screen.dart';
|
||||||
import 'package:dmc_app/ui/providers/active_drone_profile_provider.dart';
|
import 'package:dmc_app/ui/providers/active_drone_profile_provider.dart';
|
||||||
@@ -28,8 +29,10 @@ 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/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/ui/providers/wind_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';
|
||||||
|
import 'package:dmc_app/ui/widgets/drone_status_messages_panel.dart';
|
||||||
import 'package:dmc_app/ui/widgets/full_value_chart.dart';
|
import 'package:dmc_app/ui/widgets/full_value_chart.dart';
|
||||||
import 'package:dmc_app/ui/widgets/halo_menu.dart';
|
import 'package:dmc_app/ui/widgets/halo_menu.dart';
|
||||||
import 'package:dmc_app/ui/widgets/header_wind_pill.dart';
|
import 'package:dmc_app/ui/widgets/header_wind_pill.dart';
|
||||||
@@ -88,6 +91,16 @@ ProviderContainer _testContainer() => ProviderContainer(
|
|||||||
ref.onDispose(link.disconnect);
|
ref.onDispose(link.disconnect);
|
||||||
return link;
|
return link;
|
||||||
}),
|
}),
|
||||||
|
// Ohne diesen Override wuerde headerWindProvider.ensurePerWaypointWind()
|
||||||
|
// (seit dem automatischen Windabruf beim Oeffnen der Wegpunktliste/
|
||||||
|
// Aktivieren der Kopfleisten-Pille) einen echten Netzwerkrequest
|
||||||
|
// ausloesen, sobald ein Test Wegpunkte ohne bereits gesetzte
|
||||||
|
// Winddaten anlegt - MockClient antwortet stattdessen sofort mit
|
||||||
|
// einem Fehlschlag, den WindService._fetchJson ohnehin schon
|
||||||
|
// abfaengt (liefert dann einfach kein Windergebnis).
|
||||||
|
windServiceProvider.overrideWithValue(
|
||||||
|
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,6 +120,9 @@ Widget _wrap(Widget child) => ProviderScope(
|
|||||||
ref.onDispose(link.disconnect);
|
ref.onDispose(link.disconnect);
|
||||||
return link;
|
return link;
|
||||||
}),
|
}),
|
||||||
|
windServiceProvider.overrideWithValue(
|
||||||
|
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (_) => AppModeCubit(),
|
create: (_) => AppModeCubit(),
|
||||||
@@ -1160,7 +1176,7 @@ void main() {
|
|||||||
// steht im Widget-Baum zuerst.
|
// steht im Widget-Baum zuerst.
|
||||||
await tester.tap(find.byType(PaperPlaneIcon).first);
|
await tester.tap(find.byType(PaperPlaneIcon).first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Drone Status & System Messages'), findsOneWidget);
|
expect(find.byType(DroneStatusMessagesPanel), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('System Messages'));
|
await tester.tap(find.text('System Messages'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" role="img">
|
||||||
|
<title>Homepoint-Icon: Sechseck mit H</title>
|
||||||
|
<polygon points="36,6 62,21 62,51 36,66 10,51 10,21" fill="#161616" stroke="#ffffff" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<text x="36" y="51" text-anchor="middle" style="font-size:44px;font-weight:700;font-family:sans-serif" fill="#ffffff">H</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" role="img">
|
||||||
|
<title>Homepoint-Icon: Achteck mit H</title>
|
||||||
|
<polygon points="17,4 55,4 68,17 68,55 55,68 17,68 4,55 4,17" fill="#161616" stroke="#ffffff" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<text x="36" y="56" text-anchor="middle" style="font-size:56px;font-weight:700;font-family:sans-serif" fill="#ffffff">H</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" role="img">
|
||||||
|
<title>Homepoint-Icon: Pentagon-Häuschen</title>
|
||||||
|
<polygon points="20,52 20,34 36,16 52,34 52,52" fill="#161616" stroke="#ffffff" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" role="img">
|
||||||
|
<title>Homepoint-Icon: abgerundetes Quadrat mit H</title>
|
||||||
|
<rect x="4" y="4" width="64" height="64" rx="14" fill="#161616" stroke="#ffffff" stroke-width="3"/>
|
||||||
|
<text x="36" y="56" text-anchor="middle" style="font-size:58px;font-weight:700;font-family:sans-serif" fill="#ffffff">H</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 387 B |
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg width="100%" viewBox="0 0 680 280" xmlns="http://www.w3.org/2000/svg" role="img" style="">
|
||||||
|
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Homepoint als Häuschen-Icon auf hellem Hintergrund</title>
|
||||||
|
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Drei Varianten eines schwarzen Häuschen-Icons mit weißem Rand als Homepoint-Markierung, jeweils neben einem Wegpunkt-Kreis zum Größenvergleich: Pentagon-Silhouette, klassisch zweiteilig, mit Tür.</desc>
|
||||||
|
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>
|
||||||
|
|
||||||
|
<rect x="40" y="40" width="180" height="220" rx="12" fill="#e9e5da" style="fill:rgb(233, 229, 218);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="250" y="40" width="180" height="220" rx="12" fill="#e9e5da" style="fill:rgb(233, 229, 218);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="460" y="40" width="180" height="220" rx="12" fill="#e9e5da" style="fill:rgb(233, 229, 218);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<polygon points="115,156 115,138 130,120 145,138 145,156" fill="#161616" stroke="#ffffff" stroke-width="2.5" stroke-linejoin="round" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:round;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<circle cx="172" cy="158" r="9" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="172" y="180" text-anchor="middle" style="font-size:9px;fill:rgb(107, 106, 99);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#6b6a63">WP</text>
|
||||||
|
|
||||||
|
<rect x="326" y="138" width="28" height="18" rx="3" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<polygon points="323,138 340,118 357,138" fill="#161616" stroke="#ffffff" stroke-width="2.5" stroke-linejoin="round" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:round;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<circle cx="382" cy="158" r="9" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="382" y="180" text-anchor="middle" style="font-size:9px;fill:rgb(107, 106, 99);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#6b6a63">WP</text>
|
||||||
|
|
||||||
|
<polygon points="535,156 535,138 550,120 565,138 565,156" fill="#161616" stroke="#ffffff" stroke-width="2.5" stroke-linejoin="round" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:round;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="545" y="147" width="10" height="9" rx="1" fill="#ffffff" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<circle cx="592" cy="158" r="9" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="592" y="180" text-anchor="middle" style="font-size:9px;fill:rgb(107, 106, 99);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#6b6a63">WP</text>
|
||||||
|
|
||||||
|
<text x="130" y="235" text-anchor="middle" style="font-size:13px;fill:rgb(51, 50, 45);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#33322d">Pentagon-silhouette</text>
|
||||||
|
<text x="340" y="235" text-anchor="middle" style="font-size:13px;fill:rgb(51, 50, 45);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#33322d">Klassisch, zweiteilig</text>
|
||||||
|
<text x="550" y="235" text-anchor="middle" style="font-size:13px;fill:rgb(51, 50, 45);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#33322d">Mit tür</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg width="100%" viewBox="0 0 680 280" xmlns="http://www.w3.org/2000/svg" role="img" style="">
|
||||||
|
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Homepoint-Marker im Look der App, Version 3</title>
|
||||||
|
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Drei Formvarianten für den Homepoint-Marker mit korrigiertem symmetrischen Achteck und größerem zentrierten H: Sechseck, abgerundetes Quadrat, Achteck.</desc>
|
||||||
|
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>
|
||||||
|
|
||||||
|
<rect x="40" y="40" width="180" height="220" rx="12" fill="#23262b" style="fill:rgb(35, 38, 43);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="250" y="40" width="180" height="220" rx="12" fill="#23262b" style="fill:rgb(35, 38, 43);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="460" y="40" width="180" height="220" rx="12" fill="#23262b" style="fill:rgb(35, 38, 43);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<polygon points="159,113 130,96 101,113 101,147 130,164 159,147" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="130" y="147" text-anchor="middle" style="font-size:48px;font-weight:700;fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:48px;font-weight:700;text-anchor:middle;dominant-baseline:auto" fill="#ffffff">H</text>
|
||||||
|
<circle cx="178" cy="168" r="8" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<rect x="308" y="98" width="64" height="64" rx="14" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="340" y="147" text-anchor="middle" style="font-size:48px;font-weight:700;fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:48px;font-weight:700;text-anchor:middle;dominant-baseline:auto" fill="#ffffff">H</text>
|
||||||
|
<circle cx="388" cy="168" r="8" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<polygon points="531,98 569,98 582,111 582,149 569,162 531,162 518,149 518,111" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="550" y="147" text-anchor="middle" style="font-size:48px;font-weight:700;fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:48px;font-weight:700;text-anchor:middle;dominant-baseline:auto" fill="#ffffff">H</text>
|
||||||
|
<circle cx="608" cy="168" r="8" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<text x="130" y="225" text-anchor="middle" style="font-size:13px;fill:rgb(232, 232, 232);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#e8e8e8">Sechseck</text>
|
||||||
|
<text x="340" y="225" text-anchor="middle" style="font-size:13px;fill:rgb(232, 232, 232);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#e8e8e8">Abgerundetes quadrat</text>
|
||||||
|
<text x="550" y="225" text-anchor="middle" style="font-size:13px;fill:rgb(232, 232, 232);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#e8e8e8">Achteck</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg width="100%" viewBox="0 0 680 280" xmlns="http://www.w3.org/2000/svg" role="img" style="">
|
||||||
|
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Homepoint-Marker im Look der App, Version 4</title>
|
||||||
|
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Drei Formvarianten für den Homepoint-Marker mit maximal großem H und minimalem Innenabstand zum Rand: Sechseck, abgerundetes Quadrat, Achteck.</desc>
|
||||||
|
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>
|
||||||
|
|
||||||
|
<rect x="40" y="40" width="180" height="220" rx="12" fill="#23262b" style="fill:rgb(35, 38, 43);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="250" y="40" width="180" height="220" rx="12" fill="#23262b" style="fill:rgb(35, 38, 43);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<rect x="460" y="40" width="180" height="220" rx="12" fill="#23262b" style="fill:rgb(35, 38, 43);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<polygon points="159,113 130,96 101,113 101,147 130,164 159,147" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="130" y="149" text-anchor="middle" style="font-size:52px;font-weight:700;fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:52px;font-weight:700;text-anchor:middle;dominant-baseline:auto" fill="#ffffff">H</text>
|
||||||
|
<circle cx="178" cy="168" r="8" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<rect x="308" y="98" width="64" height="64" rx="14" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="340" y="150" text-anchor="middle" style="font-size:58px;font-weight:700;fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:58px;font-weight:700;text-anchor:middle;dominant-baseline:auto" fill="#ffffff">H</text>
|
||||||
|
<circle cx="388" cy="168" r="8" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<polygon points="531,98 569,98 582,111 582,149 569,162 531,162 518,149 518,111" fill="#161616" stroke="#ffffff" stroke-width="2.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:2.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
<text x="550" y="149" text-anchor="middle" style="font-size:56px;font-weight:700;fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:56px;font-weight:700;text-anchor:middle;dominant-baseline:auto" fill="#ffffff">H</text>
|
||||||
|
<circle cx="608" cy="168" r="8" fill="#161616" stroke="#ffffff" stroke-width="1.5" style="fill:rgb(22, 22, 22);stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||||
|
|
||||||
|
<text x="130" y="225" text-anchor="middle" style="font-size:13px;fill:rgb(232, 232, 232);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#e8e8e8">Sechseck</text>
|
||||||
|
<text x="340" y="225" text-anchor="middle" style="font-size:13px;fill:rgb(232, 232, 232);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#e8e8e8">Abgerundetes quadrat</text>
|
||||||
|
<text x="550" y="225" text-anchor="middle" style="font-size:13px;fill:rgb(232, 232, 232);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:anthropic-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:13px;font-weight:400;text-anchor:middle;dominant-baseline:auto" fill="#e8e8e8">Achteck</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |