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'; /// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public, /// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed /// verweisen koennen, statt immer auf der Liste zu starten. 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, this.initialTab = PanelTab.list, required this.onClose, required this.onCenterOnWaypoint, required this.onDelete, required this.onAltitudeChanged, required this.onSpeedChanged, required this.onCatchRadiusChanged, required this.onActionChanged, }); final int activeIndex; /// Startet direkt auf einem bestimmten Tab statt immer auf der Liste - /// genutzt, wenn eine Warnung (Doku 3.10) gezielt auf Altitude/Speed /// verweist. final PanelTab initialTab; 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 createState() => _WaypointListPanelState(); } class _WaypointListPanelState extends ConsumerState { int _rowEditIndex = -1; late PanelTab _tab; 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(); _tab = widget.initialTab; WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToActive(); // Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der // Warnungsliste), muss der Gelaendeprofil-Abruf hier ausgeloest // werden - sonst passiert das erst beim (dann gar nicht mehr // stattfindenden) Antippen des Tab-Buttons. if (_tab == PanelTab.altitude) { ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider)); } }); // 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 _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 waypoints, RouteGeometry routeGeometry, { required List 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? legWind, List? groundSpeeds, List? 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 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( 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, ), ), ], ), ), ); } }