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
@@ -23,6 +23,24 @@ class CurrentMissionNotifier extends Notifier<List<Waypoint>> {
];
}
/// Aktualisiert die Zielhoehe eines Wegpunkts (Alt-Drehrad im
/// Editier-Modus schreibt live in den Zielpunkt).
void setAltitude(int index, double altitudeM) {
state = [
for (var i = 0; i < state.length; i++)
if (i == index) state[i].copyWith(altitudeM: altitudeM) else state[i],
];
}
/// Aktualisiert die Zielgeschwindigkeit eines Wegpunkts (Speed-Drehrad im
/// Editier-Modus schreibt live in den Zielpunkt).
void setSpeed(int index, double speedMs) {
state = [
for (var i = 0; i < state.length; i++)
if (i == index) state[i].copyWith(speedMs: speedMs) else state[i],
];
}
/// Schaltet die Execute-Aktion eines Wegpunkts um (Halo-Radialmenue:
/// erneutes Antippen derselben Aktion setzt sie zurueck auf "none").
void toggleAction(int index, WaypointAction action) {
+128 -13
View File
@@ -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/route_geometry.dart';
import '../../../domain/waypoint/flat_waypoint_list.dart';
import '../../providers/current_mission_provider.dart';
import '../../widgets/halo_menu.dart';
import '../../widgets/mission_map.dart';
import '../../widgets/reticle_button.dart';
import '../../widgets/value_wheel.dart';
/// Reticle-Zustand (HTML-Demonstrator: Variable `mode`).
/// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an.
@@ -39,10 +41,26 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
// TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die
// Profilverwaltung existiert. Werte entsprechen dem Default-Profil
// T1 Ranger im HTML-Demonstrator (curAlt=60, cruiseSpeed=15, catchRadius=60).
static const _defaultAltitudeM = 60.0;
static const _defaultSpeedMs = 15.0;
// T1 Ranger im HTML-Demonstrator (DEFAULT_DRONE).
static const _altMin = -500.0;
static const _altMax = 2000.0;
static const _altStep = 10.0;
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;
// Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser).
static const _reticleRadiusPx = 55 / 2;
@@ -138,8 +156,8 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
Waypoint(
lat: center.latitude,
lon: center.longitude,
altitudeM: _defaultAltitudeM,
speedMs: _defaultSpeedMs,
altitudeM: _curAlt,
speedMs: _curSpeed,
catchRadiusM: _defaultCatchRadiusM,
),
);
@@ -148,7 +166,15 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
_mode = _ReticleMode.snapped;
});
case _ReticleMode.snapped:
setState(() => _mode = _ReticleMode.editing);
final waypoints = ref.read(currentMissionProvider);
if (_targetIndex >= 0 && _targetIndex < waypoints.length) {
final target = waypoints[_targetIndex];
setState(() {
_mode = _ReticleMode.editing;
_curAlt = target.altitudeM;
_curSpeed = target.speedMs;
});
}
case _ReticleMode.editing:
setState(() => _mode = _ReticleMode.snapped);
}
@@ -174,13 +200,30 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
}
}
void _onAltChanged(double value) {
setState(() => _curAlt = value);
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
ref.read(currentMissionProvider.notifier).setAltitude(_targetIndex, value);
}
}
void _onSpeedChanged(double value) {
setState(() => _curSpeed = value);
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
ref.read(currentMissionProvider.notifier).setSpeed(_targetIndex, value);
}
}
@override
Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider);
final routePoints = [
for (final wp in waypoints) LatLng(wp.lat, wp.lon),
];
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: _minTurnRadius,
maxClimbRate: _maxClimbRate,
maxDescentRate: _maxDescentRate,
);
List<LatLng>? rubberBand;
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
@@ -196,16 +239,26 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
? waypoints[_targetIndex]
: null;
final wheelBottomInset = math.max(
MediaQuery.of(context).size.height * 0.06,
124.0,
);
return Stack(
children: [
MissionMap(
mapController: _mapController,
onMapReady: () => setState(() => _mapReady = true),
routeLine: routePoints,
routeSegments: routeGeometry.segments,
rubberBandLine: rubberBand,
waypointMarkers: [
for (var i = 0; i < waypoints.length; i++)
_markerFor(waypoints[i], isTarget: i == _targetIndex),
_markerFor(
waypoints[i],
isTarget: i == _targetIndex,
isBad: i < routeGeometry.vertexBad.length &&
routeGeometry.vertexBad[i],
),
],
),
if (_mode == _ReticleMode.editing && targetWaypoint != null)
@@ -221,22 +274,84 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
alignment: Alignment.center,
child: ReticleButton(icon: _reticleIcon, onTap: _onReticleTap),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
left: 14,
child: ValueWheel(
value: _curAlt,
min: _altMin,
max: _altMax,
step: _altStep,
pxPerStep: _altPxPerStep,
majorEvery: _altMajorEvery,
onChanged: _onAltChanged,
),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
left: 70,
child: Center(child: _readout('Alt', _curAlt, 'm')),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
right: 14,
child: ValueWheel(
value: _curSpeed,
min: _speedMin,
max: _speedMax,
step: _speedStep,
pxPerStep: _speedPxPerStep,
majorEvery: _speedMajorEvery,
onChanged: _onSpeedChanged,
),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
right: 70,
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
),
],
);
}
Widget _readout(String label, double value, String unit) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$label: ${value.round()} $unit',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
);
}
IconData get _reticleIcon => switch (_mode) {
_ReticleMode.idle => Icons.add,
_ReticleMode.snapped => Icons.edit,
_ReticleMode.editing => Icons.check,
};
CircleMarker _markerFor(Waypoint wp, {required bool isTarget}) {
CircleMarker _markerFor(
Waypoint wp, {
required bool isTarget,
required bool isBad,
}) {
final highlighted = isTarget && _mode != _ReticleMode.idle;
return CircleMarker(
point: LatLng(wp.lat, wp.lon),
radius: highlighted ? 9 : 6,
color: const Color(0xFF2B2B28),
color: isBad ? const Color(0xFFE2574A) : const Color(0xFF2B2B28),
borderStrokeWidth: highlighted ? 3 : 1.5,
borderColor: Colors.white,
);
+12 -7
View File
@@ -3,6 +3,8 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../domain/mission/route_geometry.dart';
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
class MissionMap extends StatelessWidget {
@@ -10,7 +12,7 @@ class MissionMap extends StatelessWidget {
super.key,
this.mapController,
this.waypointMarkers = const [],
this.routeLine,
this.routeSegments = const [],
this.rubberBandLine,
this.onMapReady,
});
@@ -20,10 +22,9 @@ class MissionMap extends StatelessWidget {
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
final List<CircleMarker> waypointMarkers;
/// Verbindungslinie zwischen den Wegpunkten. Bewusst eine gerade Strecke -
/// die physikalisch fundierte Geraden+Kreisbogen-Geometrie (Doku 3.7/4.6)
/// ist ein separater, noch offener Schritt.
final List<LatLng>? routeLine;
/// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot
/// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius.
final List<RouteSegment> routeSegments;
/// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen
/// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde.
@@ -55,8 +56,12 @@ class MissionMap extends StatelessWidget {
),
PolylineLayer(
polylines: [
if (routeLine != null && routeLine!.length > 1)
Polyline(points: routeLine!, strokeWidth: 3, color: Colors.white),
for (final segment in routeSegments)
Polyline(
points: segment.points,
strokeWidth: 3,
color: segment.bad ? const Color(0xFFE2574A) : Colors.white,
),
if (rubberBandLine != null)
Polyline(
points: rubberBandLine!,
+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;
}