Add fillet flight path geometry and Alt/Speed wheels

- RouteGeometry (lib/domain/mission/route_geometry.dart): physically
  grounded flight path - straight segments + tangential arcs at each
  course change, sized from the drone's minimum turn radius (doc 3.7/4.6),
  replacing a naive spline that would suggest unrealistic turn radii.
  Marks a vertex "bad" (turn angle >= 160°, tangent length exceeding 90%
  of either adjacent leg, or exceeding the waypoint's catch radius) and a
  leg "bad" when the required climb/descent rate exceeds the drone's
  max climb/descent rate. Pure Dart, no Flutter dependency, so it stays
  usable if the mission domain is ever split into its own package (4.22).
  Wind-based turn-radius correction from the prototype isn't ported yet -
  the wind system (doc 3.8) doesn't exist in the Flutter app.
- MissionMap now draws each RouteSegment as its own Polyline, colored red
  when bad instead of a single plain white line; waypoint markers turn red
  too when their vertex is bad.
- ValueWheel: vertical drag-to-adjust tape control (HTML prototype's
  #altWheel/#spdWheel), custom-painted tick marks, blue center indicator,
  gradient fade at top/bottom. Wired into PlanScreen on both screen edges
  with Alt/Speed readouts.
- curAlt/curSpeed now live in PlanScreen state instead of fixed constants:
  dropping a new waypoint uses whatever the wheels are currently set to;
  entering editing mode on an existing waypoint loads its values into the
  wheels; adjusting a wheel while editing writes live into that waypoint
  (provider gained setAltitude/setSpeed to match the existing
  moveWaypoint/toggleAction pattern).

Added dedicated unit tests for the geometry (empty list, straight line,
feasible 90° turn producing a line-arc-line segment sequence, an
infeasible near-180° turn, and an infeasible descent rate) rather than
relying on eyeballing it on the emulator - this is exactly the kind of
ported-math correctness that's hard to verify visually but easy to get
subtly wrong. All 11 tests (previous 6 + these 5) and flutter analyze
pass. Also manually verified the wheels on the Pixel_10a emulator: drag
changes the value and the on-screen readout in real time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-27 23:13:24 +02:00
co-authored by Claude Sonnet 5
parent 6dcb765b80
commit b93bbab23b
6 changed files with 617 additions and 20 deletions
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
/// Vertikales Drehrad zum Einstellen eines Werts per Swipe (HTML-
/// Demonstrator: .wheel/#altWheel/#spdWheel + setupWheel()). Der Tick an
/// der Bildschirmmitte (blauer Indikator) zeigt den aktuellen Wert; nach
/// oben ziehen erhoeht den Wert.
class ValueWheel extends StatefulWidget {
const ValueWheel({
super.key,
required this.value,
required this.min,
required this.max,
required this.step,
required this.pxPerStep,
required this.majorEvery,
required this.onChanged,
});
final double value;
final double min;
final double max;
final double step;
final double pxPerStep;
final int majorEvery;
final ValueChanged<double> onChanged;
@override
State<ValueWheel> createState() => _ValueWheelState();
}
class _ValueWheelState extends State<ValueWheel> {
double? _dragStartY;
double _dragStartValue = 0;
void _onDragStart(DragStartDetails details) {
_dragStartY = details.globalPosition.dy;
_dragStartValue = widget.value;
}
void _onDragUpdate(DragUpdateDetails details) {
final startY = _dragStartY;
if (startY == null) return;
final deltaY = startY - details.globalPosition.dy;
final deltaSteps = deltaY / widget.pxPerStep;
final raw = _dragStartValue + deltaSteps * widget.step;
var stepped = (raw / widget.step).round() * widget.step;
stepped = stepped.clamp(widget.min, widget.max);
widget.onChanged(stepped);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: (_) => _dragStartY = null,
child: Container(
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: LinearGradient(
colors: [
Colors.black.withValues(alpha: 0.9),
const Color(0xFF333333).withValues(alpha: 0.9),
Colors.black.withValues(alpha: 0.9),
],
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (rect) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
Colors.black,
Colors.transparent,
],
stops: [0, 0.1, 0.9, 1],
).createShader(rect),
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: CustomPaint(
painter: _TapePainter(
value: widget.value,
min: widget.min,
max: widget.max,
step: widget.step,
pxPerStep: widget.pxPerStep,
majorEvery: widget.majorEvery,
),
),
),
Container(height: 2, color: const Color(0xFF4D9DFF)),
],
),
),
),
),
);
}
}
class _TapePainter extends CustomPainter {
const _TapePainter({
required this.value,
required this.min,
required this.max,
required this.step,
required this.pxPerStep,
required this.majorEvery,
});
final double value;
final double min;
final double max;
final double step;
final double pxPerStep;
final int majorEvery;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final centerX = size.width / 2;
final majorPaint = Paint()..color = Colors.white.withValues(alpha: 0.85);
final minorPaint = Paint()..color = Colors.white.withValues(alpha: 0.32);
final nearestGridIndex = ((value - min) / step).round();
final visibleHalfCount = (size.height / 2 / pxPerStep).ceil() + 1;
for (var i = -visibleHalfCount; i <= visibleHalfCount; i++) {
final gridIndex = nearestGridIndex + i;
final tickValue = min + gridIndex * step;
if (tickValue < min || tickValue > max) continue;
final y = centerY + (tickValue - value) / step * pxPerStep;
final isMajor = gridIndex % majorEvery == 0;
final halfWidth = size.width * (isMajor ? 0.35 : 0.19);
final thickness = isMajor ? 2.0 : 1.0;
canvas.drawRect(
Rect.fromCenter(
center: Offset(centerX, y),
width: halfWidth * 2,
height: thickness,
),
isMajor ? majorPaint : minorPaint,
);
}
}
@override
bool shouldRepaint(covariant _TapePainter oldDelegate) =>
oldDelegate.value != value ||
oldDelegate.min != min ||
oldDelegate.max != max ||
oldDelegate.step != step;
}