- 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>
256 lines
8.9 KiB
Dart
256 lines
8.9 KiB
Dart
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',
|
|
_ => '—',
|
|
};
|
|
}
|