UI collapsable height profile, english settings menue

This commit is contained in:
Constantin Leue
2026-07-31 09:49:28 +02:00
parent 8cc9a51a34
commit cb91436fe4
12 changed files with 157 additions and 29 deletions
+3 -3
View File
@@ -64,10 +64,10 @@ class AppSettingsTable extends Table {
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"), /// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit /// gespeichert als [ConnectionType.name]. App-Default ist WLAN; die
/// bestehende Installationen ihr bisheriges Verhalten behalten. /// Nutzerauswahl wird danach dauerhaft gemerkt.
TextColumn get connectionType => TextColumn get connectionType =>
text().withDefault(const Constant('bluetooth'))(); text().withDefault(const Constant('wifi'))();
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3: /// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
/// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe /// "Zieladresse niemals fest eincodieren") - null, solange keine Vorgabe
@@ -33,7 +33,7 @@ class AppSettingsRepository {
connectionAutoConnect: row?.connectionAutoConnect ?? true, connectionAutoConnect: row?.connectionAutoConnect ?? true,
connectionType: ConnectionType.values.firstWhere( connectionType: ConnectionType.values.firstWhere(
(t) => t.name == row?.connectionType, (t) => t.name == row?.connectionType,
orElse: () => ConnectionType.bluetooth, orElse: () => ConnectionType.wifi,
), ),
connectionWifiHost: row?.connectionWifiHost, connectionWifiHost: row?.connectionWifiHost,
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort, connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
+1 -1
View File
@@ -91,7 +91,7 @@ class UdpTransport implements LinkTransport {
// die Verbindung abzubrechen. // die Verbindung abzubrechen.
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
_lastErrorMessage = _lastErrorMessage =
'Port $configuredPort belegt, ausgewichen auf ${socket.port}.'; 'Port $configuredPort in use, fell back to ${socket.port}.';
} }
_socket = socket; _socket = socket;
_boundPort = socket.port; _boundPort = socket.port;
@@ -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<bool> {
@override
bool build() => false;
void toggle() => state = !state;
}
final altitudeProfileExpandedProvider =
NotifierProvider<AltitudeProfileExpandedNotifier, bool>(
AltitudeProfileExpandedNotifier.new);
@@ -86,12 +86,12 @@ class ConnectionLogNotifier extends Notifier<List<ConnectionLogEntry>> {
void _onStateChanged(LinkState linkState) { void _onStateChanged(LinkState linkState) {
final transport = ref.read(bluetoothTransportProvider); final transport = ref.read(bluetoothTransportProvider);
final message = switch (linkState) { final message = switch (linkState) {
LinkState.disconnected => 'Getrennt', LinkState.disconnected => 'Disconnected',
LinkState.connecting => 'Verbinde ...', LinkState.connecting => 'Connecting...',
LinkState.connected => LinkState.connected =>
'Verbunden${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}', 'Connected${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}',
LinkState.error => LinkState.error =>
'Fehler: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}', 'Error: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}',
}; };
_append(message); _append(message);
} }
@@ -70,11 +70,11 @@ class WifiConnectionLogNotifier
void _onStateChanged(LinkState linkState) { void _onStateChanged(LinkState linkState) {
final transport = ref.read(wifiTransportProvider); final transport = ref.read(wifiTransportProvider);
final message = switch (linkState) { final message = switch (linkState) {
LinkState.disconnected => 'Getrennt', LinkState.disconnected => 'Disconnected',
LinkState.connecting => 'Verbinde ...', LinkState.connecting => 'Connecting...',
LinkState.connected => 'Lausche auf Port ${transport.boundPort}', LinkState.connected => 'Listening on port ${transport.boundPort}',
LinkState.error => 'Fehler: ' LinkState.error => 'Error: '
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}', '${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}',
}; };
_append(message); _append(message);
} }
@@ -45,7 +45,7 @@ class _ConnectionSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final connectionSettings = ref.watch(connectionSettingsProvider).value; final connectionSettings = ref.watch(connectionSettingsProvider).value;
final selectedType = connectionSettings?.connectionType ?? ConnectionType.bluetooth; final selectedType = connectionSettings?.connectionType ?? ConnectionType.wifi;
return Card( return Card(
child: Padding( child: Padding(
+49 -1
View File
@@ -5,15 +5,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/mission_warnings.dart'; import '../../domain/mission/mission_warnings.dart';
import '../providers/active_drone_profile_provider.dart'; import '../providers/active_drone_profile_provider.dart';
import '../providers/altitude_profile_provider.dart';
import '../providers/mission_meta_provider.dart'; import '../providers/mission_meta_provider.dart';
import 'missions_drones_panel.dart'; import 'missions_drones_panel.dart';
import 'mountain_peak_icon.dart';
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/ /// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
/// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste; /// #statsRow). Details-Pille bleibt mittig und oeffnet die Wegpunktliste;
/// Mission-Chip links und Drohnen-Chip rechts oeffnen das Verwaltungsmenue /// Mission-Chip links und Drohnen-Chip rechts oeffnen das Verwaltungsmenue
/// mit dem passenden Starttab (HTML-Demonstrator: #missionNameBtn/ /// mit dem passenden Starttab (HTML-Demonstrator: #missionNameBtn/
/// #droneNameBtn). Warnungs- und Settings-Knopf sitzen neben dem /// #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 /// Der Send-Button ist bewusst nicht Teil dieses Schritts - die dafuer
/// noetige Upload-Anbindung existiert noch nicht. /// noetige Upload-Anbindung existiert noch nicht.
class BottomStatsBar extends ConsumerWidget { class BottomStatsBar extends ConsumerWidget {
@@ -47,6 +50,7 @@ class BottomStatsBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final missionName = ref.watch(currentMissionMetaProvider).name; final missionName = ref.watch(currentMissionMetaProvider).name;
final droneName = ref.watch(activeDroneProfileProvider).name; final droneName = ref.watch(activeDroneProfileProvider).name;
final isAltitudeProfileExpanded = ref.watch(altitudeProfileExpandedProvider);
// Gespiegelter Randabstand (wie in PlanScreen fuer die Drehraeder, Doku: // Gespiegelter Randabstand (wie in PlanScreen fuer die Drehraeder, Doku:
// "Symmetrischer Randabstand auf beiden Seiten"): die groessere der // "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(), _detailsPill(),
// Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille // Drohnen-Pille + Warnungs-/Settings-Knopf rechts. Die Pille
// sitzt in Flexible, damit sie bei langen Namen zu Gunsten der // 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- /// Kleiner, roter Rundknopf fuer aktive Warnungen (Doku 3.10, HTML-
/// Demonstrator: #warningsBtn) - oeffnet die Warnungsliste. /// Demonstrator: #warningsBtn) - oeffnet die Warnungsliste.
class _WarningButton extends StatelessWidget { class _WarningButton extends StatelessWidget {
+15 -10
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/mission_warnings.dart'; import '../../domain/mission/mission_warnings.dart';
import '../../domain/waypoint/flat_waypoint_list.dart'; import '../../domain/waypoint/flat_waypoint_list.dart';
import '../providers/altitude_profile_provider.dart';
import 'bottom_stats_bar.dart'; import 'bottom_stats_bar.dart';
import 'mini_altitude_profile.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) - /// BottomStatsBar), geteilt zwischen Plan- und Fly-Modus (Doku 3.9/3.10) -
/// beide zeigen dieselbe Missionsuebersicht, nur Reticle/Draeder gibt es /// beide zeigen dieselbe Missionsuebersicht, nur Reticle/Draeder gibt es
/// ausschliesslich im Plan-Modus. /// ausschliesslich im Plan-Modus.
class MissionFooterBar extends StatelessWidget { class MissionFooterBar extends ConsumerWidget {
const MissionFooterBar({ const MissionFooterBar({
super.key, super.key,
required this.waypoints, required this.waypoints,
@@ -36,7 +38,9 @@ class MissionFooterBar extends StatelessWidget {
final VoidCallback onSettingsTap; final VoidCallback onSettingsTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = ref.watch(altitudeProfileExpandedProvider);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -57,15 +61,16 @@ class MissionFooterBar extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Padding( if (isExpanded)
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), Padding(
child: MiniAltitudeProfile( padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
waypoints: waypoints, child: MiniAltitudeProfile(
legClimbBad: legClimbBad, waypoints: waypoints,
vertexBad: vertexBad, legClimbBad: legClimbBad,
activeIndex: activeIndex, vertexBad: vertexBad,
activeIndex: activeIndex,
),
), ),
),
BottomStatsBar( BottomStatsBar(
waypointCount: waypoints.length, waypointCount: waypoints.length,
totalDistanceM: totalDistanceM, totalDistanceM: totalDistanceM,
@@ -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;
}
@@ -77,9 +77,9 @@ void main() {
expect(settings.connectionAutoConnect, isFalse); 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(); final settings = await repository.load();
expect(settings.connectionType, ConnectionType.bluetooth); expect(settings.connectionType, ConnectionType.wifi);
expect(settings.connectionWifiHost, isNull); expect(settings.connectionWifiHost, isNull);
expect(settings.connectionWifiPort, 14550); expect(settings.connectionWifiPort, 14550);
}); });
+1 -1
View File
@@ -122,7 +122,7 @@ void main() {
expect(transport.currentState, LinkState.connected); expect(transport.currentState, LinkState.connected);
expect(transport.boundPort, isNot(occupied.port)); expect(transport.boundPort, isNot(occupied.port));
expect(transport.lastErrorMessage, contains('belegt')); expect(transport.lastErrorMessage, contains('in use'));
occupied.close(); occupied.close();
transport.dispose(); transport.dispose();