diff --git a/app/lib/domain/mission/default_drone_profile.dart b/app/lib/domain/mission/default_drone_profile.dart new file mode 100644 index 0000000..536237f --- /dev/null +++ b/app/lib/domain/mission/default_drone_profile.dart @@ -0,0 +1,20 @@ +/// T1-Ranger-Standardwerte, uebernommen aus dem HTML-Demonstrator +/// (DEFAULT_DRONE). Zentraler Platzhalter, bis die Drohnenprofil-Verwaltung +/// (Architektur-Doku 3.5) existiert - dann durch das aktive Profil ersetzen. +abstract final class DefaultDroneProfile { + static const altitudeM = 60.0; + static const speedMs = 15.0; + static const catchRadiusM = 60.0; + + static const altMin = -500.0; + static const altMax = 2000.0; + static const altStep = 10.0; + + static const speedMin = 13.0; + static const speedMax = 25.0; + static const speedStep = 1.0; + + static const minTurnRadius = 35.0; + static const maxClimbRate = 5.0; + static const maxDescentRate = 6.0; +} diff --git a/app/lib/domain/mission/route_geometry.dart b/app/lib/domain/mission/route_geometry.dart index c488ce0..e1aa0fc 100644 --- a/app/lib/domain/mission/route_geometry.dart +++ b/app/lib/domain/mission/route_geometry.dart @@ -15,7 +15,11 @@ class RouteSegment { } class RouteGeometry { - const RouteGeometry({required this.segments, required this.vertexBad}); + const RouteGeometry({ + required this.segments, + required this.vertexBad, + required this.legClimbBad, + }); final List segments; @@ -25,7 +29,12 @@ class RouteGeometry { /// Kurswinkel ist zu scharf). final List vertexBad; - static const empty = RouteGeometry(segments: [], vertexBad: []); + /// Pro Streckenabschnitt (Index i verbindet Wegpunkt i und i+1): true, + /// wenn die noetige Steig-/Sinkrate das Limit der Drohne ueberschreitet. + final List legClimbBad; + + static const empty = + RouteGeometry(segments: [], vertexBad: [], legClimbBad: []); } class _V { @@ -183,7 +192,11 @@ RouteGeometry buildRouteGeometry( } } - return RouteGeometry(segments: segments, vertexBad: vertexBad); + return RouteGeometry( + segments: segments, + vertexBad: vertexBad, + legClimbBad: legClimbBad, + ); } _V _pointOnCircle(_V center, double radius, double angle) => diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 9354fff..10e374e 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -6,11 +6,13 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; +import '../../../domain/mission/default_drone_profile.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../providers/current_mission_provider.dart'; import '../../widgets/bottom_stats_bar.dart'; import '../../widgets/halo_menu.dart'; +import '../../widgets/mini_altitude_profile.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; import '../../widgets/value_wheel.dart'; @@ -40,30 +42,16 @@ class _PlanScreenState extends ConsumerState { _ReticleMode _mode = _ReticleMode.idle; int _targetIndex = -1; bool _mapReady = false; - bool _listOpen = false; - // TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die - // Profilverwaltung existiert. Werte entsprechen dem Default-Profil - // T1 Ranger im HTML-Demonstrator (DEFAULT_DRONE). - static const _altMin = -500.0; - static const _altMax = 2000.0; - static const _altStep = 10.0; + // Rein UI-seitige Darstellungsdetails der Drehraeder; die eigentlichen + // Wertebereiche kommen aus DefaultDroneProfile (Doku 3.5-Platzhalter). static const _altPxPerStep = 14.0; static const _altMajorEvery = 5; - - static const _speedMin = 13.0; - static const _speedMax = 25.0; - static const _speedStep = 1.0; static const _speedPxPerStep = 14.0; static const _speedMajorEvery = 5; - static const _defaultCatchRadiusM = 60.0; - static const _minTurnRadius = 35.0; - static const _maxClimbRate = 5.0; - static const _maxDescentRate = 6.0; - - double _curAlt = 60; - double _curSpeed = 15; + double _curAlt = DefaultDroneProfile.altitudeM; + double _curSpeed = DefaultDroneProfile.speedMs; // Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser). static const _reticleRadiusPx = 55 / 2; @@ -161,7 +149,7 @@ class _PlanScreenState extends ConsumerState { lon: center.longitude, altitudeM: _curAlt, speedMs: _curSpeed, - catchRadiusM: _defaultCatchRadiusM, + catchRadiusM: DefaultDroneProfile.catchRadiusM, ), ); setState(() { @@ -254,19 +242,49 @@ class _PlanScreenState extends ConsumerState { }); } - /// Zeile antippen zentriert die Karte auf diesen Wegpunkt und schliesst - /// die Liste (Doku 3.6). Danach wird die Snap-Erkennung einmal manuell - /// angestossen, da flutter_maps MapController.move() - anders als - /// Leaflets panTo() - kein MoveEnd-Event feuert. + /// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). + /// Danach wird die Snap-Erkennung einmal manuell angestossen, da + /// flutter_maps MapController.move() - anders als Leaflets panTo() - kein + /// MoveEnd-Event feuert. void _onCenterOnWaypoint(int index) { final waypoints = ref.read(currentMissionProvider); if (index < 0 || index >= waypoints.length) return; final wp = waypoints[index]; - setState(() => _listOpen = false); _mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom); _handleMoveEnd(); } + /// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als + /// internen Stack-Overlay, damit sie wirklich alles ueberdeckt - + /// inklusive der Kopfleiste im AppShell darueber. + void _openWaypointList() { + Navigator.of(context).push( + PageRouteBuilder( + opaque: true, + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), + pageBuilder: (routeContext, animation, secondaryAnimation) { + return FadeTransition( + opacity: animation, + child: WaypointListPanel( + activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex, + onClose: () => Navigator.of(routeContext).pop(), + onCenterOnWaypoint: (index) { + _onCenterOnWaypoint(index); + Navigator.of(routeContext).pop(); + }, + onDelete: _onRowDelete, + onAltitudeChanged: _onRowAltitudeChanged, + onSpeedChanged: _onRowSpeedChanged, + onCatchRadiusChanged: _onRowCatchRadiusChanged, + onActionChanged: _onRowActionChanged, + ), + ); + }, + ), + ); + } + ({double distanceM, double durationSec}) _missionStats( List waypoints, ) { @@ -288,11 +306,13 @@ class _PlanScreenState extends ConsumerState { final routeGeometry = buildRouteGeometry( waypoints, - minTurnRadius: _minTurnRadius, - maxClimbRate: _maxClimbRate, - maxDescentRate: _maxDescentRate, + minTurnRadius: DefaultDroneProfile.minTurnRadius, + maxClimbRate: DefaultDroneProfile.maxClimbRate, + maxDescentRate: DefaultDroneProfile.maxDescentRate, ); final missionStats = _missionStats(waypoints); + final anyBad = routeGeometry.vertexBad.any((bad) => bad) || + routeGeometry.segments.any((s) => s.bad); List? rubberBand; if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) { @@ -312,6 +332,13 @@ class _PlanScreenState extends ConsumerState { MediaQuery.of(context).size.height * 0.06, 124.0, ); + // Symmetrischer Randabstand auf beiden Seiten, damit z.B. eine + // Frontkamera-Aussparung im Querformat die Raeder nicht verdeckt + // (HTML-Demonstrator: max(env(safe-area-inset-left), env(safe-area-inset-right))). + final safeEdgeInset = math.max( + MediaQuery.of(context).padding.left, + MediaQuery.of(context).padding.right, + ); return Stack( children: [ @@ -346,12 +373,12 @@ class _PlanScreenState extends ConsumerState { Positioned( top: 56, bottom: wheelBottomInset, - left: 14, + left: 14 + safeEdgeInset, child: ValueWheel( value: _curAlt, - min: _altMin, - max: _altMax, - step: _altStep, + min: DefaultDroneProfile.altMin, + max: DefaultDroneProfile.altMax, + step: DefaultDroneProfile.altStep, pxPerStep: _altPxPerStep, majorEvery: _altMajorEvery, onChanged: _onAltChanged, @@ -360,18 +387,18 @@ class _PlanScreenState extends ConsumerState { Positioned( top: 56, bottom: wheelBottomInset, - left: 70, + left: 70 + safeEdgeInset, child: Center(child: _readout('Alt', _curAlt, 'm')), ), Positioned( top: 56, bottom: wheelBottomInset, - right: 14, + right: 14 + safeEdgeInset, child: ValueWheel( value: _curSpeed, - min: _speedMin, - max: _speedMax, - step: _speedStep, + min: DefaultDroneProfile.speedMin, + max: DefaultDroneProfile.speedMax, + step: DefaultDroneProfile.speedStep, pxPerStep: _speedPxPerStep, majorEvery: _speedMajorEvery, onChanged: _onSpeedChanged, @@ -380,7 +407,7 @@ class _PlanScreenState extends ConsumerState { Positioned( top: 56, bottom: wheelBottomInset, - right: 70, + right: 70 + safeEdgeInset, child: Center(child: _readout('Speed', _curSpeed, 'm/s')), ), Positioned( @@ -390,8 +417,7 @@ class _PlanScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (routeGeometry.vertexBad.any((bad) => bad) || - routeGeometry.segments.any((s) => s.bad)) + if (anyBad) Container( width: double.infinity, color: const Color(0xFF7C2D12), @@ -402,33 +428,34 @@ class _PlanScreenState extends ConsumerState { style: TextStyle(color: Color(0xFFFDE3D0), fontSize: 12), ), ), - BottomStatsBar( - waypointCount: waypoints.length, - totalDistanceM: missionStats.distanceM, - totalDurationSec: missionStats.durationSec, - onTap: () => setState(() => _listOpen = true), + Container( + width: double.infinity, + color: const Color(0xFF0F0F0F).withValues(alpha: 0.72), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: MiniAltitudeProfile( + waypoints: waypoints, + legClimbBad: routeGeometry.legClimbBad, + vertexBad: routeGeometry.vertexBad, + activeIndex: + _mode == _ReticleMode.idle ? -1 : _targetIndex, + ), + ), + BottomStatsBar( + waypointCount: waypoints.length, + totalDistanceM: missionStats.distanceM, + totalDurationSec: missionStats.durationSec, + onTap: _openWaypointList, + ), + ], + ), ), ], ), ), - if (_listOpen) - Positioned.fill( - child: WaypointListPanel( - activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex, - vertexBad: routeGeometry.vertexBad, - altMin: _altMin, - altMax: _altMax, - speedMin: _speedMin, - speedMax: _speedMax, - onClose: () => setState(() => _listOpen = false), - onCenterOnWaypoint: _onCenterOnWaypoint, - onDelete: _onRowDelete, - onAltitudeChanged: _onRowAltitudeChanged, - onSpeedChanged: _onRowSpeedChanged, - onCatchRadiusChanged: _onRowCatchRadiusChanged, - onActionChanged: _onRowActionChanged, - ), - ), ], ); } diff --git a/app/lib/ui/widgets/full_value_chart.dart b/app/lib/ui/widgets/full_value_chart.dart new file mode 100644 index 0000000..ea2678d --- /dev/null +++ b/app/lib/ui/widgets/full_value_chart.dart @@ -0,0 +1,278 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart' hide Path; + +import '../../domain/waypoint/flat_waypoint_list.dart'; + +/// Vollbild-Chart zum Anpassen von Hoehe/Geschwindigkeit per Drag auf den +/// Punkten (HTML-Demonstrator: buildFullChart()/attachChartDrag()). Jeder +/// Punkt entspricht einem Wegpunkt, X-Position proportional zur +/// zurueckgelegten Distanz entlang der Route. +/// +/// Terrain-Overlay und Wind-Diamanten aus dem Demonstrator sind nicht +/// portiert, da Terrain- (Doku 3.9) und Wind-System (Doku 3.8) in der +/// Flutter-App noch nicht existieren. +class FullValueChart extends StatefulWidget { + const FullValueChart({ + super.key, + required this.waypoints, + required this.values, + required this.legBad, + required this.vertexBad, + required this.activeIndex, + required this.min, + required this.max, + required this.step, + required this.pxPerUnit, + required this.unit, + required this.onChanged, + }); + + final List waypoints; + final List values; + final List legBad; + final List vertexBad; + final int activeIndex; + final double min; + final double max; + final double step; + final double pxPerUnit; + final String unit; + final void Function(int index, double value) onChanged; + + static const _distance = Distance(roundResult: false); + + static List cumulativeDistances(List waypoints) { + final cum = [0]; + for (var i = 1; i < waypoints.length; i++) { + final a = LatLng(waypoints[i - 1].lat, waypoints[i - 1].lon); + final b = LatLng(waypoints[i].lat, waypoints[i].lon); + cum.add(cum[i - 1] + _distance(a, b)); + } + return cum; + } + + @override + State createState() => _FullValueChartState(); +} + +class _FullValueChartState extends State { + int? _dragIndex; + double _dragStartY = 0; + double _dragStartValue = 0; + + static const _padX = 50.0; + static const _hitToleranceX = 28.0; + + ({double lo, double hi}) _range() { + if (widget.values.isEmpty) return (lo: widget.min, hi: widget.max); + final dataMin = widget.values.reduce(math.min); + final dataMax = widget.values.reduce(math.max); + final span = dataMax - dataMin; + final pad = math.max(span * 0.35, widget.step * 2); + var lo = dataMin - pad; + var hi = dataMax + pad; + final minSpan = widget.step * 6; + if (hi - lo < minSpan) { + final mid = (lo + hi) / 2; + lo = mid - minSpan / 2; + hi = mid + minSpan / 2; + } + if (lo < widget.min) lo = widget.min; + if (hi > widget.max) hi = widget.max; + if (hi <= lo) { + lo = widget.min; + hi = widget.max; + } + return (lo: lo, hi: hi); + } + + double _xAt(int idx, List cumDist, double boxW) { + final n = widget.waypoints.length; + if (n <= 1) return boxW / 2; + final total = cumDist.last == 0 ? 1.0 : cumDist.last; + return _padX + (cumDist[idx] / total) * (boxW - 2 * _padX); + } + + int? _nearestIndex(Offset local, List cumDist, double boxW) { + var best = -1; + var bestDist = double.infinity; + for (var i = 0; i < widget.waypoints.length; i++) { + final dx = (_xAt(i, cumDist, boxW) - local.dx).abs(); + if (dx < bestDist) { + bestDist = dx; + best = i; + } + } + return bestDist <= _hitToleranceX ? best : null; + } + + @override + Widget build(BuildContext context) { + if (widget.waypoints.isEmpty) { + return const Center( + child: Text('No waypoints yet', style: TextStyle(color: Colors.white60)), + ); + } + + final cumDist = FullValueChart.cumulativeDistances(widget.waypoints); + final range = _range(); + + return LayoutBuilder( + builder: (context, constraints) { + final size = Size(constraints.maxWidth, constraints.maxHeight); + return GestureDetector( + onPanStart: (details) { + final idx = _nearestIndex(details.localPosition, cumDist, size.width); + if (idx == null) return; + _dragIndex = idx; + _dragStartY = details.localPosition.dy; + _dragStartValue = widget.values[idx]; + }, + onPanUpdate: (details) { + final idx = _dragIndex; + if (idx == null) return; + final deltaY = _dragStartY - details.localPosition.dy; + final deltaVal = deltaY / widget.pxPerUnit; + var val = _dragStartValue + deltaVal; + val = (val / widget.step).round() * widget.step; + val = val.clamp(widget.min, widget.max); + widget.onChanged(idx, val); + }, + onPanEnd: (_) => _dragIndex = null, + onPanCancel: () => _dragIndex = null, + child: CustomPaint( + size: size, + painter: _ChartPainter( + waypoints: widget.waypoints, + values: widget.values, + cumDist: cumDist, + legBad: widget.legBad, + vertexBad: widget.vertexBad, + activeIndex: widget.activeIndex, + lo: range.lo, + hi: range.hi, + unit: widget.unit, + ), + ), + ); + }, + ); + } +} + +class _ChartPainter extends CustomPainter { + const _ChartPainter({ + required this.waypoints, + required this.values, + required this.cumDist, + required this.legBad, + required this.vertexBad, + required this.activeIndex, + required this.lo, + required this.hi, + required this.unit, + }); + + final List waypoints; + final List values; + final List cumDist; + final List legBad; + final List vertexBad; + final int activeIndex; + final double lo; + final double hi; + final String unit; + + static const _padX = 50.0; + static const _padY = 20.0; + + double _xAt(int idx, double boxW) { + final n = waypoints.length; + if (n <= 1) return boxW / 2; + final total = cumDist.last == 0 ? 1.0 : cumDist.last; + return _padX + (cumDist[idx] / total) * (boxW - 2 * _padX); + } + + double _yAt(double val, double boxH) => + boxH - _padY - ((val - lo) / (hi - lo)) * (boxH - 2 * _padY); + + double _niceStep(double range, int targetTicks) { + if (range <= 0) return 1; + final rough = range / targetTicks; + final mag = math.pow(10, (math.log(rough) / math.ln10).floor()).toDouble(); + final norm = rough / mag; + if (norm < 1.5) return mag; + if (norm < 3) return 2 * mag; + if (norm < 7) return 5 * mag; + return 10 * mag; + } + + @override + void paint(Canvas canvas, Size size) { + final boxW = size.width; + final boxH = size.height; + final n = waypoints.length; + + // Gridlines mit gerundeten Werten. + final step = _niceStep(hi - lo, 5); + final gridPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.14) + ..strokeWidth = 1; + final axisPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.35) + ..strokeWidth = 1.5; + canvas.drawLine(Offset(_padX, 0), Offset(_padX, boxH), axisPaint); + + final labelStyle = TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 10); + var v = (lo / step).ceil() * step; + while (v <= hi + 1e-6) { + final y = _yAt(v, boxH); + canvas.drawLine(Offset(_padX, y), Offset(boxW - _padX, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}$unit', style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padX - 8 - tp.width, y - tp.height / 2)); + v += step; + } + + // Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate). + for (var i = 0; i < n - 1; i++) { + final bad = i < legBad.length && legBad[i]; + canvas.drawLine( + Offset(_xAt(i, boxW), _yAt(values[i], boxH)), + Offset(_xAt(i + 1, boxW), _yAt(values[i + 1], boxH)), + Paint() + ..color = bad ? const Color(0xFFE2574A) : Colors.white + ..strokeWidth = 2.5, + ); + } + + // Punkte (blau=aktiver Zielpunkt, rot=nicht fliegbar, sonst weiss). + for (var i = 0; i < n; i++) { + final active = i == activeIndex; + final bad = i < vertexBad.length && vertexBad[i]; + final color = + active ? const Color(0xFF4D9DFF) : (bad ? const Color(0xFFE2574A) : Colors.white); + final center = Offset(_xAt(i, boxW), _yAt(values[i], boxH)); + canvas.drawCircle(center, active ? 9 : 7, Paint()..color = color); + canvas.drawCircle( + center, + active ? 9 : 7, + Paint() + ..color = const Color(0xFF2F6FE0) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + } + + @override + bool shouldRepaint(covariant _ChartPainter oldDelegate) => + oldDelegate.values != values || + oldDelegate.activeIndex != activeIndex || + oldDelegate.lo != lo || + oldDelegate.hi != hi; +} diff --git a/app/lib/ui/widgets/mini_altitude_profile.dart b/app/lib/ui/widgets/mini_altitude_profile.dart new file mode 100644 index 0000000..ab757dd --- /dev/null +++ b/app/lib/ui/widgets/mini_altitude_profile.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart' hide Path; + +import '../../domain/waypoint/flat_waypoint_list.dart'; + +/// Kompaktes, nicht interaktives Hoehenprofil in der Fusszeile +/// (HTML-Demonstrator: #altProfile/buildProfileSvg()). Fuer die grosse, +/// per Drag editierbare Ansicht siehe die Altitude-Tab im +/// WaypointListPanel. +class MiniAltitudeProfile extends StatelessWidget { + const MiniAltitudeProfile({ + super.key, + required this.waypoints, + required this.legClimbBad, + required this.vertexBad, + required this.activeIndex, + }); + + final List waypoints; + final List legClimbBad; + final List vertexBad; + final int activeIndex; + + static const _distance = Distance(roundResult: false); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 56, + width: double.infinity, + child: waypoints.length < 2 + ? const Center( + child: Text( + 'Altitude profile', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + ) + : CustomPaint( + size: Size.infinite, + painter: _MiniProfilePainter( + waypoints: waypoints, + legClimbBad: legClimbBad, + vertexBad: vertexBad, + activeIndex: activeIndex, + ), + ), + ); + } +} + +class _MiniProfilePainter extends CustomPainter { + const _MiniProfilePainter({ + required this.waypoints, + required this.legClimbBad, + required this.vertexBad, + required this.activeIndex, + }); + + final List waypoints; + final List legClimbBad; + final List vertexBad; + final int activeIndex; + + static const _pad = 4.0; + + @override + void paint(Canvas canvas, Size size) { + final n = waypoints.length; + final boxW = size.width - 16; + final boxH = size.height - 4; + + final cum = [0]; + for (var i = 1; i < n; i++) { + final a = LatLng(waypoints[i - 1].lat, waypoints[i - 1].lon); + final b = LatLng(waypoints[i].lat, waypoints[i].lon); + cum.add(cum[i - 1] + MiniAltitudeProfile._distance(a, b)); + } + final totalD = cum.last == 0 ? 1.0 : cum.last; + + final alts = [for (final w in waypoints) w.altitudeM]; + var altMin = alts.reduce((a, b) => a < b ? a : b); + var altMax = alts.reduce((a, b) => a > b ? a : b); + if (altMax - altMin < 10) { + altMax += 5; + altMin -= 5; + } + + double xAt(int idx) => _pad + (cum[idx] / totalD) * (boxW - 2 * _pad); + double yAt(double alt) => + boxH - _pad - ((alt - altMin) / (altMax - altMin)) * (boxH - 2 * _pad); + + final areaPath = Path()..moveTo(xAt(0), boxH - _pad); + for (var i = 0; i < n; i++) { + areaPath.lineTo(xAt(i), yAt(alts[i])); + } + areaPath.lineTo(xAt(n - 1), boxH - _pad); + areaPath.close(); + canvas.drawPath( + areaPath, + Paint()..color = const Color(0xFF4D9DFF).withValues(alpha: 0.18), + ); + + for (var i = 0; i < n - 1; i++) { + final bad = i < legClimbBad.length && legClimbBad[i]; + canvas.drawLine( + Offset(xAt(i), yAt(alts[i])), + Offset(xAt(i + 1), yAt(alts[i + 1])), + Paint() + ..color = bad ? const Color(0xFFE2574A) : Colors.white + ..strokeWidth = 2, + ); + } + + for (var i = 0; i < n; i++) { + final active = i == activeIndex; + final bad = i < vertexBad.length && vertexBad[i]; + final color = active + ? const Color(0xFF4D9DFF) + : (bad ? const Color(0xFFE2574A) : Colors.white); + canvas.drawCircle( + Offset(xAt(i), yAt(alts[i])), + active ? 4.5 : 2.4, + Paint()..color = color, + ); + if (active) { + canvas.drawCircle( + Offset(xAt(i), yAt(alts[i])), + 4.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + } + } + } + + @override + bool shouldRepaint(covariant _MiniProfilePainter oldDelegate) => + oldDelegate.waypoints != waypoints || + oldDelegate.legClimbBad != legClimbBad || + oldDelegate.vertexBad != vertexBad || + oldDelegate.activeIndex != activeIndex; +} diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index 1cd7dde..11e0df8 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -1,26 +1,31 @@ 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 '../providers/current_mission_provider.dart'; +import 'full_value_chart.dart'; import 'waypoint_chip.dart'; -/// Vollbild-Wegpunktliste (HTML-Demonstrator: #wpListPanel, List-Tab). +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). -/// -/// Die Altitude/Speed-Vollbild-Charts (weitere Tabs im Demonstrator) sind -/// bewusst nicht Teil dieses Schritts. class WaypointListPanel extends ConsumerStatefulWidget { const WaypointListPanel({ super.key, required this.activeIndex, - required this.vertexBad, - required this.altMin, - required this.altMax, - required this.speedMin, - required this.speedMax, required this.onClose, required this.onCenterOnWaypoint, required this.onDelete, @@ -31,11 +36,6 @@ class WaypointListPanel extends ConsumerStatefulWidget { }); final int activeIndex; - final List vertexBad; - final double altMin; - final double altMax; - final double speedMin; - final double speedMax; final VoidCallback onClose; final void Function(int index) onCenterOnWaypoint; final void Function(int index) onDelete; @@ -50,6 +50,7 @@ class WaypointListPanel extends ConsumerStatefulWidget { class _WaypointListPanelState extends ConsumerState { int _rowEditIndex = -1; + _PanelTab _tab = _PanelTab.list; static const _headerStyle = TextStyle( color: Colors.white60, @@ -61,6 +62,12 @@ class _WaypointListPanelState extends ConsumerState { @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), @@ -88,35 +95,40 @@ class _WaypointListPanelState extends ConsumerState { ], ), ), - if (waypoints.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - 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)), - SizedBox(width: 76), - ], - ), - ), - const Divider(color: Colors.white24, height: 16), + Row( + children: [ + _tabButton('List', _PanelTab.list), + _tabButton('Altitude', _PanelTab.altitude), + _tabButton('Speed', _PanelTab.speed), + ], + ), + const Divider(color: Colors.white24, height: 1), Expanded( - child: waypoints.isEmpty - ? const Center( - child: Text( - 'No waypoints yet', - style: TextStyle(color: Colors.white60, fontSize: 15), - ), - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: waypoints.length, - itemBuilder: (context, index) => - _row(waypoints[index], index), - ), + 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, + ), + }, ), ], ), @@ -124,10 +136,105 @@ class _WaypointListPanelState extends ConsumerState { ); } - Widget _row(Waypoint wp, int index) { + Widget _tabButton(String label, _PanelTab tab) { + final active = _tab == tab; + return Expanded( + child: InkWell( + onTap: () => setState(() => _tab = tab), + 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, + }) { + 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, + ), + ); + } + + 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)), + SizedBox(width: 76), + ], + ), + ), + Expanded( + child: waypoints.isEmpty + ? const Center( + child: Text( + 'No waypoints yet', + style: TextStyle(color: Colors.white60, fontSize: 15), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + 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 < widget.vertexBad.length && widget.vertexBad[index]; + final isBad = index < routeGeometry.vertexBad.length && + routeGeometry.vertexBad[index]; return GestureDetector( onTap: () { @@ -164,8 +271,8 @@ class _WaypointListPanelState extends ConsumerState { ? WaypointChip( value: wp.altitudeM, unit: ' m', - min: widget.altMin, - max: widget.altMax, + min: DefaultDroneProfile.altMin, + max: DefaultDroneProfile.altMax, onChanged: (v) => widget.onAltitudeChanged(index, v), ) : Text('${wp.altitudeM.round()} m', @@ -176,8 +283,8 @@ class _WaypointListPanelState extends ConsumerState { ? WaypointChip( value: wp.speedMs, unit: ' m/s', - min: widget.speedMin, - max: widget.speedMax, + min: DefaultDroneProfile.speedMin, + max: DefaultDroneProfile.speedMax, onChanged: (v) => widget.onSpeedChanged(index, v), ) : Text('${wp.speedMs.round()} m/s', diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index fc9a670..9d29922 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -8,6 +8,7 @@ import 'package:dmc_app/app_mode/app_mode_cubit.dart'; import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; +import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; import 'package:dmc_app/ui/widgets/waypoint_chip.dart'; @@ -185,7 +186,11 @@ void main() { await _pumpWithContainer(tester); await tester.tap(find.text('Details:')); + // Die Liste wird als eigene Route mit Fade-Transition gepusht: erst ein + // Frame ohne Dauer, damit der synchron ausgeloeste Navigator.push() + // registriert wird, danach die Animationszeit abwarten. await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(WaypointListPanel), findsOneWidget); expect(find.text('No waypoints yet'), findsOneWidget); @@ -200,10 +205,13 @@ void main() { await tester.tap(find.text('Details:')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(WaypointListPanel), findsOneWidget); await tester.tap(find.text('60 m').first); + // Navigator.pop() spielt ebenfalls eine (Rueck-)Transition ab. await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(WaypointListPanel), findsNothing); }); @@ -216,6 +224,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(container.read(currentMissionProvider), hasLength(1)); @@ -235,6 +244,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // Icons.edit erscheint auch auf dem ReticleButton im Modus "snapped" - // gezielt das Edit-Icon der Listenzeile ansprechen. @@ -253,4 +263,50 @@ void main() { final waypoints = container.read(currentMissionProvider); expect(waypoints.single.altitudeM, greaterThan(60)); }); + + testWidgets( + 'Altitude-Tab aendert die Hoehe per Drag auf dem Chart-Punkt', + (tester) async { + final container = await _pumpWithContainer(tester); + + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + await tester.tap(find.text('Details:')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.text('Altitude')); + await tester.pump(); + expect(find.byType(FullValueChart), findsOneWidget); + + final chartCenter = tester.getCenter(find.byType(FullValueChart)); + await tester.dragFrom(chartCenter, const Offset(0, -100)); + await tester.pump(); + + final waypoints = container.read(currentMissionProvider); + expect(waypoints.single.altitudeM, greaterThan(60)); + }); + + testWidgets( + 'Speed-Tab aendert die Geschwindigkeit per Drag auf dem Chart-Punkt', + (tester) async { + final container = await _pumpWithContainer(tester); + + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + await tester.tap(find.text('Details:')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.text('Speed')); + await tester.pump(); + expect(find.byType(FullValueChart), findsOneWidget); + + final chartCenter = tester.getCenter(find.byType(FullValueChart)); + await tester.dragFrom(chartCenter, const Offset(0, -50)); + await tester.pump(); + + final waypoints = container.read(currentMissionProvider); + expect(waypoints.single.speedMs, greaterThan(15)); + }); }