- 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>
360 lines
11 KiB
Dart
360 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
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.
|
|
/// snapped: Kartenmitte ist auf einen bestehenden Wegpunkt eingerastet.
|
|
/// editing: Wegpunkt wird bearbeitet - Kartenverschieben repositioniert ihn
|
|
/// live, das Halo-Menue (Loiter/Landing/Entfernen) ist sichtbar.
|
|
enum _ReticleMode { idle, snapped, editing }
|
|
|
|
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
|
|
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
|
|
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber.
|
|
class PlanScreen extends ConsumerStatefulWidget {
|
|
const PlanScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<PlanScreen> createState() => _PlanScreenState();
|
|
}
|
|
|
|
class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|
final _mapController = MapController();
|
|
StreamSubscription<MapEvent>? _mapEventSub;
|
|
|
|
_ReticleMode _mode = _ReticleMode.idle;
|
|
int _targetIndex = -1;
|
|
bool _mapReady = 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;
|
|
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;
|
|
|
|
static const _distance = Distance(roundResult: false);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_mapEventSub?.cancel();
|
|
_mapController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onMapEvent(MapEvent event) {
|
|
if (event is MapEventMoveEnd) {
|
|
_handleMoveEnd();
|
|
} else if (event is MapEventMove) {
|
|
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
if (_targetIndex < waypoints.length) {
|
|
final center = _mapController.camera.center;
|
|
ref.read(currentMissionProvider.notifier).moveWaypoint(
|
|
_targetIndex,
|
|
center.latitude,
|
|
center.longitude,
|
|
);
|
|
}
|
|
} else if (_mode == _ReticleMode.idle) {
|
|
// Nur fuer die gestrichelte Vorschaulinie neu zeichnen.
|
|
setState(() {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Metergenauer Fangradius des Reticle bei aktuellem Zoom/Breitengrad
|
|
/// (HTML-Demonstrator: currentSnapMeters()).
|
|
double _currentSnapMeters() {
|
|
final camera = _mapController.camera;
|
|
final metersPerPixel = 156543.03392 *
|
|
math.cos(camera.center.latitude * math.pi / 180) /
|
|
math.pow(2, camera.zoom);
|
|
return _reticleRadiusPx * metersPerPixel;
|
|
}
|
|
|
|
void _handleMoveEnd() {
|
|
if (_mode == _ReticleMode.editing) return;
|
|
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
final center = _mapController.camera.center;
|
|
final snapMeters = _currentSnapMeters();
|
|
|
|
var bestIndex = -1;
|
|
var bestDist = snapMeters;
|
|
for (var i = 0; i < waypoints.length; i++) {
|
|
final d = _distance(center, LatLng(waypoints[i].lat, waypoints[i].lon));
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
if (bestIndex >= 0) {
|
|
_mode = _ReticleMode.snapped;
|
|
_targetIndex = bestIndex;
|
|
final target = waypoints[bestIndex];
|
|
final targetLatLng = LatLng(target.lat, target.lon);
|
|
if (_distance(center, targetLatLng) > 0.5) {
|
|
// flutter_map's MapController.move() emits MapEventMove, nicht
|
|
// MoveEnd - anders als Leaflets panTo() droht hier also keine
|
|
// Rekursion in _handleMoveEnd, ein Guard ist nicht noetig.
|
|
_mapController.move(targetLatLng, _mapController.camera.zoom);
|
|
}
|
|
} else {
|
|
_mode = _ReticleMode.idle;
|
|
_targetIndex = -1;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onReticleTap() {
|
|
switch (_mode) {
|
|
case _ReticleMode.idle:
|
|
final center = _mapController.camera.center;
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
ref.read(currentMissionProvider.notifier).addWaypoint(
|
|
Waypoint(
|
|
lat: center.latitude,
|
|
lon: center.longitude,
|
|
altitudeM: _curAlt,
|
|
speedMs: _curSpeed,
|
|
catchRadiusM: _defaultCatchRadiusM,
|
|
),
|
|
);
|
|
setState(() {
|
|
_targetIndex = waypoints.length;
|
|
_mode = _ReticleMode.snapped;
|
|
});
|
|
case _ReticleMode.snapped:
|
|
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);
|
|
}
|
|
}
|
|
|
|
void _onHaloAction(HaloAction action) {
|
|
if (_mode != _ReticleMode.editing || _targetIndex < 0) return;
|
|
final notifier = ref.read(currentMissionProvider.notifier);
|
|
|
|
switch (action) {
|
|
case HaloAction.loiter:
|
|
notifier.toggleAction(_targetIndex, WaypointAction.loiter);
|
|
setState(() => _mode = _ReticleMode.snapped);
|
|
case HaloAction.landing:
|
|
notifier.toggleAction(_targetIndex, WaypointAction.landing);
|
|
setState(() => _mode = _ReticleMode.snapped);
|
|
case HaloAction.remove:
|
|
notifier.removeAt(_targetIndex);
|
|
setState(() {
|
|
_mode = _ReticleMode.idle;
|
|
_targetIndex = -1;
|
|
});
|
|
}
|
|
}
|
|
|
|
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 routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
minTurnRadius: _minTurnRadius,
|
|
maxClimbRate: _maxClimbRate,
|
|
maxDescentRate: _maxDescentRate,
|
|
);
|
|
|
|
List<LatLng>? rubberBand;
|
|
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
|
|
final last = waypoints.last;
|
|
rubberBand = [
|
|
LatLng(last.lat, last.lon),
|
|
_mapController.camera.center,
|
|
];
|
|
}
|
|
|
|
final targetWaypoint =
|
|
(_targetIndex >= 0 && _targetIndex < waypoints.length)
|
|
? 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),
|
|
routeSegments: routeGeometry.segments,
|
|
rubberBandLine: rubberBand,
|
|
waypointMarkers: [
|
|
for (var i = 0; i < waypoints.length; i++)
|
|
_markerFor(
|
|
waypoints[i],
|
|
isTarget: i == _targetIndex,
|
|
isBad: i < routeGeometry.vertexBad.length &&
|
|
routeGeometry.vertexBad[i],
|
|
),
|
|
],
|
|
),
|
|
if (_mode == _ReticleMode.editing && targetWaypoint != null)
|
|
Align(
|
|
alignment: Alignment.center,
|
|
child: HaloMenu(
|
|
loiterActive: targetWaypoint.action == WaypointAction.loiter,
|
|
landingActive: targetWaypoint.action == WaypointAction.landing,
|
|
onAction: _onHaloAction,
|
|
),
|
|
),
|
|
Align(
|
|
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,
|
|
required bool isBad,
|
|
}) {
|
|
final highlighted = isTarget && _mode != _ReticleMode.idle;
|
|
return CircleMarker(
|
|
point: LatLng(wp.lat, wp.lon),
|
|
radius: highlighted ? 9 : 6,
|
|
color: isBad ? const Color(0xFFE2574A) : const Color(0xFF2B2B28),
|
|
borderStrokeWidth: highlighted ? 3 : 1.5,
|
|
borderColor: Colors.white,
|
|
);
|
|
}
|
|
}
|