import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/vechicle_identity_service.dart'; import 'package:terepi_seged/widgets/gnss_device_picker_dialog.dart'; import 'package:terepi_seged/widgets/map/animated_map_follow.dart'; class VibroNavView extends GetView { const VibroNavView({super.key}); @override Widget build(BuildContext context) { return Scaffold( // Tömör AppBar, NEM lóg a térkép fölé félig átlátszóan — az előző // verzióban ez tette rosszul láthatóvá az eszköz-választó gombot. appBar: AppBar( // title: const Text('Navigáció'), title: Obx(() => Text( 'N(${controller.sourcePoints.length}/${controller.visibleGeoSourcePoints.length})', style: const TextStyle(fontSize: 11), )), actions: [ Obx(() => IconButton( icon: Icon(controller.showReservePoints.value ? Icons.circle_outlined : Icons.circle), tooltip: controller.showReservePoints.value ? 'Tartalék pontok elrejtése' : 'Tartalék pontok megjelenítése', onPressed: controller.toggleReservePoints, )), Obx(() => IconButton( icon: Icon(controller.trackUpEnabled.value ? Icons.explore : Icons.explore_off_outlined), tooltip: controller.trackUpEnabled.value ? 'Forgó térkép (track-up) — kikapcsolás' : 'Észak-fent nézet — track-up bekapcsolása', onPressed: controller.toggleTrackUp, )), IconButton( icon: const Icon(Icons.bluetooth_connected), tooltip: 'Gyors újracsatlakozás az utolsó GNSS-vevőhöz', onPressed: _quickReconnect, ), IconButton( icon: const Icon(Icons.settings_input_antenna), tooltip: 'GNSS-eszköz kiválasztása', onPressed: () => GnssDevicePickerDialog.show(), ), ], ), body: Obx(() { if (!controller.mapReady.value) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text('Pozíció lekérése…'), ], ), ); } return Stack( children: [ const _NavMap(), Obx(() { final gnss = GnssService.to; final target = controller.followMode.value && controller.hasPosition.value ? LatLng(gnss.latitude.value, gnss.longitude.value) : null; final heading = controller.trackUpEnabled.value ? controller.travelHeading.value : null; return AnimatedMapFollow( mapController: controller.mapController, target: target, heading: heading, ); }), // EGYETLEN sor, egymás mellett — nincs két, egymást átfedő // Positioned widget, tehát fizikailag nem takarhatják egymást. Positioned( left: 8, right: 8, top: 8, child: Row( children: [ const Expanded(child: _VehicleBadge()), const SizedBox(width: 8), const _GnssStatusBadge(), const SizedBox(width: 8), const _CompassWidget(), ], ), ), Positioned( left: 12, right: 12, bottom: 16, child: const _PositionHud(), ), Obx(() => controller.followMode.value ? const SizedBox.shrink() : Positioned( right: 16, bottom: 170, child: FloatingActionButton( heroTag: 'recenter', onPressed: controller.resumeFollowing, child: const Icon(Icons.my_location), ), )), ], ); }), ); } void _quickReconnect() { final device = GnssDeviceService.to.selectedDevice.value; if (device == null) { Get.snackbar('Nincs korábbi GNSS-eszköz', 'Előbb válassz ki egyet a jobb felső ikonnal.', snackPosition: SnackPosition.BOTTOM); return; } Get.snackbar('Újracsatlakozás', '„${device.name}" — folyamatban…', snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 2)); GnssService.to.reconnect(); } } Color _qualityColor(int quality) => quality >= 4 ? Colors.green : (quality > 0 ? Colors.orange : Colors.grey); /// A térkép ITT, önálló widgetként él: a build-je Obx-en kívül fut, így /// GNSS-frissítéskor a FlutterMap és a TileLayer NEM épül újra. Csak az /// alábbi, saját Obx-ű rétegek frissülnek. class _NavMap extends StatelessWidget { const _NavMap(); @override Widget build(BuildContext context) { final controller = VibroNavController.to; final gnss = GnssService.to; // Csak a KEZDŐ középpont — a követést az AnimatedMapFollow végzi. final center = controller.hasPosition.value ? LatLng(gnss.latitude.value, gnss.longitude.value) : const LatLng(47.5, 19.05); return FlutterMap( mapController: controller.mapController, options: MapOptions( initialCenter: center, initialZoom: 17, maxZoom: 22, onPositionChanged: (position, hasGesture) { if (hasGesture && controller.followMode.value) { // Build közben ne írjunk Rx-et — képkocka végére ütemezzük. WidgetsBinding.instance.addPostFrameCallback((_) { controller.followMode.value = false; }); } }, ), children: [ TileLayer( urlTemplate: 'http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'], maxNativeZoom: 18), const _SourcePointsLayer(), Obx(() => controller.trail.length > 1 ? PolylineLayer(polylines: [ Polyline( points: controller.trail.toList(), color: Colors.blueAccent.withOpacity(0.75), strokeWidth: 4, ), ]) : const SizedBox.shrink()), Obx(() { if (!controller.hasPosition.value) return const SizedBox.shrink(); final color = _qualityColor(gnss.gpsQuality.value); return CircleLayer(circles: [ CircleMarker( point: LatLng(gnss.latitude.value, gnss.longitude.value), radius: math.max(gnss.latitudeError.value, gnss.longitudeError.value), useRadiusInMeter: true, color: color.withOpacity(0.12), borderColor: color.withOpacity(0.4), borderStrokeWidth: 1, ), ]); }), Obx(() { if (!controller.hasPosition.value) return const SizedBox.shrink(); final color = _qualityColor(gnss.gpsQuality.value); return MarkerLayer(markers: [ Marker( point: LatLng(gnss.latitude.value, gnss.longitude.value), width: 60, height: 60, child: _PulsingPositionMarker(color: color), ), ]); }), ], ); } } /// A forráspontok rétege — csak a `geoSourcePoints` és a /// `showReservePoints` változására épül újra, a GNSS-frissítésekre nem. /// A koordináták a controllerben már átváltva vannak. class _SourcePointsLayer extends StatelessWidget { const _SourcePointsLayer(); @override Widget build(BuildContext context) { final c = VibroNavController.to; return Obx(() { final list = c.visibleGeoSourcePoints; if (list.isEmpty) return const SizedBox.shrink(); return MarkerLayer( markers: [ for (final g in list) Marker( point: g.latLng, width: 74, height: 26, alignment: Alignment.centerLeft, child: _SourcePointMarker( isShot: g.point.isShotLocation, label: _fmtPointNumber(g.point.pointNumber), ), ), ], ); }); } } class _PulsingPositionMarker extends StatefulWidget { final Color color; const _PulsingPositionMarker({required this.color}); @override State<_PulsingPositionMarker> createState() => _PulsingPositionMarkerState(); } class _PulsingPositionMarkerState extends State<_PulsingPositionMarker> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2)) ..repeat(); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _ctrl, builder: (context, _) { final t = _ctrl.value; return Stack( alignment: Alignment.center, children: [ Opacity( opacity: (1 - t).clamp(0.0, 1.0), child: Container( width: 18 + t * 34, height: 18 + t * 34, decoration: BoxDecoration( shape: BoxShape.circle, color: widget.color.withOpacity(0.35), ), ), ), Container( width: 20, height: 20, decoration: BoxDecoration( color: widget.color, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2.5), boxShadow: const [ BoxShadow(color: Colors.black38, blurRadius: 4), ], ), ), ], ); }, ); } } class _CompassWidget extends StatelessWidget { const _CompassWidget(); @override Widget build(BuildContext context) { final controller = VibroNavController.to; return Obx(() { final heading = controller.travelHeading.value ?? 0; final rotation = controller.trackUpEnabled.value ? -heading : heading; return Container( width: 40, height: 40, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 3)], ), child: Transform.rotate( angle: rotation * math.pi / 180, child: const Icon(Icons.navigation, color: Colors.redAccent, size: 22), ), ); }); } } class _VehicleBadge extends StatelessWidget { const _VehicleBadge(); @override Widget build(BuildContext context) { return Obx(() { final vehicle = VehicleIdentityService.to.selectedVehicle.value; return Material( elevation: 2, borderRadius: BorderRadius.circular(8), color: Colors.white, child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () => _pickVehicle(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.local_shipping_outlined, size: 18), const SizedBox(width: 6), Flexible( child: Text(vehicle ?? 'Jármű kiválasztása', overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w600, color: vehicle == null ? Colors.orange : null)), ), ], ), ), ), ); }); } void _pickVehicle(BuildContext context) { Get.bottomSheet( Material( color: Colors.white, child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final v in VehicleIdentityService.availableVehicles) ListTile( title: Text(v), onTap: () { VehicleIdentityService.to.setVehicle(v); Get.back(); }, ), ], ), ), ); } } class _GnssStatusBadge extends StatelessWidget { const _GnssStatusBadge(); @override Widget build(BuildContext context) { if (!Get.isRegistered()) return const SizedBox.shrink(); return Obx(() { final gnss = GnssService.to; final state = gnss.connectionState.value; final quality = gnss.gpsQuality.value; Color color; IconData icon; String label; if (state == GnssConnectionState.connected && quality > 0) { color = Colors.green; icon = Icons.gps_fixed; label = quality >= 4 ? 'RTK fix' : (quality == 2 ? 'RTK float' : 'GPS'); } else if (state == GnssConnectionState.connected) { color = Colors.orange; icon = Icons.gps_not_fixed; label = 'Nincs fix'; } else if (state == GnssConnectionState.connecting) { color = Colors.orange; icon = Icons.gps_not_fixed; label = 'Csatlakozás…'; } else { color = Colors.red; icon = Icons.gps_off; label = 'Nincs GNSS'; } return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: color, width: 1.5), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 3)], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 4), Text(label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: color)), ], ), ); }); } } class _PositionHud extends StatelessWidget { const _PositionHud(); @override Widget build(BuildContext context) { final controller = VibroNavController.to; return Obx(() { final gnss = GnssService.to; if (!controller.hasPosition.value) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.black.withOpacity(0.72), borderRadius: BorderRadius.circular(16), boxShadow: const [BoxShadow(color: Colors.black45, blurRadius: 10)], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( controller.speedKmh.value.toStringAsFixed(0), style: const TextStyle( fontSize: 44, fontWeight: FontWeight.bold, color: Colors.white, height: 1, fontFeatures: [FontFeature.tabularFigures()]), ), const Padding( padding: EdgeInsets.only(left: 4, bottom: 6), child: Text('km/h', style: TextStyle(color: Colors.white70, fontSize: 13)), ), const Spacer(), _UploadPulseDot( active: controller.externalUploadPulse.value, label: 'Külső'), const SizedBox(width: 10), _UploadPulseDot( active: controller.internalUploadPulse.value, label: 'Belső'), ], ), const SizedBox(height: 6), Text( '${gnss.latitude.value.toStringAsFixed(6)}, ' '${gnss.longitude.value.toStringAsFixed(6)}', style: const TextStyle( fontSize: 13, color: Colors.white70, fontFeatures: [FontFeature.tabularFigures()]), ), const SizedBox(height: 6), Row( children: [ _hudChip(Icons.satellite_alt, '${gnss.satelliteCount.value}'), const SizedBox(width: 12), _hudChip(Icons.height, '${gnss.altitude.value.toStringAsFixed(1)} m'), const SizedBox(width: 12), _hudChip(Icons.adjust, '±${math.max(gnss.latitudeError.value, gnss.longitudeError.value).toStringAsFixed(2)} m'), ], ), ], ), ); }); } Widget _hudChip(IconData icon, String value) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: Colors.white54), const SizedBox(width: 3), Text(value, style: const TextStyle(fontSize: 12, color: Colors.white70)), ], ); } class _UploadPulseDot extends StatelessWidget { final bool active; final String label; const _UploadPulseDot({required this.active, required this.label}); @override Widget build(BuildContext context) { return Column( children: [ AnimatedContainer( duration: const Duration(milliseconds: 250), width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, color: active ? Colors.greenAccent : Colors.white24, boxShadow: active ? [ BoxShadow( color: Colors.greenAccent.withOpacity(0.7), blurRadius: 6) ] : null, ), ), const SizedBox(height: 2), Text(label, style: const TextStyle(fontSize: 9, color: Colors.white54)), ], ); } } String _fmtPointNumber(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1); class _SourcePointMarker extends StatelessWidget { final bool isShot; final String label; const _SourcePointMarker({required this.isShot, required this.label}); @override Widget build(BuildContext context) { // Jól látható, telített színek napfényben — a jelgerjesztés helye // élénk piros-narancs, a tartalék pont cián; mindkettő fekete // körvonallal, hogy világos térkép-háttéren se vesszen el. final dotColor = isShot ? const Color(0xFFFF1744) : const Color(0xFF00E5FF); return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: isShot ? 11 : 8, height: isShot ? 11 : 8, decoration: BoxDecoration( shape: BoxShape.circle, color: dotColor, border: Border.all(color: Colors.black87, width: 1), ), ), const SizedBox(width: 3), _OutlinedLabel(text: label, color: dotColor), ], ); } } /// Felirat HÁTTÉR NÉLKÜL, de fehér körvonallal a betűk körül — így /// bármilyen térkép-háttéren olvasható marad, nincs szükség kitöltött /// buborékra. class _OutlinedLabel extends StatelessWidget { final String text; final Color color; const _OutlinedLabel({required this.text, required this.color}); @override Widget build(BuildContext context) { return Stack( children: [ Text( text, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w800, foreground: Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..color = Colors.white, ), ), Text( text, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w800, color: color, ), ), ], ); } }