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>
158 lines
5.6 KiB
Dart
158 lines
5.6 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../app_mode/app_mode_cubit.dart';
|
|
import '../dmc_colors.dart';
|
|
import '../providers/flight_mode_provider.dart';
|
|
import 'flight_mode_pill.dart';
|
|
import 'fly_map_controls.dart';
|
|
import 'header_wind_pill.dart';
|
|
import 'map_search_controls.dart';
|
|
|
|
/// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus
|
|
/// (HTML-Demonstrator: #topBarWrap/#editModeBtn/#flyModeBtn). Die Leiste
|
|
/// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird
|
|
/// als solider Button am Rand angeboten, um in ihn zu wechseln.
|
|
///
|
|
/// Traegt ausserhalb des Fly-Modus zusaetzlich die Wind-Pille, Ortssuche und
|
|
/// die Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #headerWindPill/
|
|
/// #searchWrap/#homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer
|
|
/// separaten Leiste) - dafuer lebt der MapController im mapControllerProvider
|
|
/// statt als PlanScreen-privates Feld, siehe dort.
|
|
///
|
|
/// Im Fly-Modus traegt sie stattdessen die Wind-Pille (Doku 7.2: "Wind-
|
|
/// Anzeige nach Hoehe interpoliert), die Flugmodus-Auswahl (Doku 3.11,
|
|
/// HTML-Demonstrator: #flightModePill) sowie Fit-/Drohnen-Zoom-Buttons
|
|
/// (FlyMapControls) - Ortssuche/Home entfallen dort, da im Fly-Modus keine
|
|
/// Missionsbearbeitung stattfindet und "Home" (erster Wegpunkt) waehrend
|
|
/// des Flugs kein sinnvoller Bezugspunkt mehr ist.
|
|
class TopModeBar extends ConsumerWidget {
|
|
const TopModeBar({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final isFly = context.select((AppModeCubit c) => c.state.mode == AppMode.fly);
|
|
final activeTint = isFly ? DmcColors.flyModeRed : DmcColors.editModeGreen;
|
|
|
|
// Kein SafeArea: das wendet links/rechts die jeweils tatsaechlichen
|
|
// (oft asymmetrischen) Insets an, wodurch die Leiste sichtbar von der
|
|
// Bildschirmmitte abweicht, sobald eine Seite einen groesseren
|
|
// Aussparungsbereich meldet als die andere. Stattdessen derselbe
|
|
// symmetrische Randabstand wie bei den Drehraedern (PlanScreen:
|
|
// safeEdgeInset) - dadurch bleibt die per FractionallySizedBox
|
|
// zentrierte Leiste auch bei asymmetrischen Insets echt mittig.
|
|
final topInset = MediaQuery.of(context).padding.top;
|
|
final safeEdgeInset = math.max(
|
|
MediaQuery.of(context).padding.left,
|
|
MediaQuery.of(context).padding.right,
|
|
);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
top: topInset,
|
|
left: safeEdgeInset,
|
|
right: safeEdgeInset,
|
|
),
|
|
child: FractionallySizedBox(
|
|
widthFactor: 0.70,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: activeTint.withValues(alpha: 0.55),
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(26),
|
|
bottomRight: Radius.circular(26),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_ModeButton(
|
|
label: 'Plan',
|
|
isActive: !isFly,
|
|
inactiveColor: DmcColors.editModeGreen,
|
|
cornerRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(26),
|
|
),
|
|
onTap: () => context.read<AppModeCubit>().toPlan(),
|
|
),
|
|
if (isFly) ...[
|
|
const SizedBox(width: 8),
|
|
const HeaderWindPill(),
|
|
const SizedBox(width: 8),
|
|
const Expanded(child: FlightModePill()),
|
|
const SizedBox(width: 8),
|
|
const FlyMapControls(),
|
|
const SizedBox(width: 8),
|
|
] else ...[
|
|
const SizedBox(width: 8),
|
|
const HeaderWindPill(),
|
|
const SizedBox(width: 8),
|
|
const Expanded(child: MapSearchControls()),
|
|
const SizedBox(width: 12),
|
|
],
|
|
_ModeButton(
|
|
label: 'Fly',
|
|
isActive: isFly,
|
|
inactiveColor: DmcColors.flyModeRed,
|
|
cornerRadius: const BorderRadius.only(
|
|
bottomRight: Radius.circular(26),
|
|
),
|
|
onTap: () {
|
|
context.read<AppModeCubit>().toFly();
|
|
// Eintritt in den Fly-Modus setzt immer auf Waypoint
|
|
// zurueck (Doku 3.11).
|
|
ref.read(flightModeProvider.notifier).reset();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ModeButton extends StatelessWidget {
|
|
const _ModeButton({
|
|
required this.label,
|
|
required this.isActive,
|
|
required this.inactiveColor,
|
|
required this.cornerRadius,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final bool isActive;
|
|
final Color inactiveColor;
|
|
final BorderRadius cornerRadius;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: isActive ? null : onTap,
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: isActive ? 6 : 11,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isActive ? null : inactiveColor.withValues(alpha: 0.85),
|
|
borderRadius: isActive ? null : cornerRadius,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
color: Color(0xFF141414),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|