diff --git a/app/lib/ui/providers/current_mission_provider.dart b/app/lib/ui/providers/current_mission_provider.dart index 29512cb..2d6c013 100644 --- a/app/lib/ui/providers/current_mission_provider.dart +++ b/app/lib/ui/providers/current_mission_provider.dart @@ -41,6 +41,17 @@ class CurrentMissionNotifier extends Notifier> { ]; } + /// 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: /// erneutes Antippen derselben Aktion setzt sie zurueck auf "none"). void toggleAction(int index, WaypointAction action) { @@ -56,6 +67,15 @@ class CurrentMissionNotifier extends Notifier> { ]; } + /// 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) { state = [ for (var i = 0; i < state.length; i++) diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 6280af2..9354fff 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -9,10 +9,12 @@ 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/bottom_stats_bar.dart'; import '../../widgets/halo_menu.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. @@ -38,6 +40,7 @@ class _PlanScreenState extends ConsumerState { _ReticleMode _mode = _ReticleMode.idle; int _targetIndex = -1; bool _mapReady = false; + bool _listOpen = false; // TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die // Profilverwaltung existiert. Werte entsprechen dem Default-Profil @@ -214,6 +217,71 @@ class _PlanScreenState extends ConsumerState { } } + // 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 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); @@ -224,6 +292,7 @@ class _PlanScreenState extends ConsumerState { maxClimbRate: _maxClimbRate, maxDescentRate: _maxDescentRate, ); + final missionStats = _missionStats(waypoints); List? rubberBand; if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) { @@ -314,6 +383,52 @@ class _PlanScreenState extends ConsumerState { right: 70, 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, + ), + ), ], ); } diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart new file mode 100644 index 0000000..c18ecdb --- /dev/null +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -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')}'; + } +} diff --git a/app/lib/ui/widgets/waypoint_chip.dart b/app/lib/ui/widgets/waypoint_chip.dart new file mode 100644 index 0000000..3358f4e --- /dev/null +++ b/app/lib/ui/widgets/waypoint_chip.dart @@ -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 onChanged; + + static const _stepSize = 10.0; + static const _pxPerStep = 14.0; + + @override + State createState() => _WaypointChipState(); +} + +class _WaypointChipState extends State { + 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, + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart new file mode 100644 index 0000000..1cd7dde --- /dev/null +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -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 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 createState() => _WaypointListPanelState(); +} + +class _WaypointListPanelState extends ConsumerState { + 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( + 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', + _ => '—', + }; +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 624700e..fc9a670 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -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/widgets/halo_menu.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( child: BlocProvider( @@ -18,6 +20,21 @@ Widget _wrap(Widget child) => ProviderScope( ), ); +Future _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() { testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand', (tester) async { @@ -162,4 +179,78 @@ void main() { expect(waypoints.single.action, WaypointAction.loiter); 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)); + }); }