import 'dart:async'; 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 '../../transport/connection_type.dart'; import '../../transport/link_transport.dart'; import '../dmc_colors.dart'; import '../providers/connection_settings_provider.dart'; import '../providers/flight_mode_provider.dart'; import '../providers/wifi_connection_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.flyModeColor : DmcColors.planModeColor; // 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.planModeColor, cornerRadius: const BorderRadius.only( bottomLeft: Radius.circular(26), ), onTap: () => context.read().toPlan(), ), if (isFly) ...[ const SizedBox(width: 8), const HeaderWindPill(), const SizedBox(width: 8), const _WifiConnectionProgressPill(), 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.flyModeColor, cornerRadius: const BorderRadius.only( bottomRight: Radius.circular(26), ), onTap: () { context.read().toFly(); // Eintritt in den Fly-Modus setzt immer auf Waypoint // zurueck (Doku 3.11). ref.read(flightModeProvider.notifier).reset(); _maybeAutoConnectWifi(ref); }, ), ], ), ), ), ); } } /// Verbindet automatisch zur mLRS-Bruecke, wenn in den Fly-Modus gewechselt /// wird (Doku Kommunikationsschicht v2 Abschnitt 8: "Verbindung automatisch /// herstellen bei Wechsel in Fly, wenn WLAN gemerkt"). Voraussetzungen: /// Verbindungsart ist WLAN, "Auto-connect"-Einstellung ist an, und es gibt /// bereits eine gemerkte SSID aus einer frueheren erfolgreichen Verbindung - /// ohne gemerkte SSID muesste ohnehin der Systemdialog erscheinen, das ist /// dann kein "automatisches" Verbinden mehr. Der Fortschritt ist ueber /// [_WifiConnectionProgressPill] sichtbar. Future _maybeAutoConnectWifi(WidgetRef ref) async { final settings = ref.read(connectionSettingsProvider).value; if (settings == null) return; if (settings.connectionType != ConnectionType.wifi) return; if (!settings.connectionAutoConnect) return; if (settings.connectionWifiLastSsid == null) return; final transport = ref.read(wifiTransportProvider); if (transport.currentState == LinkState.connected || transport.currentState == LinkState.connecting) { return; } try { await transport.connect(); } on LinkConnectException { // Grund/Nachricht sind bereits ueber den State-Stream gemeldet, siehe // _WifiConnectionProgressPill. } } /// Kompakte Fortschrittsanzeige fuer den automatischen WLAN-Verbindungsaufbau /// (siehe [_maybeAutoConnectWifi]) - nur sichtbar, solange die Verbindungsart /// WLAN ist und gerade etwas passiert bzw. ein Fehler vorliegt. Bleibt im /// Ruhezustand (disconnected, kein vorheriger Fehler) unsichtbar, damit die /// Kopfleiste nicht dauerhaft mit einer Statuspille zugestellt ist. class _WifiConnectionProgressPill extends ConsumerWidget { const _WifiConnectionProgressPill(); @override Widget build(BuildContext context, WidgetRef ref) { final connectionType = ref.watch(connectionSettingsProvider).value?.connectionType; final linkState = ref.watch(wifiLinkStateProvider).value; if (connectionType != ConnectionType.wifi) return const SizedBox.shrink(); if (linkState == null || linkState == LinkState.disconnected) { return const SizedBox.shrink(); } final (label, color) = switch (linkState) { LinkState.connecting => ('Connecting…', DmcColors.pafAmber), LinkState.connected => ('WiFi connected', DmcColors.liveGreen), LinkState.error => ('WiFi error', DmcColors.warnRed), LinkState.disconnected => ('', Colors.grey), }; return Padding( padding: const EdgeInsets.only(right: 8), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), border: Border.all(color: color), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (linkState == LinkState.connecting) ...[ SizedBox( width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 1.5, color: color), ), const SizedBox(width: 6), ], Text(label, style: TextStyle(color: color, fontSize: 11)), ], ), ), ); } } 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), ), ), ), ); } }