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>
134 lines
4.7 KiB
Dart
134 lines
4.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import '../providers/current_mission_provider.dart';
|
|
import '../providers/geocoding_provider.dart';
|
|
import '../providers/map_controller_provider.dart';
|
|
import '../providers/mission_meta_provider.dart';
|
|
import '../providers/wind_provider.dart';
|
|
import 'nav_icon_button.dart';
|
|
|
|
/// Ortssuche (Nominatim) + Home-/Fit-Buttons, eingebettet als Mittelteil
|
|
/// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/
|
|
/// #fitBtn sitzen alle in derselben Leiste wie die Modus-Buttons).
|
|
///
|
|
/// Vollstaendig selbststaendig (kein Callback-Durchreichen von aussen):
|
|
/// greift direkt auf den geteilten mapControllerProvider und
|
|
/// currentMissionProvider zu, da diese Aktionen konzeptionell zur
|
|
/// Kartensteuerung gehoeren, nicht zum Elternwidget.
|
|
class MapSearchControls extends ConsumerStatefulWidget {
|
|
const MapSearchControls({super.key});
|
|
|
|
@override
|
|
ConsumerState<MapSearchControls> createState() => _MapSearchControlsState();
|
|
}
|
|
|
|
class _MapSearchControlsState extends ConsumerState<MapSearchControls> {
|
|
final _controller = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submitSearch() async {
|
|
final query = _controller.text.trim();
|
|
FocusScope.of(context).unfocus();
|
|
if (query.isEmpty) return;
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
try {
|
|
final result = await ref.read(nominatimServiceProvider).search(query);
|
|
if (result == null) {
|
|
messenger.showSnackBar(const SnackBar(content: Text('Location not found')));
|
|
return;
|
|
}
|
|
ref.read(mapControllerProvider).move(LatLng(result.lat, result.lon), 15);
|
|
|
|
// Eine Ortssuche beginnt immer eine neue, nach dem Ort benannte
|
|
// Mission statt nur die Karte zu verschieben (HTML-Demonstrator:
|
|
// setMissionFromPlace(), aufgerufen aus doSearch()) - vorherige
|
|
// Aenderungen werden dabei zuerst autosaved, gehen also nicht verloren.
|
|
await ref
|
|
.read(currentMissionMetaProvider.notifier)
|
|
.startNewFromPlace(result.label);
|
|
await ref.read(headerWindProvider.notifier).refresh();
|
|
} catch (_) {
|
|
messenger.showSnackBar(const SnackBar(content: Text('Search failed')));
|
|
}
|
|
}
|
|
|
|
/// Zentriert auf den ersten Wegpunkt (HTML-Demonstrator: homeBtn).
|
|
void _onHomePressed() {
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
if (waypoints.isEmpty) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('No waypoints yet')));
|
|
return;
|
|
}
|
|
final home = waypoints.first;
|
|
ref.read(mapControllerProvider).move(LatLng(home.lat, home.lon), 16);
|
|
}
|
|
|
|
/// Zoomt auf die gesamte Mission (HTML-Demonstrator: fitToWaypoints()).
|
|
void _onFitPressed() {
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
if (waypoints.isEmpty) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('No waypoints to fit')));
|
|
return;
|
|
}
|
|
fitMapToWaypoints(ref, waypoints);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
height: 34,
|
|
padding: const EdgeInsets.only(left: 12, right: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.55),
|
|
borderRadius: BorderRadius.circular(17),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
onSubmitted: (_) => _submitSearch(),
|
|
textInputAction: TextInputAction.search,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
border: InputBorder.none,
|
|
hintText: 'Search location',
|
|
hintStyle: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: _submitSearch,
|
|
icon: const Icon(Icons.search, color: Colors.white, size: 16),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
NavIconButton(icon: Icons.home, onTap: _onHomePressed),
|
|
const SizedBox(width: 8),
|
|
NavIconButton(icon: Icons.crop_free, onTap: _onFitPressed),
|
|
],
|
|
);
|
|
}
|
|
}
|