Add waypoint route line, snap-to-edit, and halo action menu
Full reticle interaction state machine (idle/snapped/editing), matching the HTML prototype's mode variable: - idle: reticle shows "+", tap drops a new waypoint at the map center. - snapped: after any pan ends, if the map center lands within the reticle's on-screen radius (converted to meters at the current zoom/latitude) of an existing waypoint, the view auto-recenters onto it and the reticle switches to a pencil icon. Tap to enter editing. - editing: reticle shows a checkmark; panning the map now live-updates the target waypoint's position (drag-to-reposition) instead of moving the camera "away" from it, and the HaloMenu radial menu appears. HaloMenu: three ring-wedge hit regions (custom ClipPath/CustomClipper, angles/radii taken from the prototype's buildRingWedgePath) for Loiter/Landing/Remove, with Material icons standing in for the demonstrator's hand-drawn SVGs. Loiter/Landing toggle the waypoint's action and drop back to snapped; Remove deletes it and returns to idle. MissionMap now draws a straight white polyline connecting all waypoints in order (the physically-grounded straight-line + fillet-arc geometry from doc 3.7/4.6 is still open - this is a straight-line placeholder) plus a dashed rubber-band preview from the last waypoint to the live map center while idle. Note on porting from Leaflet: flutter_map's MapController.move() emits MapEventMove, not MapEventMoveEnd, so (unlike the prototype's panTo()) the auto-recenter-on-snap doesn't re-trigger snap detection - no "autoPanning" guard flag was needed. Verified with flutter analyze, flutter test (three new tests: halo menu open/close, remove, loiter toggle - the wedge tap tests had to target the icon's exact center via tapAt(), since a wedge's bounding-box center falls outside its actual donut-segment hit area), and a full manual pass on the Pixel_10a emulator: idle -> add -> snap (auto-recenter confirmed) -> edit -> drag-reposition (confirmed via re-snap after further panning showing the moved position, not the original one) -> halo remove -> back to idle, plus the connecting line rendering correctly between two waypoints set far apart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
db6b46140c
commit
6dcb765b80
@@ -13,6 +13,37 @@ class CurrentMissionNotifier extends Notifier<List<Waypoint>> {
|
|||||||
void addWaypoint(Waypoint waypoint) {
|
void addWaypoint(Waypoint waypoint) {
|
||||||
state = [...state, waypoint];
|
state = [...state, waypoint];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aktualisiert die Position eines Wegpunkts (Drag-to-Reposition im
|
||||||
|
/// Editier-Modus: Kartenverschieben bewegt den Zielpunkt live mit).
|
||||||
|
void moveWaypoint(int index, double lat, double lon) {
|
||||||
|
state = [
|
||||||
|
for (var i = 0; i < state.length; i++)
|
||||||
|
if (i == index) state[i].copyWith(lat: lat, lon: lon) 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) {
|
||||||
|
state = [
|
||||||
|
for (var i = 0; i < state.length; i++)
|
||||||
|
if (i == index)
|
||||||
|
state[i].copyWith(
|
||||||
|
action:
|
||||||
|
state[i].action == action ? WaypointAction.none : action,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
state[i],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAt(int index) {
|
||||||
|
state = [
|
||||||
|
for (var i = 0; i < state.length; i++)
|
||||||
|
if (i != index) state[i],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentMissionProvider =
|
final currentMissionProvider =
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -5,13 +8,20 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../../providers/current_mission_provider.dart';
|
import '../../providers/current_mission_provider.dart';
|
||||||
|
import '../../widgets/halo_menu.dart';
|
||||||
import '../../widgets/mission_map.dart';
|
import '../../widgets/mission_map.dart';
|
||||||
import '../../widgets/reticle_button.dart';
|
import '../../widgets/reticle_button.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).
|
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
|
||||||
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
|
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
|
||||||
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber. Wegpunktliste,
|
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber.
|
||||||
/// Draeder und Bottom-Bar folgen als naechster Schritt.
|
|
||||||
class PlanScreen extends ConsumerStatefulWidget {
|
class PlanScreen extends ConsumerStatefulWidget {
|
||||||
const PlanScreen({super.key});
|
const PlanScreen({super.key});
|
||||||
|
|
||||||
@@ -21,6 +31,11 @@ class PlanScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _PlanScreenState extends ConsumerState<PlanScreen> {
|
class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||||
final _mapController = MapController();
|
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
|
// TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die
|
||||||
// Profilverwaltung existiert. Werte entsprechen dem Default-Profil
|
// Profilverwaltung existiert. Werte entsprechen dem Default-Profil
|
||||||
@@ -29,14 +44,96 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
static const _defaultSpeedMs = 15.0;
|
static const _defaultSpeedMs = 15.0;
|
||||||
static const _defaultCatchRadiusM = 60.0;
|
static const _defaultCatchRadiusM = 60.0;
|
||||||
|
|
||||||
|
// 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_mapEventSub?.cancel();
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addWaypointAtMapCenter() {
|
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;
|
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(
|
ref.read(currentMissionProvider.notifier).addWaypoint(
|
||||||
Waypoint(
|
Waypoint(
|
||||||
lat: center.latitude,
|
lat: center.latitude,
|
||||||
@@ -46,32 +143,102 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
catchRadiusM: _defaultCatchRadiusM,
|
catchRadiusM: _defaultCatchRadiusM,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setState(() {
|
||||||
|
_targetIndex = waypoints.length;
|
||||||
|
_mode = _ReticleMode.snapped;
|
||||||
|
});
|
||||||
|
case _ReticleMode.snapped:
|
||||||
|
setState(() => _mode = _ReticleMode.editing);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final waypoints = ref.watch(currentMissionProvider);
|
final waypoints = ref.watch(currentMissionProvider);
|
||||||
|
|
||||||
|
final routePoints = [
|
||||||
|
for (final wp in waypoints) LatLng(wp.lat, wp.lon),
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
MissionMap(
|
MissionMap(
|
||||||
mapController: _mapController,
|
mapController: _mapController,
|
||||||
|
onMapReady: () => setState(() => _mapReady = true),
|
||||||
|
routeLine: routePoints,
|
||||||
|
rubberBandLine: rubberBand,
|
||||||
waypointMarkers: [
|
waypointMarkers: [
|
||||||
for (final wp in waypoints)
|
for (var i = 0; i < waypoints.length; i++)
|
||||||
CircleMarker(
|
_markerFor(waypoints[i], isTarget: i == _targetIndex),
|
||||||
point: LatLng(wp.lat, wp.lon),
|
|
||||||
radius: 6,
|
|
||||||
color: const Color(0xFF2B2B28),
|
|
||||||
borderStrokeWidth: 1.5,
|
|
||||||
borderColor: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: ReticleButton(onTap: _addWaypointAtMapCenter),
|
child: ReticleButton(icon: _reticleIcon, onTap: _onReticleTap),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData get _reticleIcon => switch (_mode) {
|
||||||
|
_ReticleMode.idle => Icons.add,
|
||||||
|
_ReticleMode.snapped => Icons.edit,
|
||||||
|
_ReticleMode.editing => Icons.check,
|
||||||
|
};
|
||||||
|
|
||||||
|
CircleMarker _markerFor(Waypoint wp, {required bool isTarget}) {
|
||||||
|
final highlighted = isTarget && _mode != _ReticleMode.idle;
|
||||||
|
return CircleMarker(
|
||||||
|
point: LatLng(wp.lat, wp.lon),
|
||||||
|
radius: highlighted ? 9 : 6,
|
||||||
|
color: const Color(0xFF2B2B28),
|
||||||
|
borderStrokeWidth: highlighted ? 3 : 1.5,
|
||||||
|
borderColor: Colors.white,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Radialmenue um den Reticle-Button im Editier-Modus (HTML-Demonstrator:
|
||||||
|
/// #haloSvg mit den drei Ring-Segmenten Loiter/Landing/Remove).
|
||||||
|
enum HaloAction { loiter, landing, remove }
|
||||||
|
|
||||||
|
class HaloMenu extends StatelessWidget {
|
||||||
|
const HaloMenu({
|
||||||
|
super.key,
|
||||||
|
required this.loiterActive,
|
||||||
|
required this.landingActive,
|
||||||
|
required this.onAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool loiterActive;
|
||||||
|
final bool landingActive;
|
||||||
|
final void Function(HaloAction action) onAction;
|
||||||
|
|
||||||
|
// Radien/Groesse 1:1 aus dem Demonstrator uebernommen
|
||||||
|
// (HALO_SCALE=1.2, HALO_RIN=27, HALO_ROUT=60, viewBox 192x192).
|
||||||
|
static const _size = 192.0;
|
||||||
|
static const _innerRadius = 27 * 1.2;
|
||||||
|
static const _outerRadius = 60 * 1.2;
|
||||||
|
static const _midRadius = (_innerRadius + _outerRadius) / 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: _size,
|
||||||
|
height: _size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
_wedge(
|
||||||
|
key: const ValueKey('halo-loiter'),
|
||||||
|
startAngleDeg: -60,
|
||||||
|
endAngleDeg: 60,
|
||||||
|
active: loiterActive,
|
||||||
|
onTap: () => onAction(HaloAction.loiter),
|
||||||
|
),
|
||||||
|
_wedge(
|
||||||
|
key: const ValueKey('halo-landing'),
|
||||||
|
startAngleDeg: 60,
|
||||||
|
endAngleDeg: 180,
|
||||||
|
active: landingActive,
|
||||||
|
onTap: () => onAction(HaloAction.landing),
|
||||||
|
),
|
||||||
|
_wedge(
|
||||||
|
key: const ValueKey('halo-remove'),
|
||||||
|
startAngleDeg: 180,
|
||||||
|
endAngleDeg: 300,
|
||||||
|
active: false,
|
||||||
|
onTap: () => onAction(HaloAction.remove),
|
||||||
|
),
|
||||||
|
_wedgeIcon(angleDeg: 0, icon: Icons.loop),
|
||||||
|
_wedgeIcon(angleDeg: 120, icon: Icons.flight_land),
|
||||||
|
_wedgeIcon(angleDeg: 240, icon: Icons.remove),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _wedge({
|
||||||
|
Key? key,
|
||||||
|
required double startAngleDeg,
|
||||||
|
required double endAngleDeg,
|
||||||
|
required bool active,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return ClipPath(
|
||||||
|
key: key,
|
||||||
|
clipper: _WedgeClipper(
|
||||||
|
startAngleDeg: startAngleDeg,
|
||||||
|
endAngleDeg: endAngleDeg,
|
||||||
|
innerRadius: _innerRadius,
|
||||||
|
outerRadius: _outerRadius,
|
||||||
|
),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
color: active
|
||||||
|
? const Color(0xFF4D9DFF).withValues(alpha: 0.55)
|
||||||
|
: Colors.black.withValues(alpha: 0.62),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _wedgeIcon({required double angleDeg, required IconData icon}) {
|
||||||
|
final point = _polarPoint(angleDeg, _midRadius);
|
||||||
|
return Positioned(
|
||||||
|
left: _size / 2 + point.dx - 12,
|
||||||
|
top: _size / 2 + point.dy - 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Icon(icon, color: Colors.white, size: 22),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Offset _polarPoint(double angleDeg, double radius) {
|
||||||
|
final rad = angleDeg * math.pi / 180;
|
||||||
|
return Offset(radius * math.sin(rad), -radius * math.cos(rad));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WedgeClipper extends CustomClipper<Path> {
|
||||||
|
const _WedgeClipper({
|
||||||
|
required this.startAngleDeg,
|
||||||
|
required this.endAngleDeg,
|
||||||
|
required this.innerRadius,
|
||||||
|
required this.outerRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double startAngleDeg;
|
||||||
|
final double endAngleDeg;
|
||||||
|
final double innerRadius;
|
||||||
|
final double outerRadius;
|
||||||
|
|
||||||
|
static const _steps = 18;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Path getClip(Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
|
||||||
|
Offset pointAt(double angleDeg, double radius) {
|
||||||
|
final rad = angleDeg * math.pi / 180;
|
||||||
|
return Offset(
|
||||||
|
center.dx + radius * math.sin(rad),
|
||||||
|
center.dy - radius * math.cos(rad),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = Path();
|
||||||
|
final first = pointAt(startAngleDeg, outerRadius);
|
||||||
|
path.moveTo(first.dx, first.dy);
|
||||||
|
for (var i = 1; i <= _steps; i++) {
|
||||||
|
final a = startAngleDeg + (endAngleDeg - startAngleDeg) * i / _steps;
|
||||||
|
final p = pointAt(a, outerRadius);
|
||||||
|
path.lineTo(p.dx, p.dy);
|
||||||
|
}
|
||||||
|
for (var i = _steps; i >= 0; i--) {
|
||||||
|
final a = startAngleDeg + (endAngleDeg - startAngleDeg) * i / _steps;
|
||||||
|
final p = pointAt(a, innerRadius);
|
||||||
|
path.lineTo(p.dx, p.dy);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(covariant _WedgeClipper oldClipper) =>
|
||||||
|
oldClipper.startAngleDeg != startAngleDeg ||
|
||||||
|
oldClipper.endAngleDeg != endAngleDeg ||
|
||||||
|
oldClipper.innerRadius != innerRadius ||
|
||||||
|
oldClipper.outerRadius != outerRadius;
|
||||||
|
}
|
||||||
@@ -6,13 +6,31 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
|
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
|
||||||
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
|
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
|
||||||
class MissionMap extends StatelessWidget {
|
class MissionMap extends StatelessWidget {
|
||||||
const MissionMap({super.key, this.mapController, this.waypointMarkers = const []});
|
const MissionMap({
|
||||||
|
super.key,
|
||||||
|
this.mapController,
|
||||||
|
this.waypointMarkers = const [],
|
||||||
|
this.routeLine,
|
||||||
|
this.rubberBandLine,
|
||||||
|
this.onMapReady,
|
||||||
|
});
|
||||||
|
|
||||||
final MapController? mapController;
|
final MapController? mapController;
|
||||||
|
|
||||||
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
|
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
|
||||||
final List<CircleMarker> waypointMarkers;
|
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;
|
||||||
|
|
||||||
|
/// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen
|
||||||
|
/// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde.
|
||||||
|
final List<LatLng>? rubberBandLine;
|
||||||
|
|
||||||
|
final VoidCallback? onMapReady;
|
||||||
|
|
||||||
// Default-Zentrum/Zoom uebernommen aus dem HTML-Demonstrator
|
// Default-Zentrum/Zoom uebernommen aus dem HTML-Demonstrator
|
||||||
// (Drone Mission Control.html: L.map(...).setView([52.3676, 4.9041], 16)).
|
// (Drone Mission Control.html: L.map(...).setView([52.3676, 4.9041], 16)).
|
||||||
static const _initialCenter = LatLng(52.3676, 4.9041);
|
static const _initialCenter = LatLng(52.3676, 4.9041);
|
||||||
@@ -22,11 +40,12 @@ class MissionMap extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FlutterMap(
|
return FlutterMap(
|
||||||
mapController: mapController,
|
mapController: mapController,
|
||||||
options: const MapOptions(
|
options: MapOptions(
|
||||||
initialCenter: _initialCenter,
|
initialCenter: _initialCenter,
|
||||||
initialZoom: _initialZoom,
|
initialZoom: _initialZoom,
|
||||||
minZoom: 3,
|
minZoom: 3,
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
|
onMapReady: onMapReady,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
TileLayer(
|
||||||
@@ -34,6 +53,19 @@ class MissionMap extends StatelessWidget {
|
|||||||
userAgentPackageName: 'de.dmc.dmc_app',
|
userAgentPackageName: 'de.dmc.dmc_app',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
),
|
),
|
||||||
|
PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
if (routeLine != null && routeLine!.length > 1)
|
||||||
|
Polyline(points: routeLine!, strokeWidth: 3, color: Colors.white),
|
||||||
|
if (rubberBandLine != null)
|
||||||
|
Polyline(
|
||||||
|
points: rubberBandLine!,
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white.withValues(alpha: 0.55),
|
||||||
|
pattern: StrokePattern.dashed(segments: const [6.0, 6.0]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
CircleLayer(circles: waypointMarkers),
|
CircleLayer(circles: waypointMarkers),
|
||||||
SimpleAttributionWidget(
|
SimpleAttributionWidget(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Zentrales Steuerelement zum Setzen von Wegpunkten (HTML-Demonstrator:
|
/// Zentrales Steuerelement zum Setzen/Editieren von Wegpunkten
|
||||||
/// #centerCluster/#reticleBtn), fixiert in der Bildschirmmitte. Die Karte
|
/// (HTML-Demonstrator: #centerCluster/#reticleBtn), fixiert in der
|
||||||
/// wird darunter verschoben; ein Tap setzt einen Wegpunkt an der aktuellen
|
/// Bildschirmmitte. Die Karte wird darunter verschoben. Icon wechselt je
|
||||||
/// Kartenmitte.
|
/// nach Modus: "+" (idle, neuer Wegpunkt), Stift (snapped, zum Bearbeiten
|
||||||
|
/// antippen), Haken (editing, bestaetigen).
|
||||||
class ReticleButton extends StatelessWidget {
|
class ReticleButton extends StatelessWidget {
|
||||||
const ReticleButton({super.key, required this.onTap});
|
const ReticleButton({super.key, required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,7 +23,7 @@ class ReticleButton extends StatelessWidget {
|
|||||||
color: Colors.black.withValues(alpha: 0.68),
|
color: Colors.black.withValues(alpha: 0.68),
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.add, color: Colors.white, size: 26),
|
child: Icon(icon, color: Colors.white, size: 26),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
|
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
|
||||||
|
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||||
import 'package:dmc_app/ui/app_shell.dart';
|
import 'package:dmc_app/ui/app_shell.dart';
|
||||||
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||||
|
import 'package:dmc_app/ui/widgets/halo_menu.dart';
|
||||||
import 'package:dmc_app/ui/widgets/reticle_button.dart';
|
import 'package:dmc_app/ui/widgets/reticle_button.dart';
|
||||||
|
|
||||||
Widget _wrap(Widget child) => ProviderScope(
|
Widget _wrap(Widget child) => ProviderScope(
|
||||||
@@ -72,4 +74,92 @@ void main() {
|
|||||||
expect(waypoints.single.lat, closeTo(52.3676, 0.0001));
|
expect(waypoints.single.lat, closeTo(52.3676, 0.0001));
|
||||||
expect(waypoints.single.lon, closeTo(4.9041, 0.0001));
|
expect(waypoints.single.lon, closeTo(4.9041, 0.0001));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue',
|
||||||
|
(tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (_) => AppModeCubit(),
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 1. Tap: idle -> snapped (legt Wegpunkt an).
|
||||||
|
await tester.tap(find.byType(ReticleButton));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(HaloMenu), findsNothing);
|
||||||
|
|
||||||
|
// 2. Tap: snapped -> editing.
|
||||||
|
await tester.tap(find.byType(ReticleButton));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(HaloMenu), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue',
|
||||||
|
(tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (_) => AppModeCubit(),
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ReticleButton)); // idle -> snapped
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byType(ReticleButton)); // snapped -> editing
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Die Wedge-Bounding-Box-Mitte liegt ausserhalb der Keilform (Donut-
|
||||||
|
// Segment) - stattdessen exakt auf das (nicht hit-testbare) Icon tippen,
|
||||||
|
// das per Konstruktion innerhalb des Keils sitzt.
|
||||||
|
await tester.tapAt(tester.getCenter(find.byIcon(Icons.remove)));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(container.read(currentMissionProvider), isEmpty);
|
||||||
|
expect(find.byType(HaloMenu), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue',
|
||||||
|
(tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (_) => AppModeCubit(),
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ReticleButton)); // idle -> snapped
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byType(ReticleButton)); // snapped -> editing
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tapAt(tester.getCenter(find.byIcon(Icons.loop)));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final waypoints = container.read(currentMissionProvider);
|
||||||
|
expect(waypoints.single.action, WaypointAction.loiter);
|
||||||
|
expect(find.byType(HaloMenu), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user