Merge search/home/fit controls into the Plan/Fly header pill
Suche, Home- und Fit-Button sassen bisher in einer eigenen Leiste unter der Plan/Fly-Kopfleiste - ein reiner UI-Kompromiss, weil die Karte (inkl. MapController) in PlanScreen lebte und von der aeusseren Kopfleiste aus nicht erreichbar war. Der HTML-Demonstrator zeigt Suche/Home/Fit dagegen als Mittelteil derselben Leiste wie die Modus-Buttons (#searchWrap/#homeBtn/#fitBtn in #topBarMiddle) - das war also keine Stilfrage, sondern eine Datenfluss-Frage. Loesung: MapController aus einem PlanScreen-privaten Feld in einen app-lebenslangen mapControllerProvider (Riverpod) gehoben. Damit kann die neue MapSearchControls (ersetzt TopNavBar) direkt in TopModeBar eingebettet werden und lebt architektonisch dort, wo sie hingehoert: bei der Kartensteuerung, nicht als Kind von PlanScreen. Als Nebeneffekt - tatsaechlich der wichtigere Punkt - bleibt die Kamera-Position/Zoom jetzt auch beim Wechsel Plan -> Fly -> Plan erhalten. Verifiziert per flutter_map-Quellcode (FlutterMap haengt einen extern uebergebenen Controller beim Neu-Mounten nicht an und disposed ihn nicht) sowie per neuem Widget-Test, der den Cubit direkt durch das Ready-to-Fly-Gate schickt (der Fly-Modus ist ueber die UI aktuell nicht erreichbar, da Upload/Verify noch nicht implementiert ist) und die Kamera vor/nach dem Wechsel vergleicht. Zusaetzlich: _onMapEvent unterscheidet jetzt per MapEventMove.source, ob eine Kartenbewegung programmatisch (mapController, z.B. durch Suche/ Home/Fit) oder per Geste ausgeloest wurde, und triggert den Snap-to-Edit-Handler entsprechend nur bei Gesten bzw. sofort bei programmatischen Moves - das ersetzt den bisherigen Ansatz, an jeder Aufrufstelle manuell _handleMoveEnd() zu rufen, der nicht mehr skaliert sobald diese Aufrufstellen (wie jetzt) in einem anderen Widget liegen. Verifiziert: flutter analyze (0 issues), flutter test (22/22, inkl. neuem Kamera-Persistenz-Test), manuell auf Pixel_10a-Emulator (Release- Build) - Suche/Home/Fit erscheinen fusioniert in der gruenen Pille, Kartenverschiebung bleibt nach Wechsel in den (durch das Gate weiterhin blockierten) Fly-Modus sichtbar unveraendert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
dc8e6f9d6e
commit
13da935f99
@@ -0,0 +1,165 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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 '../providers/current_mission_provider.dart';
|
||||
import '../providers/map_controller_provider.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 uri = Uri.https('nominatim.openstreetmap.org', '/search', {
|
||||
'format': 'json',
|
||||
'addressdetails': '1',
|
||||
'accept-language': 'en',
|
||||
'limit': '1',
|
||||
'q': query,
|
||||
});
|
||||
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);
|
||||
ref.read(mapControllerProvider).move(LatLng(lat, lon), 15);
|
||||
} 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;
|
||||
}
|
||||
final points = [for (final w in waypoints) LatLng(w.lat, w.lon)];
|
||||
ref.read(mapControllerProvider).fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: LatLngBounds.fromPoints(points),
|
||||
padding: const EdgeInsets.fromLTRB(50, 50, 50, 140),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../app_mode/app_mode_cubit.dart';
|
||||
import '../dmc_colors.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 Ortssuche und die
|
||||
/// Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #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.
|
||||
class TopModeBar extends StatelessWidget {
|
||||
const TopModeBar({super.key});
|
||||
|
||||
@@ -19,7 +26,7 @@ class TopModeBar extends StatelessWidget {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.64,
|
||||
widthFactor: isFly ? 0.64 : 0.86,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
@@ -40,7 +47,13 @@ class TopModeBar extends StatelessWidget {
|
||||
),
|
||||
onTap: () => context.read<AppModeCubit>().toPlan(),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isFly)
|
||||
const Spacer()
|
||||
else ...[
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: MapSearchControls()),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
_ModeButton(
|
||||
label: 'Fly',
|
||||
isActive: isFly,
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user