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/mission_meta_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'; /// 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; // Wind pro Wegpunkt automatisch abrufen, sobald das Panel geoeffnet // wird (Doku: "wind automatisch abrufen, wenn das menue geoeffnet // wird") - ersetzt den vorherigen manuellen "Fetch wind"-Knopf. // ensurePerWaypointWind() selbst entscheidet, ob dafuer ueberhaupt ein // Request noetig ist (siehe dortige Doku). ref.read(headerWindProvider.notifier).ensurePerWaypointWind(); 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), missionId: ref.read(currentMissionMetaProvider).id, ); } }); // 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, missionId: ref.read(currentMissionMetaProvider).id, ); } }); } @override void dispose() { _listScrollController.dispose(); super.dispose(); } /// 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( // Kein eigener Titel-Header mehr (Doku: "nach dem neuen // Designschema anpassen und ueberschrift entfernen", analog Drone- // Status- und Missions-/Drohnen-Panel) - der Schliessen-Knopf // schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz // frei laesst (siehe unten). child: Stack( children: [ Column( children: [ Padding( // Rechts Platz fuer den schwebenden Schliessen-Knopf, // damit "Speed" nicht darunter verschwindet. padding: const EdgeInsets.only(right: 48), child: 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), ), }, ), ], ), Positioned( top: 4, right: 4, child: IconButton( onPressed: widget.onClose, icon: const Icon(Icons.close, color: Colors.white), ), ), ], ), ), ); } 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), missionId: ref.read(currentMissionMetaProvider).id, ); } }, child: Container( // 18 statt vorher 10 (analog Drone-Status-/Missions-Panel: Tabs // wirkten seit dem Wegfall der Titelzeile zu gequetscht). padding: const EdgeInsets.symmetric(vertical: 18), 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', _ => '—', }; }