Add waypoint list panel and bottom stats bar
- BottomStatsBar: persistent "Details: N WP · dist m · duration" pill
(HTML prototype's #bottomBar/#statsRow), tap opens the full waypoint
list. Mission/drone name and the Send button are intentionally not part
of this - mission library and upload aren't wired up yet.
- A warn banner ("Route exceeds drone capabilities at highlighted point")
appears above it whenever buildRouteGeometry finds a bad vertex or leg -
a direct, low-effort payoff from the fillet geometry work.
- WaypointListPanel: full-screen overlay listing every waypoint
(altitude/speed/execute/catch radius), matching the prototype's List
tab. Only one row editable at a time; editing swaps plain text for
draggable WaypointChip values and an execute dropdown. Row tap (outside
edit mode) centers the map on that waypoint and closes the panel; delete
removes it and adjusts the reticle's target index the same way the halo
menu's remove already does.
- WaypointChip: vertical-drag value chip (HTML prototype's .value-chip),
fixed step of 10 per 14px matching chipFieldConfig - simpler than the
original's dual vertical/horizontal axis-lock gesture, which felt like
more fidelity than the interaction warrants here.
- Provider gained setCatchRadius and setAction (direct assignment, as
opposed to toggleAction's toggle-to-none semantics used by the halo
menu) to support the list's editable fields.
- Altitude/Speed full-chart tabs from the prototype (drag-to-adjust
altitude/speed profile charts) are a separate, larger feature and not
part of this step.
Centering on a waypoint from the list calls MapController.move(), which -
like the reticle's auto-snap - doesn't emit MoveEnd, so snap detection is
invoked manually afterward to match the prototype's resulting behavior.
Added 4 widget tests (open empty list, row-tap centers and closes, delete
removes and updates the empty state, chip drag updates altitude) - one
required a pump(duration) after the drag to flush the chip's 120ms flash
Timer, which was otherwise still pending at test teardown. All 15 tests
and flutter analyze pass. Manually verified on the Pixel_10a emulator:
opened the list with two waypoints, edited a row's altitude via chip drag
(confirmed value change, accounting for device-pixel-ratio scaling vs the
widget-test logical-pixel case), and confirmed row-tap re-centers/closes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b93bbab23b
commit
d3c5a68636
@@ -41,6 +41,17 @@ class CurrentMissionNotifier extends Notifier<List<Waypoint>> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aktualisiert den Fangradius eines Wegpunkts (Wegpunktliste: Catch-Chip).
|
||||||
|
void setCatchRadius(int index, double catchRadiusM) {
|
||||||
|
state = [
|
||||||
|
for (var i = 0; i < state.length; i++)
|
||||||
|
if (i == index)
|
||||||
|
state[i].copyWith(catchRadiusM: catchRadiusM)
|
||||||
|
else
|
||||||
|
state[i],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Schaltet die Execute-Aktion eines Wegpunkts um (Halo-Radialmenue:
|
/// Schaltet die Execute-Aktion eines Wegpunkts um (Halo-Radialmenue:
|
||||||
/// erneutes Antippen derselben Aktion setzt sie zurueck auf "none").
|
/// erneutes Antippen derselben Aktion setzt sie zurueck auf "none").
|
||||||
void toggleAction(int index, WaypointAction action) {
|
void toggleAction(int index, WaypointAction action) {
|
||||||
@@ -56,6 +67,15 @@ class CurrentMissionNotifier extends Notifier<List<Waypoint>> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt die Execute-Aktion eines Wegpunkts direkt (Wegpunktliste:
|
||||||
|
/// Execute-Dropdown - im Unterschied zu [toggleAction] kein Umschalten).
|
||||||
|
void setAction(int index, WaypointAction action) {
|
||||||
|
state = [
|
||||||
|
for (var i = 0; i < state.length; i++)
|
||||||
|
if (i == index) state[i].copyWith(action: action) else state[i],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
void removeAt(int index) {
|
void removeAt(int index) {
|
||||||
state = [
|
state = [
|
||||||
for (var i = 0; i < state.length; i++)
|
for (var i = 0; i < state.length; i++)
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../../domain/mission/route_geometry.dart';
|
import '../../../domain/mission/route_geometry.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/bottom_stats_bar.dart';
|
||||||
import '../../widgets/halo_menu.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';
|
||||||
import '../../widgets/value_wheel.dart';
|
import '../../widgets/value_wheel.dart';
|
||||||
|
import '../../widgets/waypoint_list_panel.dart';
|
||||||
|
|
||||||
/// Reticle-Zustand (HTML-Demonstrator: Variable `mode`).
|
/// Reticle-Zustand (HTML-Demonstrator: Variable `mode`).
|
||||||
/// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an.
|
/// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an.
|
||||||
@@ -38,6 +40,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
_ReticleMode _mode = _ReticleMode.idle;
|
_ReticleMode _mode = _ReticleMode.idle;
|
||||||
int _targetIndex = -1;
|
int _targetIndex = -1;
|
||||||
bool _mapReady = false;
|
bool _mapReady = false;
|
||||||
|
bool _listOpen = 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
|
||||||
@@ -214,6 +217,71 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 und schliesst
|
||||||
|
/// die Liste (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];
|
||||||
|
setState(() => _listOpen = false);
|
||||||
|
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
|
||||||
|
_handleMoveEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
({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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final waypoints = ref.watch(currentMissionProvider);
|
final waypoints = ref.watch(currentMissionProvider);
|
||||||
@@ -224,6 +292,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
maxClimbRate: _maxClimbRate,
|
maxClimbRate: _maxClimbRate,
|
||||||
maxDescentRate: _maxDescentRate,
|
maxDescentRate: _maxDescentRate,
|
||||||
);
|
);
|
||||||
|
final missionStats = _missionStats(waypoints);
|
||||||
|
|
||||||
List<LatLng>? rubberBand;
|
List<LatLng>? rubberBand;
|
||||||
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
|
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
|
||||||
@@ -314,6 +383,52 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
right: 70,
|
right: 70,
|
||||||
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
|
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (routeGeometry.vertexBad.any((bad) => bad) ||
|
||||||
|
routeGeometry.segments.any((s) => s.bad))
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BottomStatsBar(
|
||||||
|
waypointCount: waypoints.length,
|
||||||
|
totalDistanceM: missionStats.distanceM,
|
||||||
|
totalDurationSec: missionStats.durationSec,
|
||||||
|
onTap: () => setState(() => _listOpen = true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_listOpen)
|
||||||
|
Positioned.fill(
|
||||||
|
child: WaypointListPanel(
|
||||||
|
activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex,
|
||||||
|
vertexBad: routeGeometry.vertexBad,
|
||||||
|
altMin: _altMin,
|
||||||
|
altMax: _altMax,
|
||||||
|
speedMin: _speedMin,
|
||||||
|
speedMax: _speedMax,
|
||||||
|
onClose: () => setState(() => _listOpen = false),
|
||||||
|
onCenterOnWaypoint: _onCenterOnWaypoint,
|
||||||
|
onDelete: _onRowDelete,
|
||||||
|
onAltitudeChanged: _onRowAltitudeChanged,
|
||||||
|
onSpeedChanged: _onRowSpeedChanged,
|
||||||
|
onCatchRadiusChanged: _onRowCatchRadiusChanged,
|
||||||
|
onActionChanged: _onRowActionChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Statuszeile am unteren Bildschirmrand (HTML-Demonstrator: #bottomBar/
|
||||||
|
/// #statsRow). Antippen oeffnet die Wegpunktliste. Mission-/Drohnen-Name
|
||||||
|
/// und der Send-Button sind bewusst nicht Teil dieses Schritts - die
|
||||||
|
/// dafuer noetige Missionsverwaltung bzw. Upload-Anbindung existiert noch
|
||||||
|
/// nicht.
|
||||||
|
class BottomStatsBar extends StatelessWidget {
|
||||||
|
const BottomStatsBar({
|
||||||
|
super.key,
|
||||||
|
required this.waypointCount,
|
||||||
|
required this.totalDistanceM,
|
||||||
|
required this.totalDurationSec,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int waypointCount;
|
||||||
|
final double totalDistanceM;
|
||||||
|
final double totalDurationSec;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Details:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Icon(Icons.flag, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('$waypointCount WP',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Icon(Icons.straighten, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${totalDistanceM.round()} m',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Icon(Icons.access_time, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(_fmtDuration(totalDurationSec),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmtDuration(double seconds) {
|
||||||
|
final total = seconds.round();
|
||||||
|
final minutes = total ~/ 60;
|
||||||
|
final secs = total % 60;
|
||||||
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Swipe-adjustierbarer Wertechip in der Wegpunktliste (HTML-Demonstrator:
|
||||||
|
/// .value-chip / chipFieldConfig). Vertikal ziehen aendert den Wert in
|
||||||
|
/// festen Schritten von 10 pro 14px - nach oben erhoeht.
|
||||||
|
class WaypointChip extends StatefulWidget {
|
||||||
|
const WaypointChip({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.unit,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double value;
|
||||||
|
final String unit;
|
||||||
|
final double min;
|
||||||
|
final double max;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
|
||||||
|
static const _stepSize = 10.0;
|
||||||
|
static const _pxPerStep = 14.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaypointChip> createState() => _WaypointChipState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaypointChipState extends State<WaypointChip> {
|
||||||
|
double? _dragStartY;
|
||||||
|
double _dragStartValue = 0;
|
||||||
|
bool _flash = false;
|
||||||
|
|
||||||
|
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 steps = (deltaY / WaypointChip._pxPerStep).round();
|
||||||
|
var val = _dragStartValue + steps * WaypointChip._stepSize;
|
||||||
|
val = val.clamp(widget.min, widget.max);
|
||||||
|
if (val != widget.value) {
|
||||||
|
widget.onChanged(val);
|
||||||
|
setState(() => _flash = true);
|
||||||
|
Future.delayed(const Duration(milliseconds: 120), () {
|
||||||
|
if (mounted) setState(() => _flash = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onVerticalDragStart: _onDragStart,
|
||||||
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
|
onVerticalDragEnd: (_) => _dragStartY = null,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _flash
|
||||||
|
? const Color(0xFF4D9DFF).withValues(alpha: 0.4)
|
||||||
|
: Colors.black.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _flash
|
||||||
|
? const Color(0xFF8FD0FF)
|
||||||
|
: const Color(0xFF4D9DFF).withValues(alpha: 0.7),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${widget.value.round()}${widget.unit}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import '../providers/current_mission_provider.dart';
|
||||||
|
import 'waypoint_chip.dart';
|
||||||
|
|
||||||
|
/// Vollbild-Wegpunktliste (HTML-Demonstrator: #wpListPanel, List-Tab).
|
||||||
|
/// Nur eine Zeile gleichzeitig editierbar (rowEditIdx); Antippen einer
|
||||||
|
/// Zeile ausserhalb des Edit-Modus zentriert die Karte auf diesen
|
||||||
|
/// Wegpunkt und schliesst die Liste (Doku 3.6).
|
||||||
|
///
|
||||||
|
/// Die Altitude/Speed-Vollbild-Charts (weitere Tabs im Demonstrator) sind
|
||||||
|
/// bewusst nicht Teil dieses Schritts.
|
||||||
|
class WaypointListPanel extends ConsumerStatefulWidget {
|
||||||
|
const WaypointListPanel({
|
||||||
|
super.key,
|
||||||
|
required this.activeIndex,
|
||||||
|
required this.vertexBad,
|
||||||
|
required this.altMin,
|
||||||
|
required this.altMax,
|
||||||
|
required this.speedMin,
|
||||||
|
required this.speedMax,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onCenterOnWaypoint,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.onAltitudeChanged,
|
||||||
|
required this.onSpeedChanged,
|
||||||
|
required this.onCatchRadiusChanged,
|
||||||
|
required this.onActionChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int activeIndex;
|
||||||
|
final List<bool> vertexBad;
|
||||||
|
final double altMin;
|
||||||
|
final double altMax;
|
||||||
|
final double speedMin;
|
||||||
|
final double speedMax;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final void Function(int index) onCenterOnWaypoint;
|
||||||
|
final void Function(int index) onDelete;
|
||||||
|
final void Function(int index, double value) onAltitudeChanged;
|
||||||
|
final void Function(int index, double value) onSpeedChanged;
|
||||||
|
final void Function(int index, double value) onCatchRadiusChanged;
|
||||||
|
final void Function(int index, WaypointAction action) onActionChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<WaypointListPanel> createState() => _WaypointListPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||||
|
int _rowEditIndex = -1;
|
||||||
|
|
||||||
|
static const _headerStyle = TextStyle(
|
||||||
|
color: Colors.white60,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final waypoints = ref.watch(currentMissionProvider);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: const Color(0xEB080808),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Waypoints',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (waypoints.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
SizedBox(width: 34),
|
||||||
|
Expanded(child: Text('ALTITUDE', style: _headerStyle)),
|
||||||
|
Expanded(child: Text('SPEED', style: _headerStyle)),
|
||||||
|
Expanded(child: Text('EXECUTE', style: _headerStyle)),
|
||||||
|
Expanded(child: Text('CATCH', style: _headerStyle)),
|
||||||
|
SizedBox(width: 76),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.white24, height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: waypoints.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'No waypoints yet',
|
||||||
|
style: TextStyle(color: Colors.white60, fontSize: 15),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: waypoints.length,
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_row(waypoints[index], index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(Waypoint wp, int index) {
|
||||||
|
final isEditing = _rowEditIndex == index;
|
||||||
|
final isActive = widget.activeIndex == index;
|
||||||
|
final isBad = index < widget.vertexBad.length && widget.vertexBad[index];
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_rowEditIndex >= 0) return;
|
||||||
|
widget.onCenterOnWaypoint(index);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive
|
||||||
|
? const Color(0xFF4D9DFF).withValues(alpha: 0.28)
|
||||||
|
: Colors.white.withValues(alpha: 0.04),
|
||||||
|
border: isActive
|
||||||
|
? Border.all(color: const Color(0xFF4D9DFF))
|
||||||
|
: null,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 13,
|
||||||
|
backgroundColor: isBad
|
||||||
|
? const Color(0xFFE2574A).withValues(alpha: 0.5)
|
||||||
|
: Colors.white24,
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: isEditing
|
||||||
|
? WaypointChip(
|
||||||
|
value: wp.altitudeM,
|
||||||
|
unit: ' m',
|
||||||
|
min: widget.altMin,
|
||||||
|
max: widget.altMax,
|
||||||
|
onChanged: (v) => widget.onAltitudeChanged(index, v),
|
||||||
|
)
|
||||||
|
: Text('${wp.altitudeM.round()} m',
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: isEditing
|
||||||
|
? WaypointChip(
|
||||||
|
value: wp.speedMs,
|
||||||
|
unit: ' m/s',
|
||||||
|
min: widget.speedMin,
|
||||||
|
max: widget.speedMax,
|
||||||
|
onChanged: (v) => widget.onSpeedChanged(index, v),
|
||||||
|
)
|
||||||
|
: Text('${wp.speedMs.round()} m/s',
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: isEditing
|
||||||
|
? DropdownButton<WaypointAction>(
|
||||||
|
value: wp.action,
|
||||||
|
isDense: true,
|
||||||
|
dropdownColor: const Color(0xFF1A1A1A),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: WaypointAction.none,
|
||||||
|
child: Text('—'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: WaypointAction.loiter,
|
||||||
|
child: Text('Loiter'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: WaypointAction.landing,
|
||||||
|
child: Text('Landing'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (action) {
|
||||||
|
if (action != null) widget.onActionChanged(index, action);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Text(_actionLabel(wp.action),
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: isEditing
|
||||||
|
? WaypointChip(
|
||||||
|
value: wp.catchRadiusM,
|
||||||
|
unit: ' m',
|
||||||
|
min: 5,
|
||||||
|
max: 500,
|
||||||
|
onChanged: (v) => widget.onCatchRadiusChanged(index, v),
|
||||||
|
)
|
||||||
|
: Text('${wp.catchRadiusM.round()} m',
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _rowEditIndex = isEditing ? -1 : index,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
isEditing ? Icons.check : Icons.edit,
|
||||||
|
color: Colors.white70,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onDelete(index);
|
||||||
|
if (_rowEditIndex == index) setState(() => _rowEditIndex = -1);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
color: Color(0xFFE2574A), size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _actionLabel(WaypointAction action) => switch (action) {
|
||||||
|
WaypointAction.loiter => 'Loiter',
|
||||||
|
WaypointAction.landing => 'Landing',
|
||||||
|
_ => '—',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ 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/halo_menu.dart';
|
||||||
import 'package:dmc_app/ui/widgets/reticle_button.dart';
|
import 'package:dmc_app/ui/widgets/reticle_button.dart';
|
||||||
|
import 'package:dmc_app/ui/widgets/waypoint_chip.dart';
|
||||||
|
import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart';
|
||||||
|
|
||||||
Widget _wrap(Widget child) => ProviderScope(
|
Widget _wrap(Widget child) => ProviderScope(
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
@@ -18,6 +20,21 @@ Widget _wrap(Widget child) => ProviderScope(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<ProviderContainer> _pumpWithContainer(WidgetTester tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (_) => AppModeCubit(),
|
||||||
|
child: const MaterialApp(home: AppShell()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand',
|
testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
@@ -162,4 +179,78 @@ void main() {
|
|||||||
expect(waypoints.single.action, WaypointAction.loiter);
|
expect(waypoints.single.action, WaypointAction.loiter);
|
||||||
expect(find.byType(HaloMenu), findsNothing);
|
expect(find.byType(HaloMenu), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Bottom-Stats-Bar oeffnet die leere Wegpunktliste',
|
||||||
|
(tester) async {
|
||||||
|
await _pumpWithContainer(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Details:'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(WaypointListPanel), findsOneWidget);
|
||||||
|
expect(find.text('No waypoints yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Zeile antippen zentriert die Karte und schliesst die Liste',
|
||||||
|
(tester) async {
|
||||||
|
await _pumpWithContainer(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ReticleButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Details:'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(WaypointListPanel), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('60 m').first);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(WaypointListPanel), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Papierkorb-Icon in der Liste entfernt den Wegpunkt',
|
||||||
|
(tester) async {
|
||||||
|
final container = await _pumpWithContainer(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ReticleButton));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Details:'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(container.read(currentMissionProvider), hasLength(1));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(container.read(currentMissionProvider), isEmpty);
|
||||||
|
expect(find.text('No waypoints yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Wertechip in der Liste aendert die Hoehe des Wegpunkts per Drag',
|
||||||
|
(tester) async {
|
||||||
|
final container = await _pumpWithContainer(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ReticleButton));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Details:'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Icons.edit erscheint auch auf dem ReticleButton im Modus "snapped" -
|
||||||
|
// gezielt das Edit-Icon der Listenzeile ansprechen.
|
||||||
|
await tester.tap(find.descendant(
|
||||||
|
of: find.byType(WaypointListPanel),
|
||||||
|
matching: find.byIcon(Icons.edit),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(WaypointChip), findsNWidgets(3));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(WaypointChip).first, const Offset(0, -140));
|
||||||
|
// Der Chip-Flash-Effekt setzt per Timer nach 120ms zurueck - abwarten,
|
||||||
|
// damit kein Timer nach Testende/Dispose noch aussteht.
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
final waypoints = container.read(currentMissionProvider);
|
||||||
|
expect(waypoints.single.altitudeM, greaterThan(60));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user