Rundet das Altitude-Tab um ein reales Gelaendeprofil ab, damit sichtbar wird, ob die geplante Flughoehe ausreichend Bodenabstand haelt: - domain/mission/terrain_math.dart: reine Sampling-/Geometrie-Funktionen (routeKeyForTerrain fuer Caching, distanzbasiertes Sampling alle 30m, lineare Sollhoehen-Interpolation, terrainDangerRanges fuer Abschnitte mit < 10m Bodenabstand) - unabhaengig testbar ohne Netzwerk/UI. - services/terrain/terrain_service.dart: laedt AWS-Terrarium-PNG-Kacheln (elevation-tiles-prod, zoom 13) und dekodiert sie ueber dart:ui (instantiateImageCodec/toByteData), ohne zusaetzliches Bildpaket. Elevation = R*256 + G + B/256 - 32768 pro Pixel; Kacheln werden pro Route nur einmal geladen (nach Kachel gruppierte Sample-Punkte). - ui/providers/terrain_provider.dart: cached das geladene Profil ueber routeKeyForTerrain - reine Werteaenderungen (Hoehe/Speed) loesen keinen erneuten Kachel-Download aus, nur eine tatsaechliche Ortsverschiebung. - ui/widgets/full_value_chart.dart: _paintDangerBands/_paintTerrain zeichnen rote Gefahrenbaender bzw. die braune Gelaende-Flaeche, in der gleichen Reihenfolge wie im HTML-Demonstrator (dangerHtml, grid, terrainHtml, dann Soll-Kurve obenauf). - ui/widgets/waypoint_list_panel.dart: triggert ensureFor() beim Wechsel auf den Altitude-Tab sowie bei Routenaenderungen waehrend dieser aktiv ist (ref.listenManual auf currentMissionProvider). Getestet: 13 neue Unit-Tests fuer die reine Geometrie/Sampling-Logik, 3 Service-Tests mit einer zur Testzeit synthetisch erzeugten PNG (dart:ui-Encoding, keine Testasset-Datei noetig). Alle 80 Tests sowie flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert: Altitude-Tab zeigt das reale Amsterdam-Gelaendeprofil (nahe Meereshoehe) korrekt als Flaeche unter der Sollhoehen-Linie. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
562 lines
20 KiB
Dart
562 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../domain/mission/default_drone_profile.dart';
|
|
import '../../domain/mission/drone_profile.dart';
|
|
import '../../domain/mission/route_geometry.dart';
|
|
import '../../domain/mission/terrain_math.dart';
|
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
|
import '../../domain/wind/wind_math.dart';
|
|
import '../providers/active_drone_profile_provider.dart';
|
|
import '../providers/current_mission_provider.dart';
|
|
import '../providers/terrain_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());
|
|
// Laedt/aktualisiert das Gelaendeprofil (Doku 3.9) nur, waehrend der
|
|
// Altitude-Tab tatsaechlich sichtbar ist - aendert sich die Route
|
|
// waehrenddessen (Editieren, Loeschen, neuer Wegpunkt), muss neu
|
|
// gesampelt werden. ensureFor() selbst filtert per routeKey, ob ein
|
|
// erneuter Kachel-Download noetig ist.
|
|
ref.listenManual(currentMissionProvider, (previous, next) {
|
|
if (_tab == _PanelTab.altitude) {
|
|
ref.read(terrainProvider.notifier).ensureFor(next);
|
|
}
|
|
});
|
|
}
|
|
|
|
@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 droneProfile = ref.watch(activeDroneProfileProvider);
|
|
final terrainState = ref.watch(terrainProvider);
|
|
final routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
minTurnRadius: droneProfile.minTurnRadius,
|
|
maxClimbRate: droneProfile.maxClimbRate,
|
|
maxDescentRate: droneProfile.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, droneProfile),
|
|
_PanelTab.altitude => _chartTab(
|
|
waypoints,
|
|
routeGeometry,
|
|
values: [for (final w in waypoints) w.altitudeM],
|
|
min: DefaultDroneProfile.altMin,
|
|
max: droneProfile.maxAltitudeM,
|
|
step: DefaultDroneProfile.altStep,
|
|
pxPerUnit: 5,
|
|
unit: ' m',
|
|
onChanged: widget.onAltitudeChanged,
|
|
terrainPoints: terrainState.profile?.points,
|
|
terrainDangerRanges: terrainState.profile != null
|
|
? terrainDangerRanges(waypoints, terrainState.profile!)
|
|
: null,
|
|
terrainLoading: terrainState.loading,
|
|
),
|
|
_PanelTab.speed => _chartTab(
|
|
waypoints,
|
|
routeGeometry,
|
|
values: [for (final w in waypoints) w.speedMs],
|
|
min: droneProfile.minSpeed,
|
|
max: droneProfile.maxSpeed,
|
|
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());
|
|
} else if (tab == _PanelTab.altitude) {
|
|
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
|
|
}
|
|
},
|
|
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,
|
|
List<TerrainSamplePoint>? terrainPoints,
|
|
List<(double, double)>? terrainDangerRanges,
|
|
bool terrainLoading = false,
|
|
}) {
|
|
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,
|
|
terrainPoints: terrainPoints,
|
|
terrainDangerRanges: terrainDangerRanges,
|
|
terrainLoading: terrainLoading,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _listTab(
|
|
List<Waypoint> waypoints,
|
|
RouteGeometry routeGeometry,
|
|
DroneProfile droneProfile,
|
|
) {
|
|
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, droneProfile),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _row(
|
|
Waypoint wp,
|
|
int index,
|
|
RouteGeometry routeGeometry,
|
|
DroneProfile droneProfile,
|
|
) {
|
|
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: droneProfile.maxAltitudeM,
|
|
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: droneProfile.minSpeed,
|
|
max: droneProfile.maxSpeed,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|