Fix wheel/panel layering, add bottom-bar background, and full-screen charts

- 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>
This commit is contained in:
Constantin Leue
2026-07-28 08:13:01 +02:00
co-authored by Claude Sonnet 5
parent d3c5a68636
commit 3091476771
7 changed files with 758 additions and 113 deletions
+89 -62
View File
@@ -6,11 +6,13 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.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 '../../widgets/bottom_stats_bar.dart';
import '../../widgets/halo_menu.dart';
import '../../widgets/mini_altitude_profile.dart';
import '../../widgets/mission_map.dart';
import '../../widgets/reticle_button.dart';
import '../../widgets/value_wheel.dart';
@@ -40,30 +42,16 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
_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
// T1 Ranger im HTML-Demonstrator (DEFAULT_DRONE).
static const _altMin = -500.0;
static const _altMax = 2000.0;
static const _altStep = 10.0;
// Rein UI-seitige Darstellungsdetails der Drehraeder; die eigentlichen
// Wertebereiche kommen aus DefaultDroneProfile (Doku 3.5-Platzhalter).
static const _altPxPerStep = 14.0;
static const _altMajorEvery = 5;
static const _speedMin = 13.0;
static const _speedMax = 25.0;
static const _speedStep = 1.0;
static const _speedPxPerStep = 14.0;
static const _speedMajorEvery = 5;
static const _defaultCatchRadiusM = 60.0;
static const _minTurnRadius = 35.0;
static const _maxClimbRate = 5.0;
static const _maxDescentRate = 6.0;
double _curAlt = 60;
double _curSpeed = 15;
double _curAlt = DefaultDroneProfile.altitudeM;
double _curSpeed = DefaultDroneProfile.speedMs;
// Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser).
static const _reticleRadiusPx = 55 / 2;
@@ -161,7 +149,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
lon: center.longitude,
altitudeM: _curAlt,
speedMs: _curSpeed,
catchRadiusM: _defaultCatchRadiusM,
catchRadiusM: DefaultDroneProfile.catchRadiusM,
),
);
setState(() {
@@ -254,19 +242,49 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
});
}
/// 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.
/// Zeile antippen zentriert die Karte auf diesen Wegpunkt (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();
}
/// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als
/// internen Stack-Overlay, damit sie wirklich alles ueberdeckt -
/// inklusive der Kopfleiste im AppShell darueber.
void _openWaypointList() {
Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: true,
transitionDuration: const Duration(milliseconds: 150),
reverseTransitionDuration: const Duration(milliseconds: 150),
pageBuilder: (routeContext, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: WaypointListPanel(
activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex,
onClose: () => Navigator.of(routeContext).pop(),
onCenterOnWaypoint: (index) {
_onCenterOnWaypoint(index);
Navigator.of(routeContext).pop();
},
onDelete: _onRowDelete,
onAltitudeChanged: _onRowAltitudeChanged,
onSpeedChanged: _onRowSpeedChanged,
onCatchRadiusChanged: _onRowCatchRadiusChanged,
onActionChanged: _onRowActionChanged,
),
);
},
),
);
}
({double distanceM, double durationSec}) _missionStats(
List<Waypoint> waypoints,
) {
@@ -288,11 +306,13 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: _minTurnRadius,
maxClimbRate: _maxClimbRate,
maxDescentRate: _maxDescentRate,
minTurnRadius: DefaultDroneProfile.minTurnRadius,
maxClimbRate: DefaultDroneProfile.maxClimbRate,
maxDescentRate: DefaultDroneProfile.maxDescentRate,
);
final missionStats = _missionStats(waypoints);
final anyBad = routeGeometry.vertexBad.any((bad) => bad) ||
routeGeometry.segments.any((s) => s.bad);
List<LatLng>? rubberBand;
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
@@ -312,6 +332,13 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
MediaQuery.of(context).size.height * 0.06,
124.0,
);
// Symmetrischer Randabstand auf beiden Seiten, damit z.B. eine
// Frontkamera-Aussparung im Querformat die Raeder nicht verdeckt
// (HTML-Demonstrator: max(env(safe-area-inset-left), env(safe-area-inset-right))).
final safeEdgeInset = math.max(
MediaQuery.of(context).padding.left,
MediaQuery.of(context).padding.right,
);
return Stack(
children: [
@@ -346,12 +373,12 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
Positioned(
top: 56,
bottom: wheelBottomInset,
left: 14,
left: 14 + safeEdgeInset,
child: ValueWheel(
value: _curAlt,
min: _altMin,
max: _altMax,
step: _altStep,
min: DefaultDroneProfile.altMin,
max: DefaultDroneProfile.altMax,
step: DefaultDroneProfile.altStep,
pxPerStep: _altPxPerStep,
majorEvery: _altMajorEvery,
onChanged: _onAltChanged,
@@ -360,18 +387,18 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
Positioned(
top: 56,
bottom: wheelBottomInset,
left: 70,
left: 70 + safeEdgeInset,
child: Center(child: _readout('Alt', _curAlt, 'm')),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
right: 14,
right: 14 + safeEdgeInset,
child: ValueWheel(
value: _curSpeed,
min: _speedMin,
max: _speedMax,
step: _speedStep,
min: DefaultDroneProfile.speedMin,
max: DefaultDroneProfile.speedMax,
step: DefaultDroneProfile.speedStep,
pxPerStep: _speedPxPerStep,
majorEvery: _speedMajorEvery,
onChanged: _onSpeedChanged,
@@ -380,7 +407,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
Positioned(
top: 56,
bottom: wheelBottomInset,
right: 70,
right: 70 + safeEdgeInset,
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
),
Positioned(
@@ -390,8 +417,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (routeGeometry.vertexBad.any((bad) => bad) ||
routeGeometry.segments.any((s) => s.bad))
if (anyBad)
Container(
width: double.infinity,
color: const Color(0xFF7C2D12),
@@ -402,33 +428,34 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
style: TextStyle(color: Color(0xFFFDE3D0), fontSize: 12),
),
),
BottomStatsBar(
waypointCount: waypoints.length,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
onTap: () => setState(() => _listOpen = true),
Container(
width: double.infinity,
color: const Color(0xFF0F0F0F).withValues(alpha: 0.72),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
child: MiniAltitudeProfile(
waypoints: waypoints,
legClimbBad: routeGeometry.legClimbBad,
vertexBad: routeGeometry.vertexBad,
activeIndex:
_mode == _ReticleMode.idle ? -1 : _targetIndex,
),
),
BottomStatsBar(
waypointCount: waypoints.length,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
onTap: _openWaypointList,
),
],
),
),
],
),
),
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,
),
),
],
);
}