diff --git a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt index 1116946..cafe3f3 100644 --- a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt +++ b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt @@ -71,6 +71,9 @@ class MlrsNetworkPlugin( private var networkCallback: ConnectivityManager.NetworkCallback? = null private var currentNetwork: Network? = null + /** Zuletzt an Dart gemeldete SSID der aktuellen Anfrage (siehe onCapabilitiesChanged unten) - vermeidet doppelte "ssid"-Ereignisse. */ + private var reportedSsid: String? = null + private var socket: DatagramSocket? = null @Volatile private var receiveLoopRunning = false @@ -160,16 +163,35 @@ class MlrsNetworkPlugin( .setNetworkSpecifier(specifier) .build() + reportedSsid = null val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { currentNetwork = network val gateway = gatewayAddressOf(connectivityManager.getLinkProperties(network)) val ssid = ssidOf(connectivityManager.getNetworkCapabilities(network)) + if (ssid != null) reportedSsid = ssid eventSink?.success( mapOf("type" to "available", "gatewayAddress" to gateway, "ssid" to ssid), ) } + // WifiInfo/SSID ist bei onAvailable() oft noch nicht befuellt - + // Android liefert `NetworkCapabilities.getTransportInfo()` fuer + // WifiNetworkSpecifier-Netze haeufig erst mit einem spaeteren + // onCapabilitiesChanged()-Aufruf (bekannter Android-Stolperstein). + // Deshalb hier zusaetzlich pruefen und die SSID nachreichen, + // sobald sie bekannt wird. + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + if (network != currentNetwork) return + val ssid = ssidOf(networkCapabilities) ?: return + if (ssid == reportedSsid) return + reportedSsid = ssid + eventSink?.success(mapOf("type" to "ssid", "ssid" to ssid)) + } + override fun onUnavailable() { currentNetwork = null eventSink?.success(mapOf("type" to "unavailable")) @@ -177,6 +199,7 @@ class MlrsNetworkPlugin( override fun onLost(network: Network) { currentNetwork = null + reportedSsid = null eventSink?.success(mapOf("type" to "lost")) } } diff --git a/app/lib/transport/udp/mlrs_network_controller.dart b/app/lib/transport/udp/mlrs_network_controller.dart index 90bd6f3..31f65c5 100644 --- a/app/lib/transport/udp/mlrs_network_controller.dart +++ b/app/lib/transport/udp/mlrs_network_controller.dart @@ -34,6 +34,18 @@ class MlrsNetworkLost extends MlrsNetworkEvent { const MlrsNetworkLost(); } +/// Die SSID des verbundenen Netzes wurde (nachtraeglich) ermittelt. Android +/// liefert `WifiInfo`/SSID bei `WifiNetworkSpecifier`-Netzen haeufig noch +/// nicht synchron im `onAvailable`-Callback, sondern erst kurz danach ueber +/// `NetworkCallback.onCapabilitiesChanged` (bekannter Android-Stolperstein, +/// siehe MlrsNetworkPlugin.kt) - deshalb ein eigenes, spaeter eintreffendes +/// Ereignis statt eines Felds auf [MlrsNetworkAvailable]. +class MlrsNetworkSsidUpdated extends MlrsNetworkEvent { + const MlrsNetworkSsidUpdated(this.ssid); + + final String ssid; +} + /// Ein per [MlrsNetworkController.openSocket] nativ gebundener UDP-Socket /// erfolgreich geoeffnet. [boundPort] ist der tatsaechlich gebundene Port; /// [fallbackFromPort] ist gesetzt, wenn der urspruenglich gewuenschte Port @@ -150,6 +162,7 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController { gatewayAddress: map['gatewayAddress'] as String?, ssid: map['ssid'] as String?, ), + 'ssid' => MlrsNetworkSsidUpdated(map['ssid']! as String), 'lost' => const MlrsNetworkLost(), _ => const MlrsNetworkUnavailable(), }; diff --git a/app/lib/transport/udp/udp_transport.dart b/app/lib/transport/udp/udp_transport.dart index 7fc4e11..93043f1 100644 --- a/app/lib/transport/udp/udp_transport.dart +++ b/app/lib/transport/udp/udp_transport.dart @@ -52,6 +52,7 @@ class UdpTransport implements LinkTransport { final _incomingController = StreamController.broadcast(); final _stateController = StreamController.broadcast(); + final _ssidController = StreamController.broadcast(); StreamSubscription? _packetSub; StreamSubscription? _networkLossSub; @@ -99,6 +100,12 @@ class UdpTransport implements LinkTransport { /// angefragt werden koennen. String? get connectedSsid => _connectedSsid; + /// Reaktive Aenderungen von [connectedSsid] - liefert erneut ein Ereignis, + /// wenn Android die SSID nachtraeglich meldet (siehe [_onNetworkEvent]), + /// da die Zustandsmaschine dafuer kein neues [LinkState] durchlaeuft und + /// die UI sonst nicht mitbekommt, dass sich der Wert geaendert hat. + Stream get connectedSsidChanges => _ssidController.stream; + @override Future connect() async { // Idempotent: mehrere Aufrufer koennen unabhaengig voneinander "sorg @@ -143,6 +150,7 @@ class UdpTransport implements LinkTransport { _networkLossSub = _networkController.events.listen(_onNetworkEvent); _connectedSsid = available.ssid; + _ssidController.add(_connectedSsid); if (available.ssid != null) { await _rememberSsid(available.ssid!); } @@ -196,21 +204,36 @@ class UdpTransport implements LinkTransport { } void _onNetworkEvent(MlrsNetworkEvent event) { - if (event is! MlrsNetworkLost) return; - _networkLossSub?.cancel(); - _networkLossSub = null; - _packetSub?.cancel(); - _packetSub = null; - _boundPort = null; - _connectedSsid = null; - if (_socketOpen) { - _socketOpen = false; - unawaited(_networkController.closeSocket()); + switch (event) { + case MlrsNetworkSsidUpdated(): + // Android liefert WifiInfo/SSID fuer WifiNetworkSpecifier-Netze oft + // erst nach onAvailable() nach (siehe MlrsNetworkPlugin.kt) - hier + // nachtragen und merken, statt auf das schon vergangene erste + // Ereignis in connect() angewiesen zu sein. + _connectedSsid = event.ssid; + _ssidController.add(_connectedSsid); + unawaited(_rememberSsid(event.ssid)); + case MlrsNetworkLost(): + _networkLossSub?.cancel(); + _networkLossSub = null; + _packetSub?.cancel(); + _packetSub = null; + _boundPort = null; + _connectedSsid = null; + _ssidController.add(null); + if (_socketOpen) { + _socketOpen = false; + unawaited(_networkController.closeSocket()); + } + _setError( + LinkErrorReason.networkLost, + 'The mLRS WiFi network was lost.', + ); + case MlrsNetworkAvailable() || MlrsNetworkUnavailable(): + // Wird hier nicht erwartet - diese Ereignisse werden nur waehrend + // des initialen connect() ueber firstEvent ausgewertet (siehe oben). + break; } - _setError( - LinkErrorReason.networkLost, - 'The mLRS WiFi network was lost.', - ); } void _onPacket(MlrsSocketPacket packet) { @@ -227,6 +250,7 @@ class UdpTransport implements LinkTransport { _packetSub = null; _boundPort = null; _connectedSsid = null; + _ssidController.add(null); if (_socketOpen) { _socketOpen = false; await _networkController.closeSocket(); @@ -265,6 +289,7 @@ class UdpTransport implements LinkTransport { _networkController.dispose(); _incomingController.close(); _stateController.close(); + _ssidController.close(); } void _setError(LinkErrorReason reason, String message) { diff --git a/app/lib/ui/providers/wifi_connection_provider.dart b/app/lib/ui/providers/wifi_connection_provider.dart index 24241d0..315b11c 100644 --- a/app/lib/ui/providers/wifi_connection_provider.dart +++ b/app/lib/ui/providers/wifi_connection_provider.dart @@ -35,6 +35,16 @@ final wifiLinkErrorReasonProvider = Provider((ref) { return ref.watch(wifiTransportProvider).lastErrorReason; }); +/// Reaktive tatsaechliche SSID des verbundenen Netzes (Doku: "connected +/// pille soll den WiFi-Namen anzeigen") - eigener Stream statt Ableitung aus +/// [wifiLinkStateProvider], weil Android die SSID fuer WifiNetworkSpecifier- +/// Netze oft erst nachtraeglich liefert (siehe UdpTransport.connectedSsidChanges/ +/// MlrsNetworkPlugin.kt), also ohne begleitenden LinkState-Wechsel. +final wifiConnectedSsidProvider = StreamProvider((ref) { + final transport = ref.watch(wifiTransportProvider); + return transport.connectedSsidChanges; +}); + /// Zeigt an, ob gerade UDP-Pakete ankommen ("receiving"/"not receiving"), /// unabhaengig vom (binaeren) Verbindungszustand - der Socket kann offen /// sein (LinkState.connected), ohne dass die Gegenstelle tatsaechlich @@ -82,6 +92,27 @@ final wifiStreamActivityProvider = NotifierProvider( WifiStreamActivityNotifier.new); +/// Reaktive Gesamtzahl empfangener UDP-Pakete seit Verbindungsaufbau - fuer +/// die "Receiving"-Pille und die Connection-Log-Ueberschrift, die beide bei +/// jedem Paket sofort aktualisiert werden sollen (anders als +/// [WifiConnectionLogNotifier], deren Log-Eintraege nur bei Zustands- +/// aenderungen bzw. neu gelernter Gegenstelle geschrieben werden). +class WifiPacketCountNotifier extends Notifier { + StreamSubscription? _dataSub; + + @override + int build() { + final transport = ref.watch(wifiTransportProvider); + _dataSub?.cancel(); + _dataSub = transport.incoming.listen((_) => state++); + ref.onDispose(() => _dataSub?.cancel()); + return 0; + } +} + +final wifiPacketCountProvider = + NotifierProvider(WifiPacketCountNotifier.new); + /// Ein Eintrag im WLAN-Verbindungsprotokoll (Doku 7). class WifiConnectionLogEntry { const WifiConnectionLogEntry(this.timestamp, this.message); @@ -90,25 +121,22 @@ class WifiConnectionLogEntry { final String message; } -/// Verbindungsprotokoll + Paketzaehler fuer den WLAN-Transport, analog zu -/// [ConnectionLogNotifier] fuer Bluetooth. +/// Verbindungsprotokoll fuer den WLAN-Transport, analog zu +/// [ConnectionLogNotifier] fuer Bluetooth. Der Paketzaehler lebt separat in +/// [wifiPacketCountProvider] (reaktiv pro Paket, hier nur protokolliert). class WifiConnectionLogNotifier extends Notifier> { static const _maxEntries = 50; StreamSubscription? _stateSub; StreamSubscription? _dataSub; - int _packetCount = 0; String? _lastLoggedPeer; - int get packetCount => _packetCount; - @override List build() { final transport = ref.watch(wifiTransportProvider); _stateSub?.cancel(); _dataSub?.cancel(); - _packetCount = 0; _lastLoggedPeer = null; _stateSub = transport.state.listen(_onStateChanged); _dataSub = transport.incoming.listen((_) => _onPacket(transport)); @@ -116,11 +144,18 @@ class WifiConnectionLogNotifier _stateSub?.cancel(); _dataSub?.cancel(); }); + // Telemetrie-Start/-Stopp (ueber denselben 4s-Timeout wie die + // "Receiving"-Pille) als eigene Log-Zeile, statt jedes einzelne Paket zu + // protokollieren - sonst wuerde das Log bei ~7 Paketen/s sofort + // ueberlaufen. + ref.listen(wifiStreamActivityProvider, (previous, isReceiving) { + if (previous == isReceiving) return; + _append(isReceiving ? 'Receiving telemetry' : 'Telemetry stream stopped'); + }); return const []; } void _onPacket(UdpTransport transport) { - _packetCount++; // Gegenstelle wird aus dem ersten eingehenden Paket gelernt (Doku // Kommunikationsschicht v2, Abschnitt 3/9) - nur beim ersten Mal bzw. // bei Aenderung protokollieren, nicht pro Paket. diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 5d7b715..9fce4c7 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -114,6 +116,10 @@ class FlyScreen extends ConsumerWidget { maxDescentRate: droneProfile.maxDescentRate, ); final missionStats = computeMissionStats(waypoints); + final safeEdgeInset = math.max( + MediaQuery.of(context).padding.left, + MediaQuery.of(context).padding.right, + ); final terrainProfile = ref.watch(terrainProvider).profile; final warnings = computeMissionWarnings( waypoints: waypoints, @@ -125,6 +131,28 @@ class FlyScreen extends ConsumerWidget { terrain: terrainProfile, ); + // Vor der ersten Telemetrie (Verbindungsaufbau laeuft noch, oder Mock + // hat noch nicht getickt) auf dem ersten Wegpunkt statt gar nicht + // anzeigen - vermeidet einen leeren/verwirrenden Kartenausschnitt beim + // Eintritt in den Fly-Modus. + final droneMarker = telemetry != null + ? Marker( + point: LatLng(telemetry.lat, telemetry.lon), + width: 28, + height: 28, + alignment: Alignment.center, + child: DroneMarkerIcon(headingDeg: telemetry.headingDeg), + ) + : waypoints.isEmpty + ? null + : Marker( + point: LatLng(waypoints.first.lat, waypoints.first.lon), + width: 28, + height: 28, + alignment: Alignment.center, + child: const DroneMarkerIcon(), + ); + return Stack( children: [ MissionMap( @@ -142,15 +170,21 @@ class FlyScreen extends ConsumerWidget { borderColor: Colors.white, ), ], - droneMarker: telemetry == null - ? null - : Marker( - point: LatLng(telemetry.lat, telemetry.lon), - width: 28, - height: 28, - alignment: Alignment.center, - child: DroneMarkerIcon(headingDeg: telemetry.headingDeg), - ), + droneMarker: droneMarker, + ), + // Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator: + // #altReadout/#spdReadout im Fly-Modus) - anders als dort keine + // SET-Zeile, weil es im Waypoint-Fly-Modus kein editierbares + // Rad-Widget (curAlt/curSpeed) gibt, dessen Wert sinnvoll waere. + Positioned( + top: 56, + left: 14 + safeEdgeInset, + child: _liveReadout('Alt', telemetry?.altitudeM, 'm'), + ), + Positioned( + top: 56, + right: 14 + safeEdgeInset, + child: _liveReadout('Speed', telemetry?.speedMs, 'm/s'), ), Positioned( left: 0, @@ -174,4 +208,22 @@ class FlyScreen extends ConsumerWidget { ], ); } + + Widget _liveReadout(String label, double? value, String unit) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '$label: ${value != null ? '${value.round()} $unit' : '—'}', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ); + } } diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index b437227..f476117 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../transport/bluetooth/bluetooth_device_info.dart'; import '../../../transport/bluetooth/bluetooth_system_settings.dart'; import '../../../transport/connection_type.dart'; +import '../../../transport/flight_controller_link.dart'; import '../../../transport/link_transport.dart'; import '../../dmc_colors.dart'; import '../../providers/bluetooth_connection_provider.dart'; import '../../providers/connection_settings_provider.dart'; import '../../providers/database_provider.dart'; +import '../../providers/telemetry_provider.dart'; import '../../providers/wifi_connection_provider.dart'; /// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier @@ -96,7 +97,7 @@ class _ConnectionSection extends ConsumerWidget { /// ([_ConnectionSection]), damit die Beschriftungen an beiden Stellen /// gleich lauten. const _connectionTypeItems = [ - DropdownMenuItem(value: ConnectionType.wifi, child: Text('WiFi')), + DropdownMenuItem(value: ConnectionType.wifi, child: Text('WiFi mLRS UDP')), DropdownMenuItem(value: ConnectionType.cellular5g, child: Text('5G (soon)')), DropdownMenuItem(value: ConnectionType.bluetooth, child: Text('Bluetooth')), DropdownMenuItem(value: ConnectionType.usb, child: Text('USB (soon)')), @@ -116,54 +117,34 @@ class _NotAvailablePanel extends StatelessWidget { } } -class _WifiPanel extends ConsumerStatefulWidget { +/// SSID-Praefix und Port werden bewusst nicht mehr als Textfelder angeboten +/// (Doku: "Preset WiFi mLRS UDP, alles automatisch setzen") - die +/// gespeicherten Defaults ("mLRS-"/14550, siehe AppDatabase) passen fuer die +/// mLRS-WLAN-Bruecke immer, ein manuelles Ueberschreiben war in der Praxis +/// nur eine Fehlerquelle beim Verbindungsaufbau. +class _WifiPanel extends ConsumerWidget { const _WifiPanel(); @override - ConsumerState<_WifiPanel> createState() => _WifiPanelState(); -} - -class _WifiPanelState extends ConsumerState<_WifiPanel> { - late final TextEditingController _ssidPrefixController; - late final TextEditingController _portController; - bool _controllersSeeded = false; - - @override - void initState() { - super.initState(); - _ssidPrefixController = TextEditingController(); - _portController = TextEditingController(); - } - - @override - void dispose() { - _ssidPrefixController.dispose(); - _portController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final linkState = ref.watch(wifiLinkStateProvider).value; final errorReason = ref.watch(wifiLinkErrorReasonProvider); final isReceiving = ref.watch(wifiStreamActivityProvider); final connectionSettings = ref.watch(connectionSettingsProvider).value; final log = ref.watch(wifiConnectionLogProvider); - final packetCount = ref.watch(wifiConnectionLogProvider.notifier).packetCount; - // Kein eigener Reaktivitaets-Provider fuer connectedSsid noetig - der - // Rebuild wird bereits durch das Beobachten von wifiLinkStateProvider - // oben ausgeloest, und das Feld wird synchron gesetzt, bevor der - // Transport auf LinkState.connected wechselt (siehe UdpTransport.connect). - final connectedSsid = ref.read(wifiTransportProvider).connectedSsid; + final packetCount = ref.watch(wifiPacketCountProvider); + final connectedSsid = ref.watch(wifiConnectedSsidProvider).value; - // Controller einmalig aus den gespeicherten Einstellungen befuellen - - // nicht bei jedem Rebuild, sonst wird waehrend der Nutzereingabe - // ueberschrieben. - if (!_controllersSeeded && connectionSettings != null) { - _controllersSeeded = true; - _ssidPrefixController.text = connectionSettings.connectionWifiSsidPrefix; - _portController.text = connectionSettings.connectionWifiPort.toString(); - } + // MSP-Telemetrie nur beobachten, wenn der Transport bereits verbunden + // ist - sonst wuerde allein das Oeffnen dieses Screens ueber + // telemetryProvider einen neuen Verbindungsversuch (samt Systemdialog) + // auslösen. Ist bereits eine echte Verbindung aktiv (z.B. weil der + // Fly-Modus sie aufgebaut hat), liefert dies denselben Provider/dieselbe + // MSP-Client-Instanz wie FlyScreen - keine zweite, konkurrierende + // Abfrage-Schleife. + final telemetry = linkState == LinkState.connected + ? ref.watch(telemetryProvider).value + : null; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -171,7 +152,7 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> { Row( children: [ SizedBox( - width: 130, + width: 260, child: DropdownButtonFormField( initialValue: ConnectionType.wifi, decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), @@ -190,7 +171,7 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> { onDisconnect: () => ref.read(wifiTransportProvider).disconnect(), ), const Spacer(), - _StreamActivityPill(isReceiving: isReceiving), + _StreamActivityPill(isReceiving: isReceiving, packetCount: packetCount), const SizedBox(width: 6), _StatePill(state: linkState, connectedLabel: connectedSsid), ], @@ -200,40 +181,6 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> { _WifiErrorHint(reason: errorReason), ], const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: TextField( - controller: _ssidPrefixController, - decoration: const InputDecoration( - labelText: 'SSID prefix', - isDense: true, - border: OutlineInputBorder(), - ), - onSubmitted: (value) => _saveSsidPrefix(value), - onEditingComplete: () => - _saveSsidPrefix(_ssidPrefixController.text), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _portController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - labelText: 'Port', - isDense: true, - border: OutlineInputBorder(), - ), - onSubmitted: (value) => _savePort(value), - onEditingComplete: () => _savePort(_portController.text), - ), - ), - ], - ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Auto-connect when entering Fly mode'), @@ -250,6 +197,10 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> { }, ), const SizedBox(height: 8), + Text('Telemetry', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + _TelemetryFields(telemetry: telemetry), + const SizedBox(height: 16), Text( 'Connection log ($packetCount packets received)', style: Theme.of(context).textTheme.titleSmall, @@ -278,22 +229,6 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> { ); } - Future _saveSsidPrefix(String value) async { - final trimmed = value.trim(); - if (trimmed.isEmpty) return; - await ref - .read(appSettingsRepositoryProvider) - .setConnectionWifiSsidPrefix(trimmed); - ref.invalidate(connectionSettingsProvider); - } - - Future _savePort(String value) async { - final port = int.tryParse(value); - if (port == null || port <= 0 || port > 65535) return; - await ref.read(appSettingsRepositoryProvider).setConnectionWifiPort(port); - ref.invalidate(connectionSettingsProvider); - } - Future _connect(WidgetRef ref) async { try { await ref.read(wifiTransportProvider).connect(); @@ -307,6 +242,46 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> { '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; } +/// Empfangene, ueber MSP dekodierte Telemetriefelder (Doku: "vor dem +/// Connection log die empfangenen Telemetriefelder anzeigen") - zum +/// schnellen Pruefen auf echter Hardware, ohne in den Fly-Modus wechseln zu +/// muessen. +class _TelemetryFields extends StatelessWidget { + const _TelemetryFields({required this.telemetry}); + + final TelemetryFrame? telemetry; + + @override + Widget build(BuildContext context) { + final t = telemetry; + if (t == null) { + return const Text( + 'No telemetry yet.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ); + } + return Wrap( + spacing: 16, + runSpacing: 4, + children: [ + _field('Lat', t.lat.toStringAsFixed(6)), + _field('Lon', t.lon.toStringAsFixed(6)), + _field('Alt', '${t.altitudeM.toStringAsFixed(1)} m'), + _field('Speed', '${t.speedMs.toStringAsFixed(1)} m/s'), + _field('Heading', '${t.headingDeg.toStringAsFixed(0)}°'), + _field('Armed', t.armed ? 'Yes' : 'No'), + if (t.activeWaypointIndex != null) + _field('Active WP', '${t.activeWaypointIndex}'), + ], + ); + } + + Widget _field(String label, String value) => Text( + '$label: $value', + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + ); +} + /// Runder Connect/Disconnect-Umschalter fuer die kompakte erste Zeile des /// WLAN-Panels (Doku: "kompakter gestalten") - ein Knopf statt zwei, der je /// nach [state] Beschriftung und Aktion wechselt. @@ -325,9 +300,16 @@ class _ConnectToggleButton extends StatelessWidget { Widget build(BuildContext context) { final isActive = state == LinkState.connected || state == LinkState.connecting; - return isActive - ? OutlinedButton(onPressed: onDisconnect, child: const Text('Disconnect')) - : ElevatedButton(onPressed: onConnect, child: const Text('Connect')); + final color = isActive ? DmcColors.warnRed : DmcColors.liveGreen; + return OutlinedButton( + onPressed: isActive ? onDisconnect : onConnect, + style: OutlinedButton.styleFrom( + shape: const StadiumBorder(), + side: BorderSide(color: color), + foregroundColor: color, + ), + child: Text(isActive ? 'Disconnect' : 'Connect'), + ); } } @@ -520,13 +502,19 @@ class _BluetoothPanel extends ConsumerWidget { /// unabhaengig vom Verbindungszustand, der Socket kann offen sein, ohne /// dass die Gegenstelle sendet (siehe wifiStreamActivityProvider). class _StreamActivityPill extends StatelessWidget { - const _StreamActivityPill({required this.isReceiving}); + const _StreamActivityPill({required this.isReceiving, required this.packetCount}); final bool isReceiving; + /// Gesamtzahl empfangener Pakete (Doku: "Receiving-Pille soll empfangene + /// Pakete anzeigen und automatisch aktualisieren") - reaktiv ueber + /// wifiPacketCountProvider, aktualisiert sich also mit jedem Paket. + final int packetCount; + @override Widget build(BuildContext context) { final color = isReceiving ? DmcColors.liveGreen : Colors.grey; + final label = isReceiving ? 'Receiving ($packetCount)' : 'Not receiving'; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( @@ -534,10 +522,7 @@ class _StreamActivityPill extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: color), ), - child: Text( - isReceiving ? 'Receiving' : 'Not receiving', - style: TextStyle(color: color, fontSize: 12), - ), + child: Text(label, style: TextStyle(color: color, fontSize: 12)), ); } } @@ -547,15 +532,18 @@ class _StatePill extends StatelessWidget { final LinkState? state; - /// Ueberschreibt die Beschriftung im verbundenen Zustand - z.B. der - /// WLAN-Netzname statt des generischen "Connected" (Doku: "die connected - /// pill soll den WiFi-Namen anzeigen"). + /// WLAN-Netzname, falls bekannt - erscheint im verbundenen Zustand als + /// "Connected to {name}" statt des generischen "Connected" (Doku: "die + /// connected pille soll den WiFi-Namen anzeigen"). final String? connectedLabel; @override Widget build(BuildContext context) { final (label, color) = switch (state) { - LinkState.connected => (connectedLabel ?? 'Connected', DmcColors.liveGreen), + LinkState.connected => ( + connectedLabel != null ? 'Connected to $connectedLabel' : 'Connected', + DmcColors.liveGreen, + ), LinkState.connecting => ('Connecting…', DmcColors.pafAmber), LinkState.error => ('Error', DmcColors.warnRed), LinkState.disconnected || null => ('Disconnected', Colors.grey), diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index 9691cac..30f352b 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -62,7 +62,7 @@ class TopModeBar extends ConsumerWidget { right: safeEdgeInset, ), child: FractionallySizedBox( - widthFactor: 0.70, + widthFactor: 0.77, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -87,8 +87,6 @@ class TopModeBar extends ConsumerWidget { const SizedBox(width: 8), const HeaderWindPill(), const SizedBox(width: 8), - const _WifiConnectionProgressPill(), - const SizedBox(width: 8), const Expanded(child: FlightModePill()), const SizedBox(width: 8), const FlyMapControls(), @@ -151,58 +149,6 @@ Future _maybeAutoConnectWifi(WidgetRef ref) async { } } -/// Kompakte Fortschrittsanzeige fuer den automatischen WLAN-Verbindungsaufbau -/// (siehe [_maybeAutoConnectWifi]) - nur sichtbar, solange die Verbindungsart -/// WLAN ist und gerade etwas passiert bzw. ein Fehler vorliegt. Bleibt im -/// Ruhezustand (disconnected, kein vorheriger Fehler) unsichtbar, damit die -/// Kopfleiste nicht dauerhaft mit einer Statuspille zugestellt ist. -class _WifiConnectionProgressPill extends ConsumerWidget { - const _WifiConnectionProgressPill(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final connectionType = ref.watch(connectionSettingsProvider).value?.connectionType; - final linkState = ref.watch(wifiLinkStateProvider).value; - if (connectionType != ConnectionType.wifi) return const SizedBox.shrink(); - if (linkState == null || linkState == LinkState.disconnected) { - return const SizedBox.shrink(); - } - - final (label, color) = switch (linkState) { - LinkState.connecting => ('Connecting…', DmcColors.pafAmber), - LinkState.connected => ('WiFi connected', DmcColors.liveGreen), - LinkState.error => ('WiFi error', DmcColors.warnRed), - LinkState.disconnected => ('', Colors.grey), - }; - - return Padding( - padding: const EdgeInsets.only(right: 8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (linkState == LinkState.connecting) ...[ - SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator(strokeWidth: 1.5, color: color), - ), - const SizedBox(width: 6), - ], - Text(label, style: TextStyle(color: color, fontSize: 11)), - ], - ), - ), - ); - } -} - class _ModeButton extends StatelessWidget { const _ModeButton({ required this.label,