UI collapsable height profile, english settings menue
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,6 +61,7 @@ class MissionFooterBar extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (isExpanded)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
||||||
child: MiniAltitudeProfile(
|
child: MiniAltitudeProfile(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user