From 3091476771310b64dffc6ec3e1bcc7e5f9d568b1 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:01 +0200 Subject: [PATCH] Fix wheel/panel layering, add bottom-bar background, and full-screen charts - Wheels/readouts now add max(MediaQuery.padding.left/right) on both sides symmetrically (HTML prototype: max(env(safe-area-inset-left), env(safe-area-inset-right))), so a front-camera cutout in landscape doesn't sit behind the altitude wheel, and both sides stay in sync. - WaypointListPanel is now pushed via Navigator (PageRouteBuilder + fade) instead of being an internal Stack overlay in PlanScreen. It was rendering *under* AppShell's TopModeBar before, since TopModeBar is always the last (topmost) child of AppShell's own Stack regardless of what PlanScreen draws internally - a plain bool flag inside PlanScreen had no way to paint above a sibling higher up the tree. A pushed route paints above the entire invoking route's content by construction, so this was the actual fix rather than reshuffling Z-order inside PlanScreen. - Extracted DefaultDroneProfile (T1 Ranger constants) out of PlanScreen's private statics into lib/domain/mission/, since WaypointListPanel now needs the same values to compute its own route geometry reactively (previously passed down as a vertexBad/altMin/altMax/... snapshot, which would have gone stale while the panel was open across a route boundary). - Bottom bar now has the prototype's rgba(15,15,15,0.72) background (previously fully transparent outside the stats pill), and gained MiniAltitudeProfile - the non-interactive height-profile sparkline that sits above the stats row. - Added the Altitude/Speed full-screen chart tabs (FullValueChart): drag any waypoint's point to adjust its altitude/speed, X position proportional to cumulative route distance, matching the prototype's buildFullChart()/attachChartDrag(). Terrain overlay and wind diamonds from the prototype aren't ported - terrain (doc 3.9) and wind (doc 3.8) systems don't exist yet in the Flutter app. - RouteGeometry now also exposes legClimbBad (was computed internally but not surfaced), needed by both the mini profile and the chart tabs. Discovered while updating the widget tests: a single tester.pump() after tapping something that triggers Navigator.push/pop isn't enough - the push/pop itself needs a frame to register before a duration-based pump can animate it, so both are needed in sequence. Added 2 new tests for the chart tabs; all 17 tests and flutter analyze pass. Verified all four changes on the Pixel_10a emulator: wheels, opaque bottom bar with the mini profile, the list panel now fully covering the top bar (confirmed by its complete absence while the panel is open), and dragging points on both chart tabs. Co-Authored-By: Claude Sonnet 5 --- .../domain/mission/default_drone_profile.dart | 20 ++ app/lib/domain/mission/route_geometry.dart | 19 +- app/lib/ui/screens/plan/plan_screen.dart | 151 ++++++---- app/lib/ui/widgets/full_value_chart.dart | 278 ++++++++++++++++++ app/lib/ui/widgets/mini_altitude_profile.dart | 144 +++++++++ app/lib/ui/widgets/waypoint_list_panel.dart | 203 ++++++++++--- app/test/widget_test.dart | 56 ++++ 7 files changed, 758 insertions(+), 113 deletions(-) create mode 100644 app/lib/domain/mission/default_drone_profile.dart create mode 100644 app/lib/ui/widgets/full_value_chart.dart create mode 100644 app/lib/ui/widgets/mini_altitude_profile.dart 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)); + }); }