diff --git a/app/lib/ui/widgets/drone_follow_icon.dart b/app/lib/ui/widgets/drone_follow_icon.dart new file mode 100644 index 0000000..4b9e843 --- /dev/null +++ b/app/lib/ui/widgets/drone_follow_icon.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +/// Statisches (nicht rotierendes) Papierflieger-Symbol fuer den +/// Kartenfolge-Knopf in der Kopfleiste (FlyMapControls) - dieselbe +/// Silhouette wie [DroneMarkerIcon] auf der Karte, hier aber einfarbig +/// statt zweifarbig: nur Umriss im inaktiven Zustand, komplett mit dem +/// Akzent-Pink gefuellt im aktiven Folgemodus. +class DroneFollowIcon extends StatelessWidget { + const DroneFollowIcon({super.key, this.size = 16, this.active = false}); + + final double size; + final bool active; + + @override + Widget build(BuildContext context) { + // NavIconButton zwingt seinem Kind straffe 34x34-Constraints auf - ohne + // Center/SizedBox wuerde CustomPaint sein size-Argument ignorieren und + // auf die volle Knopfgroesse aufgeblasen (RenderCustomPaint hat anders + // als Icon kein eingebautes Center, das die Constraints fuer sein Kind + // lockert). Center() lockert die Constraints, die SizedBox erzwingt + // danach die tatsaechlich gewuenschte, kleinere Zeichenflaeche. + return Center( + child: SizedBox( + width: size, + height: size, + child: CustomPaint(painter: _DroneFollowPainter(active: active)), + ), + ); + } +} + +class _DroneFollowPainter extends CustomPainter { + _DroneFollowPainter({required this.active}); + + final bool active; + + // Gleiche Koordinaten wie DroneMarkerIcon._PaperPlanePainter (viewBox + // 0 0 200 200, aus design/B_paperplane.svg), hier als ein einzelner + // Umriss statt zweier separat gefuellter Haelften. + static const _viewBoxSize = 200.0; + static const _nose = Offset(100, 22); + static const _rightWing = Offset(164, 172); + static const _tailNotch = Offset(100, 138); + static const _leftWing = Offset(36, 172); + static const _pink = Color(0xFFFF2E93); + static const _strokeWidth = 16.0; + + @override + void paint(Canvas canvas, Size size) { + final scale = size.width / _viewBoxSize; + Offset s(Offset p) => p * scale; + + final outline = Path() + ..moveTo(s(_nose).dx, s(_nose).dy) + ..lineTo(s(_rightWing).dx, s(_rightWing).dy) + ..lineTo(s(_tailNotch).dx, s(_tailNotch).dy) + ..lineTo(s(_leftWing).dx, s(_leftWing).dy) + ..close(); + + canvas.drawPath( + outline, + active + ? (Paint()..color = _pink) + : (Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth * scale + ..strokeJoin = StrokeJoin.round), + ); + } + + @override + bool shouldRepaint(covariant _DroneFollowPainter oldDelegate) => + oldDelegate.active != active; +} diff --git a/app/lib/ui/widgets/fly_map_controls.dart b/app/lib/ui/widgets/fly_map_controls.dart index cb54199..8ee70d9 100644 --- a/app/lib/ui/widgets/fly_map_controls.dart +++ b/app/lib/ui/widgets/fly_map_controls.dart @@ -6,6 +6,7 @@ import '../providers/current_mission_provider.dart'; import '../providers/follow_drone_provider.dart'; import '../providers/map_controller_provider.dart'; import '../providers/telemetry_provider.dart'; +import 'drone_follow_icon.dart'; import 'nav_icon_button.dart'; /// Kartensteuerung im Fly-Modus (Doku 3.11): Ortssuche/Home entfallen hier @@ -16,10 +17,11 @@ import 'nav_icon_button.dart'; class FlyMapControls extends ConsumerWidget { const FlyMapControls({super.key}); - /// Zielzoom beim Aktivieren des Folgemodus - naeher als der - /// Standard-Kartenzoom (siehe MissionMap._initialZoom), damit der Sprung - /// tatsaechlich als Zoom wahrgenommen wird und nicht nur als Verschieben. - static const _followZoom = 18.0; + /// Zielzoom beim Aktivieren des Folgemodus - deutlich weiter herausgezoomt + /// als der Standard-Kartenzoom (siehe MissionMap._initialZoom), damit + /// waehrend des Verfolgens genug Gelaende/Route um die Drohne herum + /// sichtbar bleibt statt nur ihrer unmittelbaren Umgebung. + static const _followZoom = 16.0; /// Erster Tap zoomt auf die aktuelle Telemetrieposition und aktiviert den /// Folgemodus (FlyScreen haengt sich darauf ein, zieht die Karte bei @@ -41,6 +43,14 @@ class FlyMapControls extends ConsumerWidget { ref.read(followDroneProvider.notifier).set(true); } + /// Beendet den Folgemodus explizit, statt sich auf den Abbruch bei + /// manueller Karteninteraktion in FlyScreen zu verlassen (FlyScreen._ + /// onMapEvent) - fitMapToWaypoints() bewegt die Kamera ueber denselben + /// MapController (source == mapController), gilt dort also nicht als + /// manuelle Geste und wuerde den Folgemodus sonst aktiv lassen. Ohne + /// diesen Abbruch wuerde der naechste Telemetrie-Frame die Kamera sofort + /// wieder von der gerade eingestellten Missionsuebersicht weg auf die + /// Drohne zurueckziehen. void _onFitPressed(BuildContext context, WidgetRef ref) { final waypoints = ref.read(currentMissionProvider); if (waypoints.isEmpty) { @@ -48,6 +58,7 @@ class FlyMapControls extends ConsumerWidget { .showSnackBar(const SnackBar(content: Text('No waypoints to fit'))); return; } + ref.read(followDroneProvider.notifier).set(false); fitMapToWaypoints(ref, waypoints); } @@ -58,8 +69,7 @@ class FlyMapControls extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ NavIconButton( - icon: following ? Icons.gps_fixed : Icons.my_location, - active: following, + iconWidget: DroneFollowIcon(active: following), onTap: () => _onDronePressed(context, ref), ), const SizedBox(width: 8), diff --git a/app/lib/ui/widgets/nav_icon_button.dart b/app/lib/ui/widgets/nav_icon_button.dart index d056f01..61c80c6 100644 --- a/app/lib/ui/widgets/nav_icon_button.dart +++ b/app/lib/ui/widgets/nav_icon_button.dart @@ -6,18 +6,23 @@ import 'package:flutter/material.dart'; class NavIconButton extends StatelessWidget { const NavIconButton({ super.key, - required this.icon, + this.icon, + this.iconWidget, required this.onTap, - this.active = false, - }); + }) : assert( + icon != null || iconWidget != null, + 'Entweder icon oder iconWidget angeben.', + ); + + final IconData? icon; + + /// Individuelles Icon-Widget statt eines Material-[IconData], z.B. + /// [DroneFollowIcon] fuer den Kartenfolge-Knopf (FlyMapControls). Hat + /// Vorrang vor [icon], falls beides gesetzt ist. + final Widget? iconWidget; - final IconData icon; final VoidCallback onTap; - /// Rosa Akzentrand (Doku 3.10: DroneMarkerIcon-Farbe) fuer Knoepfe mit - /// an/aus-Zustand, z.B. den Drohnen-Folgemodus (FlyMapControls). - final bool active; - @override Widget build(BuildContext context) { return GestureDetector( @@ -28,11 +33,8 @@ class NavIconButton extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.black.withValues(alpha: 0.55), - border: active - ? Border.all(color: const Color(0xFFFF2E93), width: 2) - : null, ), - child: Icon(icon, color: Colors.white, size: 16), + child: iconWidget ?? Icon(icon, color: Colors.white, size: 16), ), ); } diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 769ecaf..6d52220 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -26,6 +26,7 @@ import 'package:dmc_app/ui/providers/mission_meta_provider.dart'; import 'package:dmc_app/ui/providers/sharing_provider.dart'; import 'package:dmc_app/ui/providers/telemetry_provider.dart'; import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart'; +import 'package:dmc_app/ui/widgets/drone_follow_icon.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/header_wind_pill.dart'; @@ -984,13 +985,13 @@ void main() { // Noch keine Telemetrie eingetroffen (MockFlightControllerLink tickt // erst nach 1s) - Hinweis statt Kamerasprung. - await tester.tap(find.byIcon(Icons.my_location)); + await tester.tap(find.byType(DroneFollowIcon)); await tester.pump(); expect(find.text('No telemetry yet'), findsOneWidget); await tester.pump(const Duration(seconds: 1, milliseconds: 100)); - await tester.tap(find.byIcon(Icons.my_location)); + await tester.tap(find.byType(DroneFollowIcon)); await tester.pump(); final camera = container.read(mapControllerProvider).camera;