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:
Constantin Leue
2026-07-28 07:25:32 +02:00
co-authored by Claude Sonnet 5
parent b93bbab23b
commit d3c5a68636
6 changed files with 645 additions and 0 deletions
+78
View File
@@ -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')}';
}
}
+86
View File
@@ -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,
),
),
),
);
}
}
+255
View File
@@ -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',
_ => '—',
};
}