- 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>
502 lines
17 KiB
Dart
502 lines
17 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/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';
|
|
import '../../widgets/waypoint_list_panel.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;
|
|
|
|
// 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 _speedPxPerStep = 14.0;
|
|
static const _speedMajorEvery = 5;
|
|
|
|
double _curAlt = DefaultDroneProfile.altitudeM;
|
|
double _curSpeed = DefaultDroneProfile.speedMs;
|
|
|
|
// 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: DefaultDroneProfile.catchRadiusM,
|
|
),
|
|
);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Wegpunktliste (Doku 3.6): Aenderungen wirken auf einen explizit
|
|
// angegebenen Index, nicht zwingend den aktuellen Reticle-Zielpunkt.
|
|
|
|
void _onRowAltitudeChanged(int index, double value) {
|
|
ref.read(currentMissionProvider.notifier).setAltitude(index, value);
|
|
if (_mode == _ReticleMode.editing && index == _targetIndex) {
|
|
setState(() => _curAlt = value);
|
|
}
|
|
}
|
|
|
|
void _onRowSpeedChanged(int index, double value) {
|
|
ref.read(currentMissionProvider.notifier).setSpeed(index, value);
|
|
if (_mode == _ReticleMode.editing && index == _targetIndex) {
|
|
setState(() => _curSpeed = value);
|
|
}
|
|
}
|
|
|
|
void _onRowCatchRadiusChanged(int index, double value) {
|
|
ref.read(currentMissionProvider.notifier).setCatchRadius(index, value);
|
|
}
|
|
|
|
void _onRowActionChanged(int index, WaypointAction action) {
|
|
ref.read(currentMissionProvider.notifier).setAction(index, action);
|
|
}
|
|
|
|
void _onRowDelete(int index) {
|
|
ref.read(currentMissionProvider.notifier).removeAt(index);
|
|
setState(() {
|
|
if (_targetIndex == index) {
|
|
_mode = _ReticleMode.idle;
|
|
_targetIndex = -1;
|
|
} else if (_targetIndex > index) {
|
|
_targetIndex -= 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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];
|
|
_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<void>(
|
|
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<Waypoint> waypoints,
|
|
) {
|
|
var totalDist = 0.0;
|
|
var totalDur = 0.0;
|
|
for (var i = 0; i < waypoints.length - 1; i++) {
|
|
final a = LatLng(waypoints[i].lat, waypoints[i].lon);
|
|
final b = LatLng(waypoints[i + 1].lat, waypoints[i + 1].lon);
|
|
final d = _distance(a, b);
|
|
totalDist += d;
|
|
totalDur += d / waypoints[i + 1].speedMs;
|
|
}
|
|
return (distanceM: totalDist, durationSec: totalDur);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final waypoints = ref.watch(currentMissionProvider);
|
|
|
|
final routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
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<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,
|
|
);
|
|
// 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: [
|
|
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 + safeEdgeInset,
|
|
child: ValueWheel(
|
|
value: _curAlt,
|
|
min: DefaultDroneProfile.altMin,
|
|
max: DefaultDroneProfile.altMax,
|
|
step: DefaultDroneProfile.altStep,
|
|
pxPerStep: _altPxPerStep,
|
|
majorEvery: _altMajorEvery,
|
|
onChanged: _onAltChanged,
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 56,
|
|
bottom: wheelBottomInset,
|
|
left: 70 + safeEdgeInset,
|
|
child: Center(child: _readout('Alt', _curAlt, 'm')),
|
|
),
|
|
Positioned(
|
|
top: 56,
|
|
bottom: wheelBottomInset,
|
|
right: 14 + safeEdgeInset,
|
|
child: ValueWheel(
|
|
value: _curSpeed,
|
|
min: DefaultDroneProfile.speedMin,
|
|
max: DefaultDroneProfile.speedMax,
|
|
step: DefaultDroneProfile.speedStep,
|
|
pxPerStep: _speedPxPerStep,
|
|
majorEvery: _speedMajorEvery,
|
|
onChanged: _onSpeedChanged,
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 56,
|
|
bottom: wheelBottomInset,
|
|
right: 70 + safeEdgeInset,
|
|
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (anyBad)
|
|
Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFF7C2D12),
|
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
|
child: const Text(
|
|
'Route exceeds drone capabilities at highlighted point',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Color(0xFFFDE3D0), fontSize: 12),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|