From dc8e6f9d6e8c0044e8ce0b04d13fffcc5cf90991 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:06:16 +0200 Subject: [PATCH] Add location search, home/fit map buttons, and slim OSM attribution - TopNavBar: Nominatim geocoding search field plus Home (center on first waypoint) and Fit (zoom to mission bounds) buttons (HTML prototype: #searchWrap/#homeBtn/#fitBtn). Both show a SnackBar instead of doing nothing when there are no waypoints yet. - Renders as its own bar just below TopModeBar rather than fused into the same pill: TopModeBar lives in AppShell, a layer above PlanScreen, which owns the MapController these actions actually need. Fusing them would need lifting the MapController to shared state - reasonable follow-up if pixel-fidelity with the prototype's single bar matters later, but not necessary for the functionality itself. - Replaced SimpleAttributionWidget with a compact custom attribution: OSM's tile usage policy requires visible attribution to stay, so instead of removing it (as first asked) I shrank it and dropped the "flutter_map |" prefix per the user's follow-up choice, so it reads cleanly against the now-opaque bottom bar instead of looking like a second banner. Added 5 tests (nav bar renders, home/fit SnackBars with no waypoints, empty search is a no-op). All 26 tests and flutter analyze pass. Verified on the Pixel_10a emulator: searched "Berlin" and confirmed the map flew there, dropped a waypoint, panned far away, and confirmed Home re-centers and snaps onto it exactly. Co-Authored-By: Claude Sonnet 5 --- app/lib/ui/screens/plan/plan_screen.dart | 80 +++++++++++++++++ app/lib/ui/widgets/mission_map.dart | 37 ++++++-- app/lib/ui/widgets/top_nav_bar.dart | 110 +++++++++++++++++++++++ app/test/widget_test.dart | 43 +++++++++ 4 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 app/lib/ui/widgets/top_nav_bar.dart diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 10e374e..891bcf5 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import '../../../domain/mission/default_drone_profile.dart'; @@ -15,6 +17,7 @@ import '../../widgets/halo_menu.dart'; import '../../widgets/mini_altitude_profile.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; +import '../../widgets/top_nav_bar.dart'; import '../../widgets/value_wheel.dart'; import '../../widgets/waypoint_list_panel.dart'; @@ -254,6 +257,68 @@ class _PlanScreenState extends ConsumerState { _handleMoveEnd(); } + /// Ortssuche per Nominatim-Geocoding (HTML-Demonstrator: doSearch()). + /// Zentriert die Karte auf den ersten Treffer. + Future _onSearch(String query) async { + final q = query.trim(); + if (q.isEmpty) return; + final messenger = ScaffoldMessenger.of(context); + try { + final uri = Uri.https('nominatim.openstreetmap.org', '/search', { + 'format': 'json', + 'addressdetails': '1', + 'accept-language': 'en', + 'limit': '1', + 'q': q, + }); + final response = await http + .get(uri, headers: {'User-Agent': 'dmc_app (Drone Mission Control)'}); + final results = jsonDecode(response.body) as List; + if (results.isEmpty) { + messenger.showSnackBar(const SnackBar(content: Text('Location not found'))); + return; + } + final first = results.first as Map; + final lat = double.parse(first['lat'] as String); + final lon = double.parse(first['lon'] as String); + _mapController.move(LatLng(lat, lon), 15); + _handleMoveEnd(); + } 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; + _mapController.move(LatLng(home.lat, home.lon), 16); + _handleMoveEnd(); + } + + /// 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; + } + final points = [for (final w in waypoints) LatLng(w.lat, w.lon)]; + _mapController.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds.fromPoints(points), + padding: const EdgeInsets.fromLTRB(50, 50, 50, 140), + ), + ); + _handleMoveEnd(); + } + /// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als /// internen Stack-Overlay, damit sie wirklich alles ueberdeckt - /// inklusive der Kopfleiste im AppShell darueber. @@ -357,6 +422,21 @@ class _PlanScreenState extends ConsumerState { ), ], ), + Positioned( + // Direkt unterhalb der Plan/Fly-Kopfleiste (AppShell/TopModeBar), + // dieselbe Hoehe wie der Start der Draeder (top:56) - dort ist + // die Kopfleiste garantiert bereits zu Ende. + top: 62, + left: 0, + right: 0, + child: Center( + child: TopNavBar( + onSearch: _onSearch, + onHome: _onHomePressed, + onFit: _onFitPressed, + ), + ), + ), if (_mode == _ReticleMode.editing && targetWaypoint != null) Align( alignment: Alignment.center, diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index e30e260..5886b16 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -72,13 +72,38 @@ class MissionMap extends StatelessWidget { ], ), CircleLayer(circles: waypointMarkers), - SimpleAttributionWidget( - alignment: Alignment.bottomLeft, - source: const Text('OpenStreetMap contributors'), - onTap: () => - launchUrl(Uri.parse('https://www.openstreetmap.org/copyright')), - ), + const _CompactAttribution(), ], ); } } + +/// Schlanke Pflicht-Attribution fuer die OSM-Kacheln (Tile Usage Policy) - +/// bewusst ohne den "flutter_map |"-Praefix von SimpleAttributionWidget und +/// dezenter gestaltet, damit sie nicht wie ein zweiter Balken ueber der +/// Bottom-Bar wirkt. Bleibt sichtbar und antippbar, da die Policy sichtbare +/// Namensnennung verlangt. +class _CompactAttribution extends StatelessWidget { + const _CompactAttribution(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Align( + alignment: Alignment.bottomLeft, + child: GestureDetector( + onTap: () => + launchUrl(Uri.parse('https://www.openstreetmap.org/copyright')), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + color: Colors.white.withValues(alpha: 0.5), + child: const Text( + '© OpenStreetMap contributors', + style: TextStyle(fontSize: 9, color: Colors.black87), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/top_nav_bar.dart b/app/lib/ui/widgets/top_nav_bar.dart new file mode 100644 index 0000000..76aa907 --- /dev/null +++ b/app/lib/ui/widgets/top_nav_bar.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +/// Suchleiste + Navigations-Buttons (HTML-Demonstrator: #searchWrap/ +/// #homeBtn/#fitBtn). Rendert unterhalb der Plan/Fly-Kopfleiste statt in +/// dieselbe Pille eingeschmolzen - TopModeBar liegt im AppShell, eine +/// Ebene ueber PlanScreen, das den MapController besitzt, den Suche/ +/// Home/Fit tatsaechlich benoetigen. Ein gemeinsamer Riverpod-State fuer +/// den MapController koennte das spaeter fusionieren. +class TopNavBar extends StatefulWidget { + const TopNavBar({ + super.key, + required this.onSearch, + required this.onHome, + required this.onFit, + }); + + final ValueChanged onSearch; + final VoidCallback onHome; + final VoidCallback onFit; + + @override + State createState() => _TopNavBarState(); +} + +class _TopNavBarState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + widget.onSearch(_controller.text); + FocusScope.of(context).unfocus(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 260, + height: 38, + padding: const EdgeInsets.only(left: 12, right: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(19), + border: Border.all(color: Colors.white.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + onSubmitted: (_) => _submit(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'Search location', + hintStyle: + TextStyle(color: Colors.white.withValues(alpha: 0.55)), + ), + ), + ), + IconButton( + onPressed: _submit, + icon: const Icon(Icons.search, color: Colors.white, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 30, minHeight: 30), + ), + ], + ), + ), + const SizedBox(width: 8), + _NavIconButton(icon: Icons.home, onTap: widget.onHome), + const SizedBox(width: 8), + _NavIconButton(icon: Icons.crop_free, onTap: widget.onFit), + ], + ); + } +} + +class _NavIconButton extends StatelessWidget { + const _NavIconButton({required this.icon, required this.onTap}); + + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.65), + border: Border.all(color: Colors.white.withValues(alpha: 0.4)), + ), + child: Icon(icon, color: Colors.white, size: 18), + ), + ); + } +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 9d29922..15afc5f 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -11,6 +11,7 @@ import 'package:dmc_app/ui/providers/current_mission_provider.dart'; import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; +import 'package:dmc_app/ui/widgets/top_nav_bar.dart'; import 'package:dmc_app/ui/widgets/waypoint_chip.dart'; import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart'; @@ -309,4 +310,46 @@ void main() { final waypoints = container.read(currentMissionProvider); expect(waypoints.single.speedMs, greaterThan(15)); }); + + testWidgets('TopNavBar zeigt Suchfeld und Home-/Fit-Buttons', + (tester) async { + await _pumpWithContainer(tester); + + expect(find.byType(TopNavBar), findsOneWidget); + expect(find.text('Search location'), findsOneWidget); + expect(find.byIcon(Icons.home), findsOneWidget); + expect(find.byIcon(Icons.crop_free), findsOneWidget); + }); + + testWidgets('Home-Button ohne Wegpunkte zeigt Hinweis statt Absturz', + (tester) async { + await _pumpWithContainer(tester); + + await tester.tap(find.byIcon(Icons.home)); + await tester.pump(); + + expect(find.text('No waypoints yet'), findsOneWidget); + }); + + testWidgets('Fit-Button ohne Wegpunkte zeigt Hinweis statt Absturz', + (tester) async { + await _pumpWithContainer(tester); + + await tester.tap(find.byIcon(Icons.crop_free)); + await tester.pump(); + + expect(find.text('No waypoints to fit'), findsOneWidget); + }); + + testWidgets('Leere Suche loest keinen Netzwerkaufruf/Fehler aus', + (tester) async { + await _pumpWithContainer(tester); + + await tester.enterText(find.byType(TextField), ''); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(find.text('Search failed'), findsNothing); + expect(find.text('Location not found'), findsNothing); + }); }