Portiert das Wind-System des HTML-Demonstrators (Doku 3.8) vollstaendig: Open-Meteo-Winddaten, interpoliert zwischen Druckflaechen- und festen Nabenhoehen-Stuetzpunkten auf die tatsaechliche Zielhoehe. Domain (lib/domain/wind/wind_math.dart, reines Dart): Hoehen- Interpolation (linear + zirkulaer fuer Windrichtung), Peilung zwischen zwei Punkten, Kopf-/Rueckenwind-Komponente pro Flugleg - 1:1 uebernommen aus interpolateWindAtHeight()/bearingBetween()/headTailwindComponent() des Demonstrators, per Unit-Tests abgesichert. Service (lib/services/wind/wind_service.dart): zwei getrennte, fokussierte Open-Meteo-Requests statt eines kombinierten (Doku 4.10 - ein 35-Variablen-Request verursachte im Demonstrator Modellauswahl- Fehler). Drei Verbraucher: Kopfleisten-Pille (120 m AGL ueber Referenzort), gebuendelter Wegpunkt-Fetch (ein Request fuer alle Koordinaten via Open-Meteos Mehrfach-Location-Syntax) und das Validierungspanel (rohe Messwerte je Nabenhoehe/Druckflaeche). Per MockClient getestet (kein echter Netzwerkzugriff in Tests). UI: - HeaderWindPill (TopModeBar, nur ausserhalb Fly-Modus): zeigt Windrichtung/-geschwindigkeit, Antippen schaltet perspektivisch die Wind-Marker der Wegpunkte um (Zustand vorbereitet, HTML-Demonstrator: showPerWaypointWind). - WaypointListPanel: "Fetch wind"/"Validate"-Buttons im Panel-Header, neue WIND-Spalte in der Liste. - WindValidatePanel: neue Vollbild-Route (gleiches Muster wie WaypointListPanel) mit Rohdaten je Hoehenstufe inkl. Boeen (Doku 4.9: Boeen nur hier, nicht in der Wegpunktanzeige) und Interpolationsergebnis. - FullValueChart (Speed-Tab): Kopf-/Rueckenwind-Diamanten pro Leg samt gestrichelter Nulllinie sowie gestrichelte Bodengeschwindigkeits- Stufenlinie. Bug gefunden und gefixt waehrend der Emulator-Verifikation: die Y-Achsen-Autoskalierung klemmte weiterhin auf den gueltigen Drehrad- Wertebereich (13-25 m/s), wodurch nahe Null liegende Windkomponenten (bei schwachem Wind der Normalfall) ausserhalb des sichtbaren Bereichs lagen und weder Diamanten noch Nulllinie zu sehen waren. Der HTML- Demonstrator klemmt computeSpeedChartRange() bewusst nicht auf SPD_MIN/ SPD_MAX; das Klemmen in FullValueChart._range() jetzt entsprechend entfernt. Waypoint-Modell um windSpeedMs/windDirFromDeg/windElevationM erweitert (nullable, reine Planungshilfe - der Encoder liest diese Felder nie, Doku 3.2). currentMissionProvider bekommt replaceAll() fuer den gebuendelten Wind-Fetch, der alle Wegpunkte gleichzeitig aktualisiert. Verifiziert: flutter analyze (0 issues), flutter test (44/44, davon 14 neue Wind-Mathematik- und 6 neue WindService-Tests), manuell auf Pixel_10a-Emulator mit echtem Open-Meteo-Netzwerkzugriff - Kopfleisten- Pille zeigt Live-Wind, Fetch/Validate fuellen Liste bzw. oeffnen das Validierungspanel mit echten Messwerten, Speed-Plot zeigt nach dem Fix Diamanten/Nulllinie/Bodengeschwindigkeit korrekt skaliert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
524 lines
18 KiB
Dart
524 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../domain/mission/default_drone_profile.dart';
|
|
import '../../domain/mission/route_geometry.dart';
|
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
|
import '../../domain/wind/wind_math.dart';
|
|
import '../providers/current_mission_provider.dart';
|
|
import '../providers/wind_provider.dart';
|
|
import 'full_value_chart.dart';
|
|
import 'waypoint_chip.dart';
|
|
import 'wind_arrow_icon.dart';
|
|
import 'wind_validate_panel.dart';
|
|
|
|
enum _PanelTab { list, altitude, speed }
|
|
|
|
/// Vollbild-Wegpunktliste (HTML-Demonstrator: #wpListPanel). Wird von
|
|
/// PlanScreen als eigene Vollbild-Route gepusht (Navigator.push), damit sie
|
|
/// wirklich alles ueberdeckt, inklusive der Kopfleiste - anders als ein
|
|
/// interner Stack-Overlay innerhalb des Plan-Screens.
|
|
///
|
|
/// Berechnet die Flugpfad-Geometrie (fuer bad-Markierungen) selbst aus dem
|
|
/// beobachteten currentMissionProvider, damit sie auch reaktiv bleibt,
|
|
/// wenn sich Wegpunkte aendern, waehrend das Panel offen ist.
|
|
///
|
|
/// Nur eine Zeile gleichzeitig editierbar (rowEditIdx); Antippen einer
|
|
/// Zeile ausserhalb des Edit-Modus zentriert die Karte auf diesen
|
|
/// Wegpunkt und schliesst die Liste (Doku 3.6).
|
|
class WaypointListPanel extends ConsumerStatefulWidget {
|
|
const WaypointListPanel({
|
|
super.key,
|
|
required this.activeIndex,
|
|
required this.onClose,
|
|
required this.onCenterOnWaypoint,
|
|
required this.onDelete,
|
|
required this.onAltitudeChanged,
|
|
required this.onSpeedChanged,
|
|
required this.onCatchRadiusChanged,
|
|
required this.onActionChanged,
|
|
});
|
|
|
|
final int activeIndex;
|
|
final VoidCallback onClose;
|
|
final void Function(int index) onCenterOnWaypoint;
|
|
final void Function(int index) onDelete;
|
|
final void Function(int index, double value) onAltitudeChanged;
|
|
final void Function(int index, double value) onSpeedChanged;
|
|
final void Function(int index, double value) onCatchRadiusChanged;
|
|
final void Function(int index, WaypointAction action) onActionChanged;
|
|
|
|
@override
|
|
ConsumerState<WaypointListPanel> createState() => _WaypointListPanelState();
|
|
}
|
|
|
|
class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|
int _rowEditIndex = -1;
|
|
_PanelTab _tab = _PanelTab.list;
|
|
final _listScrollController = ScrollController();
|
|
|
|
static const _headerStyle = TextStyle(
|
|
color: Colors.white60,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.5,
|
|
);
|
|
|
|
// Feste Zeilenhoehe, damit die Scrollposition des aktiven Wegpunkts ohne
|
|
// Layout-Messung berechnet werden kann (siehe _scrollToActive).
|
|
static const _rowExtent = 60.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_listScrollController.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
|
|
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
|
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
|
/// sichtbar).
|
|
void _scrollToActive() {
|
|
if (!mounted || widget.activeIndex < 0) return;
|
|
if (!_listScrollController.hasClients) return;
|
|
final position = _listScrollController.position;
|
|
final target = widget.activeIndex * _rowExtent -
|
|
position.viewportDimension / 2 +
|
|
_rowExtent / 2;
|
|
_listScrollController.animateTo(
|
|
target.clamp(0.0, position.maxScrollExtent),
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final waypoints = ref.watch(currentMissionProvider);
|
|
final routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
minTurnRadius: DefaultDroneProfile.minTurnRadius,
|
|
maxClimbRate: DefaultDroneProfile.maxClimbRate,
|
|
maxDescentRate: DefaultDroneProfile.maxDescentRate,
|
|
);
|
|
|
|
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(
|
|
'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: [
|
|
_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),
|
|
_PanelTab.altitude => _chartTab(
|
|
waypoints,
|
|
routeGeometry,
|
|
values: [for (final w in waypoints) w.altitudeM],
|
|
min: DefaultDroneProfile.altMin,
|
|
max: DefaultDroneProfile.altMax,
|
|
step: DefaultDroneProfile.altStep,
|
|
pxPerUnit: 5,
|
|
unit: ' m',
|
|
onChanged: widget.onAltitudeChanged,
|
|
),
|
|
_PanelTab.speed => _chartTab(
|
|
waypoints,
|
|
routeGeometry,
|
|
values: [for (final w in waypoints) w.speedMs],
|
|
min: DefaultDroneProfile.speedMin,
|
|
max: DefaultDroneProfile.speedMax,
|
|
step: DefaultDroneProfile.speedStep,
|
|
pxPerUnit: 22,
|
|
unit: ' m/s',
|
|
onChanged: widget.onSpeedChanged,
|
|
legWind: computeLegWind(waypoints),
|
|
groundSpeeds: computeGroundSpeeds(waypoints),
|
|
),
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tabButton(String label, _PanelTab tab) {
|
|
final active = _tab == tab;
|
|
return Expanded(
|
|
child: InkWell(
|
|
onTap: () {
|
|
setState(() => _tab = tab);
|
|
if (tab == _PanelTab.list) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
|
|
}
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: active ? const Color(0xFF4D9DFF) : Colors.transparent,
|
|
width: 2,
|
|
),
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: active ? Colors.white : Colors.white60,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _chartTab(
|
|
List<Waypoint> waypoints,
|
|
RouteGeometry routeGeometry, {
|
|
required List<double> values,
|
|
required double min,
|
|
required double max,
|
|
required double step,
|
|
required double pxPerUnit,
|
|
required String unit,
|
|
required void Function(int index, double value) onChanged,
|
|
List<double?>? legWind,
|
|
List<double?>? groundSpeeds,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
|
|
child: FullValueChart(
|
|
waypoints: waypoints,
|
|
values: values,
|
|
legBad: routeGeometry.legClimbBad,
|
|
vertexBad: routeGeometry.vertexBad,
|
|
activeIndex: widget.activeIndex,
|
|
min: min,
|
|
max: max,
|
|
step: step,
|
|
pxPerUnit: pxPerUnit,
|
|
unit: unit,
|
|
onChanged: onChanged,
|
|
legWind: legWind,
|
|
groundSpeeds: groundSpeeds,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _listTab(List<Waypoint> waypoints, RouteGeometry routeGeometry) {
|
|
return Column(
|
|
children: [
|
|
if (waypoints.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: const [
|
|
SizedBox(width: 34),
|
|
Expanded(child: Text('ALTITUDE', style: _headerStyle)),
|
|
Expanded(child: Text('SPEED', style: _headerStyle)),
|
|
Expanded(child: Text('EXECUTE', style: _headerStyle)),
|
|
Expanded(child: Text('CATCH', style: _headerStyle)),
|
|
Expanded(child: Text('WIND', style: _headerStyle)),
|
|
SizedBox(width: 76),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: waypoints.isEmpty
|
|
? const Center(
|
|
child: Text(
|
|
'No waypoints yet',
|
|
style: TextStyle(color: Colors.white60, fontSize: 15),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
controller: _listScrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
itemExtent: _rowExtent,
|
|
itemCount: waypoints.length,
|
|
itemBuilder: (context, index) =>
|
|
_row(waypoints[index], index, routeGeometry),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _row(Waypoint wp, int index, RouteGeometry routeGeometry) {
|
|
final isEditing = _rowEditIndex == index;
|
|
final isActive = widget.activeIndex == index;
|
|
final isBad = index < routeGeometry.vertexBad.length &&
|
|
routeGeometry.vertexBad[index];
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
if (_rowEditIndex >= 0) return;
|
|
widget.onCenterOnWaypoint(index);
|
|
},
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? const Color(0xFF4D9DFF).withValues(alpha: 0.28)
|
|
: Colors.white.withValues(alpha: 0.04),
|
|
border: isActive
|
|
? Border.all(color: const Color(0xFF4D9DFF))
|
|
: null,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 13,
|
|
backgroundColor: isBad
|
|
? const Color(0xFFE2574A).withValues(alpha: 0.5)
|
|
: Colors.white24,
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: isEditing
|
|
? WaypointChip(
|
|
value: wp.altitudeM,
|
|
unit: ' m',
|
|
min: DefaultDroneProfile.altMin,
|
|
max: DefaultDroneProfile.altMax,
|
|
step: DefaultDroneProfile.altStep,
|
|
onChanged: (v) => widget.onAltitudeChanged(index, v),
|
|
)
|
|
: Text('${wp.altitudeM.round()} m',
|
|
style: const TextStyle(color: Colors.white)),
|
|
),
|
|
Expanded(
|
|
child: isEditing
|
|
? WaypointChip(
|
|
value: wp.speedMs,
|
|
unit: ' m/s',
|
|
min: DefaultDroneProfile.speedMin,
|
|
max: DefaultDroneProfile.speedMax,
|
|
step: DefaultDroneProfile.speedStep,
|
|
onChanged: (v) => widget.onSpeedChanged(index, v),
|
|
)
|
|
: Text('${wp.speedMs.round()} m/s',
|
|
style: const TextStyle(color: Colors.white)),
|
|
),
|
|
Expanded(
|
|
child: isEditing
|
|
? DropdownButton<WaypointAction>(
|
|
value: wp.action,
|
|
isDense: true,
|
|
dropdownColor: const Color(0xFF1A1A1A),
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: WaypointAction.none,
|
|
child: Text('—'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: WaypointAction.loiter,
|
|
child: Text('Loiter'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: WaypointAction.landing,
|
|
child: Text('Landing'),
|
|
),
|
|
],
|
|
onChanged: (action) {
|
|
if (action != null) widget.onActionChanged(index, action);
|
|
},
|
|
)
|
|
: Text(_actionLabel(wp.action),
|
|
style: const TextStyle(color: Colors.white)),
|
|
),
|
|
Expanded(
|
|
child: isEditing
|
|
? WaypointChip(
|
|
value: wp.catchRadiusM,
|
|
unit: ' m',
|
|
min: 5,
|
|
max: 500,
|
|
step: 10,
|
|
onChanged: (v) => widget.onCatchRadiusChanged(index, v),
|
|
)
|
|
: Text('${wp.catchRadiusM.round()} m',
|
|
style: const TextStyle(color: Colors.white)),
|
|
),
|
|
Expanded(
|
|
child: wp.windSpeedMs != null
|
|
? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
WindArrowIcon(dirFromDeg: wp.windDirFromDeg!, size: 13),
|
|
const SizedBox(width: 4),
|
|
Text('${wp.windSpeedMs!.round()} m/s',
|
|
style: const TextStyle(color: Colors.white)),
|
|
],
|
|
)
|
|
: const Text('—', style: TextStyle(color: Colors.white38)),
|
|
),
|
|
IconButton(
|
|
onPressed: () => setState(
|
|
() => _rowEditIndex = isEditing ? -1 : index,
|
|
),
|
|
icon: Icon(
|
|
isEditing ? Icons.check : Icons.edit,
|
|
color: Colors.white70,
|
|
size: 18,
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
widget.onDelete(index);
|
|
if (_rowEditIndex == index) setState(() => _rowEditIndex = -1);
|
|
},
|
|
icon: const Icon(Icons.delete_outline,
|
|
color: Color(0xFFE2574A), size: 18),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _actionLabel(WaypointAction action) => switch (action) {
|
|
WaypointAction.loiter => 'Loiter',
|
|
WaypointAction.landing => 'Landing',
|
|
_ => '—',
|
|
};
|
|
}
|
|
|
|
/// 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|