Compare commits

..
10 Commits
Author SHA1 Message Date
Constantin Leue 2581bdaaef Document the wind auto-fetch decision in the architecture doc 2026-08-09 22:27:37 +02:00
Constantin Leue eca1c647b3 Auto-fetch wind, drop manual fetch/validate buttons, fix it in fly mode
Redesigns the waypoint list panel to match the drone-status/missions
panels: no title bar, close button floats over the tab row, tabs get
more vertical padding.

Removes the "Fetch wind" and "Validate" header buttons along with the
now-fully-orphaned WindValidatePanel screen and its WindService
support code (fetchValidation, WindValidationResult and friends) -
nothing else referenced them.

Wind is now fetched automatically instead: HeaderWindNotifier gains
ensurePerWaypointWind(), called when the waypoint list panel opens and
when the header pill's per-waypoint toggle switches on. A lat/lon
signature of the waypoint list tracks whether the fetched data is
still current, so a ref.listen(currentMissionProvider) in the same
notifier refetches only when the list itself changes (position added/
removed/moved) - not on every altitude/speed edit. Waypoints that
already carry wind data (e.g. loaded from a saved mission) short-
circuit the check instead of re-fetching pointlessly.

Also wires per-waypoint wind markers into the fly-mode map, which
never had them (plan_screen.dart built windMarkers; fly_screen.dart
just never did) - the reported "doesn't work in fly mode" bug.
windMarkerWidth() moved from a plan_screen.dart-private method to
wind_marker_pill.dart so both screens can share it.

Test containers in widget_test.dart now override windServiceProvider
with a MockClient so the new auto-fetch trigger can't reach the real
network during tests.
2026-08-09 22:26:23 +02:00
Constantin Leue 6b7977eca1 Show distance to home as a fourth field in the fly-mode details pill
Adds computeDistanceToHomeM() (mission_stats.dart) alongside the
existing rubber-band-target helper, wired through MissionFooterBar ->
BottomStatsBar the same way flyMissionStatus already is. Only appears
once a home point is known, as the last field after waypoint/distance/
duration.

Generalized HomePointIcon to take an optional strokeColor: the map
marker keeps its dark-fill-plus-white-outline look, while the details
pill uses a plain white fill (strokeColor: null) to match the existing
monochrome flag/ruler/clock icons instead of looking like a dropped-in
map pin.
2026-08-09 21:49:03 +02:00
Constantin Leue afc380cabc Point the fly-mode rubber band at home during Return-to-Home
iNAV freezes activeWaypointIndex at whatever mission waypoint was
active when RTH engaged instead of updating it to reflect the new
target - navigation.c derives NAV_Status.activeWpIndex unconditionally
from posControl.activeWaypointIndex, and none of the RTH state-entry
handlers touch that field. Drawing the guidance line to that stale
index would point at a waypoint the aircraft may have already passed.

Extracted the target selection into computeRubberBandTarget() (mission
stats.dart) so the RTH special-case and the abort-back-to-waypoint-mode
fallback are unit-tested rather than only living inline in the widget
build method - since it re-evaluates navMode on every frame, aborting
RTH switches the line back to the mission waypoint with no extra
transition logic needed.
2026-08-09 21:31:00 +02:00
Constantin Leue fdefa3e257 Document the home point query decision in the architecture doc 2026-08-09 21:11:05 +02:00
Constantin Leue 5fd5d0117a Show the drone's locked home point on the fly-mode map
Adds MSP_WP (118) support to query the flightcontroller's stored home
point (WP#0 is a special case for this in iNAV's getWaypoint(), per
navigation.c) - the FC reports (0,0,0) rather than an error before one
is set, so parseMspHomePoint() treats that pair as "unset". Rendered
with a small pentagon house icon (design/homepoint-pentagon.svg,
ported to a CustomPainter like the other map icons).

Deliberately not polled continuously: a new home_point_provider.dart
refreshes it only at the three moments the FC's home point can
actually change - connect/reconnect, GPS fix acquired, and arming
(iNAV's default reset_home_type=FIRST_ARM only freezes it at the first
arm; before that it continuously follows the aircraft while disarmed).

Mock's implementation offsets the point 25m from the anchor so it
doesn't sit exactly under the drone marker during UI testing.
2026-08-09 21:10:00 +02:00
Constantin Leue c9f16b13f8 homepoint design and auto fixes 2026-08-09 20:40:37 +02:00
Constantin Leue d8a0809935 Apply the drone status panel's header treatment to Missions & Drones
Same change as the drone status panel: drop the title row, float the
close button over the tab row (reserving 48px on its right), and give
the tabs more vertical padding so they don't look cramped without the
title above them.
2026-08-07 23:14:50 +02:00
Constantin Leue eb76d4deb1 Give the drone status panel's tabs more vertical padding
They looked cramped once the title bar above them was removed.
2026-08-07 23:10:02 +02:00
Constantin Leue 03c24b830e Drop the drone status panel's title bar, default the grid to 3 columns
The title row cost a full line of vertical space for a redundant
label. The close button now floats over the tab row instead, which
reserves 48px on its right so "System Messages" doesn't sit under it.
Also replaced the manually grouped 2/3-column row layout with uniform
3-per-row chunking of a flat field list, matching the user's request
to default to three fields per row.
2026-08-07 23:03:43 +02:00
40 changed files with 1439 additions and 734 deletions
+2
View File
@@ -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. |
--- ---
Binary file not shown.
+51
View File
@@ -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);
}
-152
View File
@@ -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,
);
} }
+16
View File
@@ -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);
}
+2 -3
View File
@@ -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;
}
});
});
+89 -21
View File
@@ -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;
}
}
+78 -16
View File
@@ -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,
+1 -16
View File
@@ -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;
}
} }
+29
View File
@@ -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,41 +38,41 @@ 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: [ children: [
Padding( Column(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
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), Padding(
_tabButton('System Messages', _PanelTab.messages), // Rechts Platz fuer den schwebenden Schliessen-Knopf, damit
// "System Messages" nicht darunter verschwindet.
padding: const EdgeInsets.only(right: 48),
child: Row(
children: [
_tabButton('Status', _PanelTab.status),
_tabButton('System Messages', _PanelTab.messages),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
_PanelTab.status => _statusTab(),
_PanelTab.messages => _messagesTab(),
},
),
], ],
), ),
const Divider(color: Colors.white24, height: 1), Positioned(
Expanded( top: 4,
child: switch (_tab) { right: 4,
_PanelTab.status => _statusTab(), child: IconButton(
_PanelTab.messages => _messagesTab(), 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,97 +148,80 @@ 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 _statusRow(
// direkt unter der Sensor-Status-Zeile (Doku), noch vor den 'Arming',
// farbcodierten Feldern. Flugmodus zeigt bei fehlender frame == null ? '—' : (frame.armed ? 'Armed' : 'Disarmed'),
// Nav-Uebersteuerung "Idle" (siehe MspNavMode._modeNames) - das level: frame?.armed == true ? SignalLevel.green : null,
// heisst nur "kein GPS-Nav-Modus aktiv", nicht zwingend "steht am ),
// Boden" (reine Angle/Horizon/Acro-Fluglage ist ueber _statusRow('Temperatures', _formatTemperatures(frame)),
// MSP_NAV_STATUS nicht sichtbar). _statusRow('Flight mode', frame != null ? frame.flightMode : '—'),
[
_statusRow(
'Arming',
frame == null ? '—' : (frame.armed ? 'Armed' : 'Disarmed'),
level: frame?.armed == true ? SignalLevel.green : null,
),
_statusRow('Temperatures', _formatTemperatures(frame)),
_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(
[ 'GPS fix type',
_statusRow( _formatFixType(frame),
'GPS fix type', level: frame != null ? _fixTypeLevel(frame.fixType) : null,
_formatFixType(frame), ),
level: frame != null ? _fixTypeLevel(frame.fixType) : null, _statusRow('GPS satellites', frame != null ? '${frame.numSat}' : '—', level: fieldStatus.numSat),
), _statusRow(
_statusRow('GPS satellites', frame != null ? '${frame.numSat}' : '—', level: fieldStatus.numSat), 'GPS precision (HDOP)',
_statusRow( frame != null && frame.hasFix ? frame.hdop.toStringAsFixed(2) : '—',
'GPS precision (HDOP)', level: fieldStatus.hdop,
frame != null && frame.hasFix ? frame.hdop.toStringAsFixed(2) : '—', ),
level: fieldStatus.hdop, _statusRow(
), 'Current draw',
], frame != null ? '${frame.currentA.toStringAsFixed(1)} A' : '—',
[ level: fieldStatus.currentA,
_statusRow( ),
'Current draw', _statusRow(
frame != null ? '${frame.currentA.toStringAsFixed(1)} A' : '—', 'Battery voltage',
level: fieldStatus.currentA, frame != null ? '${frame.batteryVoltage.toStringAsFixed(2)} V' : '—',
), level: fieldStatus.batteryVoltage,
_statusRow( ),
'Battery voltage', _statusRow('RX quality', frame != null ? '${frame.linkQuality}%' : '—', level: fieldStatus.rxQuality),
frame != null ? '${frame.batteryVoltage.toStringAsFixed(2)} V' : '—', _statusRow('RX SNR', frame != null ? '${frame.snrDb} dB' : '—', level: fieldStatus.rxSnr),
level: fieldStatus.batteryVoltage, _statusRow(
), 'GPS coordinates',
], frame != null && frame.hasFix
[ ? '${frame.lat.toStringAsFixed(6)}, ${frame.lon.toStringAsFixed(6)}'
_statusRow('RX quality', frame != null ? '${frame.linkQuality}%' : '—', level: fieldStatus.rxQuality), : '—',
_statusRow('RX SNR', frame != null ? '${frame.snrDb} dB' : '—', level: fieldStatus.rxSnr), ),
], _statusRow('Heading', frame != null ? '${frame.headingDeg.round()}°' : '—'),
[ _statusRow('Altitude', _formatAltitude(frame)),
_statusRow( _statusRow('Speed', frame != null ? '${frame.speedMs.toStringAsFixed(1)} m/s' : '—'),
'GPS coordinates',
frame != null && frame.hasFix
? '${frame.lat.toStringAsFixed(6)}, ${frame.lon.toStringAsFixed(6)}'
: '—',
),
_statusRow('Heading', frame != null ? '${frame.headingDeg.round()}°' : '—'),
],
[
_statusRow('Altitude', _formatAltitude(frame)),
_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 j = 0; j < chunk.length; j++) ...[
for (var i = 0; i < tiles.length; i++) ...[ if (j > 0) const SizedBox(width: 8),
if (i > 0) const SizedBox(width: 8), Expanded(child: chunk[j]),
Expanded(child: tiles[i]),
],
], ],
), ],
]; ));
}
return rows;
} }
static const _signalColors = { static const _signalColors = {
+83
View File
@@ -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,
+7
View File
@@ -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(),
+35 -32
View File
@@ -36,41 +36,42 @@ 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: [ children: [
Padding( Column(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
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), Padding(
_tabButton('Drones', _ManagementTab.drones), // Rechts Platz fuer den schwebenden Schliessen-Knopf, damit
// "Drones" nicht darunter verschwindet.
padding: const EdgeInsets.only(right: 48),
child: Row(
children: [
_tabButton('Missions', _ManagementTab.missions),
_tabButton('Drones', _ManagementTab.drones),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
_ManagementTab.missions => _missionsTab(),
_ManagementTab.drones => _dronesTab(),
},
),
], ],
), ),
const Divider(color: Colors.white24, height: 1), Positioned(
Expanded( top: 4,
child: switch (_tab) { right: 4,
_ManagementTab.missions => _missionsTab(), child: IconButton(
_ManagementTab.drones => _dronesTab(), 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(
+70 -154
View File
@@ -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,83 +158,71 @@ 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: [ children: [
Padding( Column(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
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), Padding(
_tabButton('Altitude', PanelTab.altitude), // Rechts Platz fuer den schwebenden Schliessen-Knopf,
_tabButton('Speed', PanelTab.speed), // damit "Speed" nicht darunter verschwindet.
padding: const EdgeInsets.only(right: 48),
child: Row(
children: [
_tabButton('List', PanelTab.list),
_tabButton('Altitude', PanelTab.altitude),
_tabButton('Speed', PanelTab.speed),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
PanelTab.altitude => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.altitudeM],
min: DefaultDroneProfile.altMin,
max: droneProfile.maxAltitudeM,
step: DefaultDroneProfile.altStep,
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
),
PanelTab.speed => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.speedMs],
min: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
step: DefaultDroneProfile.speedStep,
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
legWind: computeLegWind(waypoints),
groundSpeeds: computeGroundSpeeds(waypoints),
),
},
),
], ],
), ),
const Divider(color: Colors.white24, height: 1), Positioned(
Expanded( top: 4,
child: switch (_tab) { right: 4,
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile), child: IconButton(
PanelTab.altitude => _chartTab( onPressed: widget.onClose,
waypoints, icon: const Icon(Icons.close, color: Colors.white),
routeGeometry, ),
values: [for (final w in waypoints) w.altitudeM],
min: DefaultDroneProfile.altMin,
max: droneProfile.maxAltitudeM,
step: DefaultDroneProfile.altStep,
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
),
PanelTab.speed => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.speedMs],
min: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
step: DefaultDroneProfile.speedStep,
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
legWind: computeLegWind(waypoints),
groundSpeeds: computeGroundSpeeds(waypoints),
),
},
), ),
], ],
), ),
@@ -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,
),
),
],
),
),
);
}
}
+14
View File
@@ -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).
-152
View File
@@ -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),
),
),
],
);
}
}
+102
View File
@@ -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,
);
});
});
} }
-75
View File
@@ -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);
});
}
+17 -1
View File
@@ -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();
+5
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+27
View File
@@ -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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+25
View File
@@ -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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+25
View File
@@ -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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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, &quot;Segoe UI&quot;, 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