Show mission map and footer in Fly mode, add area/drone zoom buttons
Fly mode previously showed just a black placeholder. It now renders the same MissionMap (route + waypoint markers) and footer (warnings banner, altitude profile, BottomStatsBar) as the Plan screen, minus the reticle/wheels since flying observes the route rather than replanning it. Waypoints can still be edited via the details list for spontaneous in-flight changes. Live drone position comes from a new telemetryProvider/ flightControllerLinkProvider pair (autoDispose), the first place FlightControllerLink/TelemetryFrame get wired into the UI - currently backed by MockFlightControllerLink until a real MSP transport exists (Doku 4.19). A drone marker renders on the map once a telemetry frame arrives. The Fly-mode header keeps the fit-to-area button but replaces "zoom to home" with "zoom to drone" (FlyMapControls) - the first waypoint isn't a meaningful reference point anymore once airborne. Extracted computeMissionStats and MissionFooterBar out of PlanScreen so Plan/Fly share the exact same stats/footer logic instead of duplicating it, and extracted NavIconButton out of MapSearchControls so both Plan's and Fly's map controls use the same button widget. Widget tests that now mount FlyScreen switch from the ProviderScope-based _wrap() helper to a manual ProviderContainer with an explicit FlightControllerLink.disconnect() call before test end - the mock's Timer.periodic doesn't get cancelled by container disposal alone, and flutter_test's pending-timer check runs before addTearDown callbacks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d745b76403
commit
310d92f187
@@ -1,19 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Fly-Modus: Telemetrie, Draeder, Point-and-Fly-Fadenkreuz
|
||||
/// (Architektur-Doku 3.10/3.11). Platzhalter bis Telemetry-Service und
|
||||
/// FlightControllerLink verdrahtet sind. Kein eigenes Scaffold, siehe
|
||||
/// PlanScreen/AppShell.
|
||||
class FlyScreen extends StatelessWidget {
|
||||
import '../../../domain/mission/mission_stats.dart';
|
||||
import '../../../domain/mission/mission_warnings.dart';
|
||||
import '../../../domain/mission/route_geometry.dart';
|
||||
import '../../../domain/wind/wind_math.dart';
|
||||
import '../../providers/active_drone_profile_provider.dart';
|
||||
import '../../providers/current_mission_provider.dart';
|
||||
import '../../providers/map_controller_provider.dart';
|
||||
import '../../providers/telemetry_provider.dart';
|
||||
import '../../providers/terrain_provider.dart';
|
||||
import '../../widgets/drone_marker_icon.dart';
|
||||
import '../../widgets/mission_footer_bar.dart';
|
||||
import '../../widgets/mission_map.dart';
|
||||
import '../../widgets/waypoint_list_panel.dart';
|
||||
import '../../widgets/warnings_panel.dart';
|
||||
|
||||
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
|
||||
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
|
||||
/// beobachtet statt neu geplant. Der Kartencontroller ist derselbe
|
||||
/// app-lebenslange Provider wie im Plan-Modus (Doku: mapControllerProvider),
|
||||
/// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen
|
||||
/// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung),
|
||||
/// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier.
|
||||
class FlyScreen extends ConsumerWidget {
|
||||
const FlyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ColoredBox(
|
||||
color: Color(0xFF0F0F0F),
|
||||
child: Center(
|
||||
child: Text('Fly', style: TextStyle(color: Colors.white)),
|
||||
void _onCenterOnWaypoint(WidgetRef ref, int index) {
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (index < 0 || index >= waypoints.length) return;
|
||||
final wp = waypoints[index];
|
||||
final controller = ref.read(mapControllerProvider);
|
||||
controller.move(LatLng(wp.lat, wp.lon), controller.camera.zoom);
|
||||
}
|
||||
|
||||
void _openWaypointList(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
PanelTab initialTab = PanelTab.list,
|
||||
}) {
|
||||
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: -1,
|
||||
initialTab: initialTab,
|
||||
onClose: () => Navigator.of(routeContext).pop(),
|
||||
onCenterOnWaypoint: (index) {
|
||||
_onCenterOnWaypoint(ref, index);
|
||||
Navigator.of(routeContext).pop();
|
||||
},
|
||||
onDelete: (index) =>
|
||||
ref.read(currentMissionProvider.notifier).removeAt(index),
|
||||
onAltitudeChanged: (index, value) => ref
|
||||
.read(currentMissionProvider.notifier)
|
||||
.setAltitude(index, value),
|
||||
onSpeedChanged: (index, value) =>
|
||||
ref.read(currentMissionProvider.notifier).setSpeed(index, value),
|
||||
onCatchRadiusChanged: (index, value) => ref
|
||||
.read(currentMissionProvider.notifier)
|
||||
.setCatchRadius(index, value),
|
||||
onActionChanged: (index, action) =>
|
||||
ref.read(currentMissionProvider.notifier).setAction(index, action),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openWarningsPanel(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<MissionWarning> warnings,
|
||||
) {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (routeContext) => WarningsPanel(
|
||||
warnings: warnings,
|
||||
onClose: () => Navigator.of(routeContext).pop(),
|
||||
onWarningTap: (warning) {
|
||||
Navigator.of(routeContext).pop();
|
||||
switch (warning.action) {
|
||||
case WarningAction.altitude:
|
||||
_openWaypointList(context, ref, initialTab: PanelTab.altitude);
|
||||
case WarningAction.speed:
|
||||
_openWaypointList(context, ref, initialTab: PanelTab.speed);
|
||||
case WarningAction.map:
|
||||
fitMapToWaypoints(ref, ref.read(currentMissionProvider));
|
||||
}
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final waypoints = ref.watch(currentMissionProvider);
|
||||
final droneProfile = ref.watch(activeDroneProfileProvider);
|
||||
final telemetry = ref.watch(telemetryProvider).value;
|
||||
|
||||
final routeGeometry = buildRouteGeometry(
|
||||
waypoints,
|
||||
minTurnRadius: droneProfile.minTurnRadius,
|
||||
maxClimbRate: droneProfile.maxClimbRate,
|
||||
maxDescentRate: droneProfile.maxDescentRate,
|
||||
);
|
||||
final missionStats = computeMissionStats(waypoints);
|
||||
final terrainProfile = ref.watch(terrainProvider).profile;
|
||||
final warnings = computeMissionWarnings(
|
||||
waypoints: waypoints,
|
||||
routeGeometry: routeGeometry,
|
||||
legWindBad: computeLegWindBad(waypoints, droneProfile.maxSpeed),
|
||||
droneProfile: droneProfile,
|
||||
totalDistanceM: missionStats.distanceM,
|
||||
totalDurationSec: missionStats.durationSec,
|
||||
terrain: terrainProfile,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MissionMap(
|
||||
mapController: ref.watch(mapControllerProvider),
|
||||
routeSegments: routeGeometry.segments,
|
||||
waypointMarkers: [
|
||||
for (var i = 0; i < waypoints.length; i++)
|
||||
CircleMarker(
|
||||
point: LatLng(waypoints[i].lat, waypoints[i].lon),
|
||||
radius: 6,
|
||||
color: i < routeGeometry.vertexBad.length && routeGeometry.vertexBad[i]
|
||||
? const Color(0xFFE2574A)
|
||||
: const Color(0xFF2B2B28),
|
||||
borderStrokeWidth: 1.5,
|
||||
borderColor: Colors.white,
|
||||
),
|
||||
],
|
||||
droneMarker: telemetry == null
|
||||
? null
|
||||
: Marker(
|
||||
point: LatLng(telemetry.lat, telemetry.lon),
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
child: const DroneMarkerIcon(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: MissionFooterBar(
|
||||
waypoints: waypoints,
|
||||
legClimbBad: routeGeometry.legClimbBad,
|
||||
vertexBad: routeGeometry.vertexBad,
|
||||
activeIndex: -1,
|
||||
totalDistanceM: missionStats.distanceM,
|
||||
totalDurationSec: missionStats.durationSec,
|
||||
warnings: warnings,
|
||||
onDetailsTap: () => _openWaypointList(context, ref),
|
||||
onWarningsTap: () => _openWarningsPanel(context, ref, warnings),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../domain/mission/default_drone_profile.dart';
|
||||
import '../../../domain/mission/mission_stats.dart';
|
||||
import '../../../domain/mission/mission_warnings.dart';
|
||||
import '../../../domain/mission/route_geometry.dart';
|
||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||
@@ -17,9 +18,8 @@ import '../../providers/map_controller_provider.dart';
|
||||
import '../../providers/mission_meta_provider.dart';
|
||||
import '../../providers/terrain_provider.dart';
|
||||
import '../../providers/wind_provider.dart';
|
||||
import '../../widgets/bottom_stats_bar.dart';
|
||||
import '../../widgets/halo_menu.dart';
|
||||
import '../../widgets/mini_altitude_profile.dart';
|
||||
import '../../widgets/mission_footer_bar.dart';
|
||||
import '../../widgets/mission_map.dart';
|
||||
import '../../widgets/reticle_button.dart';
|
||||
import '../../widgets/value_wheel.dart';
|
||||
@@ -359,21 +359,6 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
));
|
||||
}
|
||||
|
||||
({double distanceM, double durationSec}) _missionStats(
|
||||
List<Waypoint> 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);
|
||||
@@ -386,7 +371,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
maxClimbRate: droneProfile.maxClimbRate,
|
||||
maxDescentRate: droneProfile.maxDescentRate,
|
||||
);
|
||||
final missionStats = _missionStats(waypoints);
|
||||
final missionStats = computeMissionStats(waypoints);
|
||||
final terrainProfile = ref.watch(terrainProvider).profile;
|
||||
final warnings = computeMissionWarnings(
|
||||
waypoints: waypoints,
|
||||
@@ -513,48 +498,16 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (warnings.isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFF7C2D12),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
child: Text(
|
||||
warnings.map((w) => w.text).join(' · '),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFFFDE3D0), fontSize: 12),
|
||||
),
|
||||
),
|
||||
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,
|
||||
warnings: warnings,
|
||||
onWarningsTap: () => _openWarningsPanel(warnings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MissionFooterBar(
|
||||
waypoints: waypoints,
|
||||
legClimbBad: routeGeometry.legClimbBad,
|
||||
vertexBad: routeGeometry.vertexBad,
|
||||
activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex,
|
||||
totalDistanceM: missionStats.distanceM,
|
||||
totalDurationSec: missionStats.durationSec,
|
||||
warnings: warnings,
|
||||
onDetailsTap: _openWaypointList,
|
||||
onWarningsTap: () => _openWarningsPanel(warnings),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user