- 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 <noreply@anthropic.com>
279 lines
8.6 KiB
Dart
279 lines
8.6 KiB
Dart
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<Waypoint> waypoints;
|
|
final List<double> values;
|
|
final List<bool> legBad;
|
|
final List<bool> 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<double> cumulativeDistances(List<Waypoint> waypoints) {
|
|
final cum = <double>[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<FullValueChart> createState() => _FullValueChartState();
|
|
}
|
|
|
|
class _FullValueChartState extends State<FullValueChart> {
|
|
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<double> 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<double> 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<Waypoint> waypoints;
|
|
final List<double> values;
|
|
final List<double> cumDist;
|
|
final List<bool> legBad;
|
|
final List<bool> 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;
|
|
}
|