- Wheels/readouts now add max(MediaQuery.padding.left/right) on both sides symmetrically (HTML prototype: max(env(safe-area-inset-left), env(safe-area-inset-right))), so a front-camera cutout in landscape doesn't sit behind the altitude wheel, and both sides stay in sync. - WaypointListPanel is now pushed via Navigator (PageRouteBuilder + fade) instead of being an internal Stack overlay in PlanScreen. It was rendering *under* AppShell's TopModeBar before, since TopModeBar is always the last (topmost) child of AppShell's own Stack regardless of what PlanScreen draws internally - a plain bool flag inside PlanScreen had no way to paint above a sibling higher up the tree. A pushed route paints above the entire invoking route's content by construction, so this was the actual fix rather than reshuffling Z-order inside PlanScreen. - Extracted DefaultDroneProfile (T1 Ranger constants) out of PlanScreen's private statics into lib/domain/mission/, since WaypointListPanel now needs the same values to compute its own route geometry reactively (previously passed down as a vertexBad/altMin/altMax/... snapshot, which would have gone stale while the panel was open across a route boundary). - Bottom bar now has the prototype's rgba(15,15,15,0.72) background (previously fully transparent outside the stats pill), and gained MiniAltitudeProfile - the non-interactive height-profile sparkline that sits above the stats row. - Added the Altitude/Speed full-screen chart tabs (FullValueChart): drag any waypoint's point to adjust its altitude/speed, X position proportional to cumulative route distance, matching the prototype's buildFullChart()/attachChartDrag(). Terrain overlay and wind diamonds from the prototype aren't ported - terrain (doc 3.9) and wind (doc 3.8) systems don't exist yet in the Flutter app. - RouteGeometry now also exposes legClimbBad (was computed internally but not surfaced), needed by both the mini profile and the chart tabs. Discovered while updating the widget tests: a single tester.pump() after tapping something that triggers Navigator.push/pop isn't enough - the push/pop itself needs a frame to register before a duration-based pump can animate it, so both are needed in sequence. Added 2 new tests for the chart tabs; all 17 tests and flutter analyze pass. Verified all four changes on the Pixel_10a emulator: wheels, opaque bottom bar with the mini profile, the list panel now fully covering the top bar (confirmed by its complete absence while the panel is open), and dragging points on both chart tabs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
363 lines
12 KiB
Dart
363 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../domain/mission/default_drone_profile.dart';
|
|
import '../../domain/mission/route_geometry.dart';
|
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
|
import '../providers/current_mission_provider.dart';
|
|
import 'full_value_chart.dart';
|
|
import 'waypoint_chip.dart';
|
|
|
|
enum _PanelTab { list, altitude, speed }
|
|
|
|
/// Vollbild-Wegpunktliste (HTML-Demonstrator: #wpListPanel). Wird von
|
|
/// PlanScreen als eigene Vollbild-Route gepusht (Navigator.push), damit sie
|
|
/// wirklich alles ueberdeckt, inklusive der Kopfleiste - anders als ein
|
|
/// interner Stack-Overlay innerhalb des Plan-Screens.
|
|
///
|
|
/// Berechnet die Flugpfad-Geometrie (fuer bad-Markierungen) selbst aus dem
|
|
/// beobachteten currentMissionProvider, damit sie auch reaktiv bleibt,
|
|
/// wenn sich Wegpunkte aendern, waehrend das Panel offen ist.
|
|
///
|
|
/// 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).
|
|
class WaypointListPanel extends ConsumerStatefulWidget {
|
|
const WaypointListPanel({
|
|
super.key,
|
|
required this.activeIndex,
|
|
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 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;
|
|
_PanelTab _tab = _PanelTab.list;
|
|
|
|
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);
|
|
final routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
minTurnRadius: DefaultDroneProfile.minTurnRadius,
|
|
maxClimbRate: DefaultDroneProfile.maxClimbRate,
|
|
maxDescentRate: DefaultDroneProfile.maxDescentRate,
|
|
);
|
|
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
_tabButton('List', _PanelTab.list),
|
|
_tabButton('Altitude', _PanelTab.altitude),
|
|
_tabButton('Speed', _PanelTab.speed),
|
|
],
|
|
),
|
|
const Divider(color: Colors.white24, height: 1),
|
|
Expanded(
|
|
child: switch (_tab) {
|
|
_PanelTab.list => _listTab(waypoints, routeGeometry),
|
|
_PanelTab.altitude => _chartTab(
|
|
waypoints,
|
|
routeGeometry,
|
|
values: [for (final w in waypoints) w.altitudeM],
|
|
min: DefaultDroneProfile.altMin,
|
|
max: DefaultDroneProfile.altMax,
|
|
step: DefaultDroneProfile.altStep,
|
|
pxPerUnit: 5,
|
|
unit: ' m',
|
|
onChanged: widget.onAltitudeChanged,
|
|
),
|
|
_PanelTab.speed => _chartTab(
|
|
waypoints,
|
|
routeGeometry,
|
|
values: [for (final w in waypoints) w.speedMs],
|
|
min: DefaultDroneProfile.speedMin,
|
|
max: DefaultDroneProfile.speedMax,
|
|
step: DefaultDroneProfile.speedStep,
|
|
pxPerUnit: 22,
|
|
unit: ' m/s',
|
|
onChanged: widget.onSpeedChanged,
|
|
),
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tabButton(String label, _PanelTab tab) {
|
|
final active = _tab == tab;
|
|
return Expanded(
|
|
child: InkWell(
|
|
onTap: () => setState(() => _tab = tab),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: active ? const Color(0xFF4D9DFF) : Colors.transparent,
|
|
width: 2,
|
|
),
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: active ? Colors.white : Colors.white60,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _chartTab(
|
|
List<Waypoint> waypoints,
|
|
RouteGeometry routeGeometry, {
|
|
required List<double> values,
|
|
required double min,
|
|
required double max,
|
|
required double step,
|
|
required double pxPerUnit,
|
|
required String unit,
|
|
required void Function(int index, double value) onChanged,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
|
|
child: FullValueChart(
|
|
waypoints: waypoints,
|
|
values: values,
|
|
legBad: routeGeometry.legClimbBad,
|
|
vertexBad: routeGeometry.vertexBad,
|
|
activeIndex: widget.activeIndex,
|
|
min: min,
|
|
max: max,
|
|
step: step,
|
|
pxPerUnit: pxPerUnit,
|
|
unit: unit,
|
|
onChanged: onChanged,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _listTab(List<Waypoint> waypoints, RouteGeometry routeGeometry) {
|
|
return Column(
|
|
children: [
|
|
if (waypoints.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
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, routeGeometry),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _row(Waypoint wp, int index, RouteGeometry routeGeometry) {
|
|
final isEditing = _rowEditIndex == index;
|
|
final isActive = widget.activeIndex == index;
|
|
final isBad = index < routeGeometry.vertexBad.length &&
|
|
routeGeometry.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: DefaultDroneProfile.altMin,
|
|
max: DefaultDroneProfile.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: DefaultDroneProfile.speedMin,
|
|
max: DefaultDroneProfile.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',
|
|
_ => '—',
|
|
};
|
|
}
|