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 createState() => _WaypointListPanelState(); } class _WaypointListPanelState extends ConsumerState { 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 _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 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, }) { 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 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( 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, ), ), ], ), ), ); } }