From cb91436fe4a7d78a7a2ea15006ef37fdd4e39fce Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:49:28 +0200 Subject: [PATCH] UI collapsable height profile, english settings menue --- app/lib/services/database/app_database.dart | 6 +- .../database/app_settings_repository.dart | 2 +- app/lib/transport/udp/udp_transport.dart | 2 +- .../providers/altitude_profile_provider.dart | 16 +++++ .../bluetooth_connection_provider.dart | 8 +-- .../providers/wifi_connection_provider.dart | 10 ++-- .../ui/screens/settings/settings_screen.dart | 2 +- app/lib/ui/widgets/bottom_stats_bar.dart | 50 +++++++++++++++- app/lib/ui/widgets/mission_footer_bar.dart | 25 ++++---- app/lib/ui/widgets/mountain_peak_icon.dart | 59 +++++++++++++++++++ .../app_settings_repository_test.dart | 4 +- app/test/transport/udp_transport_test.dart | 2 +- 12 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 app/lib/ui/providers/altitude_profile_provider.dart create mode 100644 app/lib/ui/widgets/mountain_peak_icon.dart diff --git a/app/lib/services/database/app_database.dart b/app/lib/services/database/app_database.dart index 2d55b87..e9de55d 100644 --- a/app/lib/services/database/app_database.dart +++ b/app/lib/services/database/app_database.dart @@ -64,10 +64,10 @@ class AppSettingsTable extends Table { boolean().withDefault(const Constant(true))(); /// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"), - /// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit - /// bestehende Installationen ihr bisheriges Verhalten behalten. + /// gespeichert als [ConnectionType.name]. App-Default ist WLAN; die + /// Nutzerauswahl wird danach dauerhaft gemerkt. TextColumn get connectionType => - text().withDefault(const Constant('bluetooth'))(); + text().withDefault(const Constant('wifi'))(); /// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3: /// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe diff --git a/app/lib/services/database/app_settings_repository.dart b/app/lib/services/database/app_settings_repository.dart index 1be8427..d03638d 100644 --- a/app/lib/services/database/app_settings_repository.dart +++ b/app/lib/services/database/app_settings_repository.dart @@ -33,7 +33,7 @@ class AppSettingsRepository { connectionAutoConnect: row?.connectionAutoConnect ?? true, connectionType: ConnectionType.values.firstWhere( (t) => t.name == row?.connectionType, - orElse: () => ConnectionType.bluetooth, + orElse: () => ConnectionType.wifi, ), connectionWifiHost: row?.connectionWifiHost, connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort, diff --git a/app/lib/transport/udp/udp_transport.dart b/app/lib/transport/udp/udp_transport.dart index d014134..d54c6d4 100644 --- a/app/lib/transport/udp/udp_transport.dart +++ b/app/lib/transport/udp/udp_transport.dart @@ -91,7 +91,7 @@ class UdpTransport implements LinkTransport { // die Verbindung abzubrechen. socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); _lastErrorMessage = - 'Port $configuredPort belegt, ausgewichen auf ${socket.port}.'; + 'Port $configuredPort in use, fell back to ${socket.port}.'; } _socket = socket; _boundPort = socket.port; diff --git a/app/lib/ui/providers/altitude_profile_provider.dart b/app/lib/ui/providers/altitude_profile_provider.dart new file mode 100644 index 0000000..af9b9af --- /dev/null +++ b/app/lib/ui/providers/altitude_profile_provider.dart @@ -0,0 +1,16 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Ein-/Ausgeklappt-Zustand des Mini-Hoehenprofils in der Fusszeile, +/// geteilt zwischen Plan- und Fly-Modus (MissionFooterBar). Rein +/// UI-lokaler Zustand fuer die aktuelle Session, Standard: eingeklappt, +/// damit die Karte moeglichst viel visuellen Raum bekommt. +class AltitudeProfileExpandedNotifier extends Notifier { + @override + bool build() => false; + + void toggle() => state = !state; +} + +final altitudeProfileExpandedProvider = + NotifierProvider( + AltitudeProfileExpandedNotifier.new); diff --git a/app/lib/ui/providers/bluetooth_connection_provider.dart b/app/lib/ui/providers/bluetooth_connection_provider.dart index 1e35c0a..62336ca 100644 --- a/app/lib/ui/providers/bluetooth_connection_provider.dart +++ b/app/lib/ui/providers/bluetooth_connection_provider.dart @@ -86,12 +86,12 @@ class ConnectionLogNotifier extends Notifier> { void _onStateChanged(LinkState linkState) { final transport = ref.read(bluetoothTransportProvider); final message = switch (linkState) { - LinkState.disconnected => 'Getrennt', - LinkState.connecting => 'Verbinde ...', + LinkState.disconnected => 'Disconnected', + LinkState.connecting => 'Connecting...', LinkState.connected => - 'Verbunden${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}', + 'Connected${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}', LinkState.error => - 'Fehler: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}', + 'Error: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}', }; _append(message); } diff --git a/app/lib/ui/providers/wifi_connection_provider.dart b/app/lib/ui/providers/wifi_connection_provider.dart index 867ca17..1845318 100644 --- a/app/lib/ui/providers/wifi_connection_provider.dart +++ b/app/lib/ui/providers/wifi_connection_provider.dart @@ -70,11 +70,11 @@ class WifiConnectionLogNotifier void _onStateChanged(LinkState linkState) { final transport = ref.read(wifiTransportProvider); final message = switch (linkState) { - LinkState.disconnected => 'Getrennt', - LinkState.connecting => 'Verbinde ...', - LinkState.connected => 'Lausche auf Port ${transport.boundPort}', - LinkState.error => 'Fehler: ' - '${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}', + LinkState.disconnected => 'Disconnected', + LinkState.connecting => 'Connecting...', + LinkState.connected => 'Listening on port ${transport.boundPort}', + LinkState.error => 'Error: ' + '${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}', }; _append(message); } diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index fa2b472..94c3c23 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -45,7 +45,7 @@ class _ConnectionSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final connectionSettings = ref.watch(connectionSettingsProvider).value; - final selectedType = connectionSettings?.connectionType ?? ConnectionType.bluetooth; + final selectedType = connectionSettings?.connectionType ?? ConnectionType.wifi; return Card( child: Padding( diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index 113c0f0..53171aa 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -5,15 +5,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/mission/mission_warnings.dart'; import '../providers/active_drone_profile_provider.dart'; +import '../providers/altitude_profile_provider.dart'; import '../providers/mission_meta_provider.dart'; import 'missions_drones_panel.dart'; +import 'mountain_peak_icon.dart'; /// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/ /// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste; /// Mission-Chip links und Drohnen-Chip rechts oeffnen das Verwaltungsmenue /// mit dem passenden Starttab (HTML-Demonstrator: #missionNameBtn/ /// #droneNameBtn). Warnungs- und Settings-Knopf sitzen neben dem -/// Drohnen-Chip rechts aussen. +/// Drohnen-Chip rechts aussen. Der Hoehenprofil-Toggle (Bergkuppen-Icon) +/// sitzt links der Details-Pille. /// Der Send-Button ist bewusst nicht Teil dieses Schritts - die dafuer /// noetige Upload-Anbindung existiert noch nicht. class BottomStatsBar extends ConsumerWidget { @@ -47,6 +50,7 @@ class BottomStatsBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final missionName = ref.watch(currentMissionMetaProvider).name; final droneName = ref.watch(activeDroneProfileProvider).name; + final isAltitudeProfileExpanded = ref.watch(altitudeProfileExpandedProvider); // Gespiegelter Randabstand (wie in PlanScreen fuer die Drehraeder, Doku: // "Symmetrischer Randabstand auf beiden Seiten"): die groessere der @@ -98,6 +102,13 @@ class BottomStatsBar extends ConsumerWidget { ), ), ), + _AltitudeProfileToggleButton( + isExpanded: isAltitudeProfileExpanded, + onTap: () => ref + .read(altitudeProfileExpandedProvider.notifier) + .toggle(), + ), + const SizedBox(width: 6), _detailsPill(), // Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille // sitzt in Flexible, damit sie bei langen Namen zu Gunsten der @@ -184,6 +195,43 @@ class BottomStatsBar extends ConsumerWidget { } } +/// Runder Knopf links der Details-Pille zum Ein-/Ausklappen des +/// Mini-Hoehenprofils. Zeigt eine stilisierte Bergkuppe: ausgeklappt steht +/// sie kopfueber (Zacken zeigen nach unten - signalisiert "einklappen"), +/// eingeklappt (Standard, um moeglichst viel Kartenflaeche frei zu lassen) +/// richtig herum (Zacken zeigen nach oben - signalisiert "ausklappen"). +class _AltitudeProfileToggleButton extends StatelessWidget { + const _AltitudeProfileToggleButton({ + required this.isExpanded, + required this.onTap, + }); + + final bool isExpanded; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.12), + ), + child: Center( + child: MountainPeakIcon( + size: 16, + color: Colors.white, + upsideDown: isExpanded, + ), + ), + ), + ); + } +} + /// Kleiner, roter Rundknopf fuer aktive Warnungen (Doku 3.10, HTML- /// Demonstrator: #warningsBtn) - oeffnet die Warnungsliste. class _WarningButton extends StatelessWidget { diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index 6fab836..d8c6ab2 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/mission/mission_warnings.dart'; import '../../domain/waypoint/flat_waypoint_list.dart'; +import '../providers/altitude_profile_provider.dart'; import 'bottom_stats_bar.dart'; import 'mini_altitude_profile.dart'; @@ -9,7 +11,7 @@ import 'mini_altitude_profile.dart'; /// BottomStatsBar), geteilt zwischen Plan- und Fly-Modus (Doku 3.9/3.10) - /// beide zeigen dieselbe Missionsuebersicht, nur Reticle/Draeder gibt es /// ausschliesslich im Plan-Modus. -class MissionFooterBar extends StatelessWidget { +class MissionFooterBar extends ConsumerWidget { const MissionFooterBar({ super.key, required this.waypoints, @@ -36,7 +38,9 @@ class MissionFooterBar extends StatelessWidget { final VoidCallback onSettingsTap; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final isExpanded = ref.watch(altitudeProfileExpandedProvider); + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -57,15 +61,16 @@ class MissionFooterBar extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), - child: MiniAltitudeProfile( - waypoints: waypoints, - legClimbBad: legClimbBad, - vertexBad: vertexBad, - activeIndex: activeIndex, + if (isExpanded) + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: MiniAltitudeProfile( + waypoints: waypoints, + legClimbBad: legClimbBad, + vertexBad: vertexBad, + activeIndex: activeIndex, + ), ), - ), BottomStatsBar( waypointCount: waypoints.length, totalDistanceM: totalDistanceM, diff --git a/app/lib/ui/widgets/mountain_peak_icon.dart b/app/lib/ui/widgets/mountain_peak_icon.dart new file mode 100644 index 0000000..0aed22a --- /dev/null +++ b/app/lib/ui/widgets/mountain_peak_icon.dart @@ -0,0 +1,59 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Stilisiertes Bergkuppe-Icon mit zwei Zacken, fuer den +/// Hoehenprofil-Toggle-Knopf in der Fusszeile (siehe +/// [MountainPeakToggleButton] in mission_footer_bar.dart). Um 180 Grad +/// gedreht zeigen die Zacken nach unten - das signalisiert "einklappen", +/// waehrend die aufrechte Ausrichtung "ausklappen" signalisiert. +class MountainPeakIcon extends StatelessWidget { + const MountainPeakIcon({ + super.key, + this.size = 16, + this.color = Colors.white, + this.upsideDown = false, + }); + + final double size; + final Color color; + final bool upsideDown; + + @override + Widget build(BuildContext context) { + return Transform.rotate( + angle: upsideDown ? math.pi : 0, + child: CustomPaint( + size: Size.square(size), + painter: _MountainPeakPainter(color: color), + ), + ); + } +} + +class _MountainPeakPainter extends CustomPainter { + const _MountainPeakPainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + // Zwei Zacken: hoher Hauptgipfel links, niedrigerer Nebengipfel rechts, + // mit einer Senke dazwischen - Silhouette bleibt in beiden Drehlagen + // (aufrecht/kopfueber) als Bergkuppe erkennbar. + final path = Path() + ..moveTo(0, h) + ..lineTo(w * 0.36, h * 0.1) + ..lineTo(w * 0.55, h * 0.45) + ..lineTo(w * 0.74, h * 0.24) + ..lineTo(w, h) + ..close(); + canvas.drawPath(path, Paint()..color = color); + } + + @override + bool shouldRepaint(covariant _MountainPeakPainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/app/test/services/app_settings_repository_test.dart b/app/test/services/app_settings_repository_test.dart index 70de074..58ba88d 100644 --- a/app/test/services/app_settings_repository_test.dart +++ b/app/test/services/app_settings_repository_test.dart @@ -77,9 +77,9 @@ void main() { expect(settings.connectionAutoConnect, isFalse); }); - test('Verbindungsart: Standard ist Bluetooth, Port-Vorgabe 14550', () async { + test('Verbindungsart: Standard ist WiFi, Port-Vorgabe 14550', () async { final settings = await repository.load(); - expect(settings.connectionType, ConnectionType.bluetooth); + expect(settings.connectionType, ConnectionType.wifi); expect(settings.connectionWifiHost, isNull); expect(settings.connectionWifiPort, 14550); }); diff --git a/app/test/transport/udp_transport_test.dart b/app/test/transport/udp_transport_test.dart index 8c2b3bc..ef6d190 100644 --- a/app/test/transport/udp_transport_test.dart +++ b/app/test/transport/udp_transport_test.dart @@ -122,7 +122,7 @@ void main() { expect(transport.currentState, LinkState.connected); expect(transport.boundPort, isNot(occupied.port)); - expect(transport.lastErrorMessage, contains('belegt')); + expect(transport.lastErrorMessage, contains('in use')); occupied.close(); transport.dispose();