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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3091476771
commit
dc8e6f9d6e
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../../../domain/mission/default_drone_profile.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/mini_altitude_profile.dart';
|
||||||
import '../../widgets/mission_map.dart';
|
import '../../widgets/mission_map.dart';
|
||||||
import '../../widgets/reticle_button.dart';
|
import '../../widgets/reticle_button.dart';
|
||||||
|
import '../../widgets/top_nav_bar.dart';
|
||||||
import '../../widgets/value_wheel.dart';
|
import '../../widgets/value_wheel.dart';
|
||||||
import '../../widgets/waypoint_list_panel.dart';
|
import '../../widgets/waypoint_list_panel.dart';
|
||||||
|
|
||||||
@@ -254,6 +257,68 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
_handleMoveEnd();
|
_handleMoveEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ortssuche per Nominatim-Geocoding (HTML-Demonstrator: doSearch()).
|
||||||
|
/// Zentriert die Karte auf den ersten Treffer.
|
||||||
|
Future<void> _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<String, dynamic>;
|
||||||
|
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
|
/// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als
|
||||||
/// internen Stack-Overlay, damit sie wirklich alles ueberdeckt -
|
/// internen Stack-Overlay, damit sie wirklich alles ueberdeckt -
|
||||||
/// inklusive der Kopfleiste im AppShell darueber.
|
/// inklusive der Kopfleiste im AppShell darueber.
|
||||||
@@ -357,6 +422,21 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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)
|
if (_mode == _ReticleMode.editing && targetWaypoint != null)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
|
|||||||
@@ -72,13 +72,38 @@ class MissionMap extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
CircleLayer(circles: waypointMarkers),
|
CircleLayer(circles: waypointMarkers),
|
||||||
SimpleAttributionWidget(
|
const _CompactAttribution(),
|
||||||
alignment: Alignment.bottomLeft,
|
|
||||||
source: const Text('OpenStreetMap contributors'),
|
|
||||||
onTap: () =>
|
|
||||||
launchUrl(Uri.parse('https://www.openstreetmap.org/copyright')),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<String> onSearch;
|
||||||
|
final VoidCallback onHome;
|
||||||
|
final VoidCallback onFit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TopNavBar> createState() => _TopNavBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TopNavBarState extends State<TopNavBar> {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/full_value_chart.dart';
|
||||||
import 'package:dmc_app/ui/widgets/halo_menu.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/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_chip.dart';
|
||||||
import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart';
|
import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart';
|
||||||
|
|
||||||
@@ -309,4 +310,46 @@ void main() {
|
|||||||
final waypoints = container.read(currentMissionProvider);
|
final waypoints = container.read(currentMissionProvider);
|
||||||
expect(waypoints.single.speedMs, greaterThan(15));
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user