import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geolocator/geolocator.dart' as geo; import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/models/measure_source_point.dart'; import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/gnss/phone_gps_connection.dart'; import 'package:terepi_seged/services/measure_source_point_service.dart'; import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/services/vechicle_identity_service.dart'; /// Forráspont + egyszer kiszámolt WGS84 pozíció. A térkép ebből rajzol, /// így GNSS-frissítéskor nincs újraszámolás. class GeoSourcePoint { final MeasureSourcePoint point; final LatLng latLng; const GeoSourcePoint(this.point, this.latLng); } /// Navigációs képernyő — a KÜLSŐ (Bluetooth/BLE RTK-vevő, vagy a telefon /// GPS-e, amit a GnssService-eszközválasztóval választasz ki) a FŐ /// pozíció. EMELLETT, attól teljesen FÜGGETLENÜL, mindig fut egy saját, /// belső telefon-GPS kapcsolat is — akkor is naplózódik, ha épp külső /// vevő az aktív forrás. /// /// Mindkét adatfolyam a KÖZÖS, PC-szoftverrel is megosztott /// `TerepiSeged_VibratorTracker` táblába ír, a `gpsType` mezővel /// megkülönböztetve (1 = külső/fő forrás, 2 = belső telefon-GPS). class VibroNavController extends GetxController { static VibroNavController get to => Get.find(); static const _gpsTypeExternal = 1; static const _gpsTypeInternal = 2; static const _trailMaxPoints = 300; static const _trailMinDistM = 3.0; final mapController = MapController(); final mapReady = false.obs; final hasPosition = false.obs; final travelHeading = Rxn(); final speedKmh = 0.0.obs; /// Ha true, a térkép automatikusan követi a pozíciót. Amint a /// felhasználó kézzel elhúzza/nagyítja a térképet, false-ra vált — a /// nézet ilyenkor "ott marad", és egy gomb hozza vissza a követést. final followMode = true.obs; /// Track-up: a térkép a haladási irány szerint forogjon, ne mindig /// észak legyen fent. Kikapcsolható, ha inkább a hagyományos, /// észak-fent nézetet szeretnéd. final trackUpEnabled = true.obs; final trail = [].obs; final sourcePoints = [].obs; final geoSourcePoints = [].obs; final Map _geoCache = {}; final isLoadingSourcePoints = false.obs; /// Ha false, a tartalék (plan_state = 0) pontok nem jelennek meg a /// térképen — csak a jelgerjesztés helyei (plan_state = 1) látszanak. final showReservePoints = false.obs; final lastExternalUploadAt = Rxn(); final lastInternalUploadAt = Rxn(); final externalUploadPulse = false.obs; final internalUploadPulse = false.obs; StreamSubscription? _gnssUpdateSub; Timer? _externalUploadTimer; final _internalGps = PhoneGpsConnection(); StreamSubscription? _internalPositionSub; StreamSubscription? _sourcePointsSub; geo.Position? _lastInternalPosition; Timer? _internalUploadTimer; double? _histY, _histX; DateTime? _histTime; static const _headingFreezeSpeedKmh = 5.0; @override void onInit() { super.onInit(); watchSourcePoints(); ever(ProjectService.to.activeProject, (_) => watchSourcePoints()); if (Get.isRegistered()) { _gnssUpdateSub = GnssService.to.onDataUpdated.listen((_) => _onExternalPosition()); GnssService.to.determineInitialPosition().whenComplete(() { // A kezdő pozíció lekérése önmagában NEM vált ki onDataUpdated-et // — ha lett érvényes koordináta, itt kell külön jeleznünk, hogy a // térkép ne maradjon a "nincs pozíció" alapértéken. final gnss = GnssService.to; if (gnss.latitude.value != 0) { hasPosition.value = true; _addTrailPoint(LatLng(gnss.latitude.value, gnss.longitude.value)); } mapReady.value = true; }); } else { mapReady.value = true; } // A kézi térkép-húzás felismerése mostantól a MapOptions // onPositionChanged (hasGesture) callback-jén megy, közvetlenül a // View-ban — az megbízhatóbb, mint a nyers mapEventStream-forrás // találgatása volt. _externalUploadTimer = Timer.periodic(const Duration(seconds: 5), (_) => _uploadExternal()); _startInternalGps(); _internalUploadTimer = Timer.periodic(const Duration(seconds: 10), (_) => _uploadInternal()); } @override void onClose() { _gnssUpdateSub?.cancel(); _sourcePointsSub?.cancel(); _externalUploadTimer?.cancel(); _internalUploadTimer?.cancel(); _internalPositionSub?.cancel(); _internalGps.disconnect().whenComplete(_internalGps.dispose); super.onClose(); } void resumeFollowing() { followMode.value = true; } void toggleTrackUp() { trackUpEnabled.value = !trackUpEnabled.value; } // ═════════════════════════════════════════════════════════════════ // Külső (fő) pozíció // ═════════════════════════════════════════════════════════════════ void _onExternalPosition() { final gnss = GnssService.to; if (gnss.gpsQuality.value <= 0 || gnss.latitude.value == 0 || !Get.isRegistered()) { return; } final p = CoordConverterService.to .wgsToEovPoint(gnss.longitude.value, gnss.latitude.value); if (!p.x.isFinite || !p.y.isFinite) return; final now = DateTime.now(); if (_histY != null && _histTime != null) { final dy = p.x - _histY!; final dx = p.y - _histX!; final dist = math.sqrt(dy * dy + dx * dx); final dtSec = now.difference(_histTime!).inMilliseconds / 1000.0; if (dtSec > 0) { final v = (dist / dtSec) * 3.6; speedKmh.value = v; if (v >= _headingFreezeSpeedKmh && dist > 0.3) { travelHeading.value = (math.atan2(dy, dx) * 180 / math.pi + 360) % 360; } } } _histY = p.x; _histX = p.y; _histTime = now; hasPosition.value = true; _addTrailPoint(LatLng(gnss.latitude.value, gnss.longitude.value)); } void _addTrailPoint(LatLng p) { if (trail.isNotEmpty) { final d = const Distance().as(LengthUnit.Meter, trail.last, p); if (d < _trailMinDistM) return; } trail.add(p); if (trail.length > _trailMaxPoints) trail.removeAt(0); } Future _uploadExternal() async { final gnss = GnssService.to; if (!hasPosition.value) return; final vehicleNumber = _vehicleNumberFrom(VehicleIdentityService.to.selectedVehicle.value); if (vehicleNumber == null) return; try { final p = Get.isRegistered() ? CoordConverterService.to .wgsToEovPoint(gnss.longitude.value, gnss.latitude.value) : null; await Supabase.instance.client .from('TerepiSeged_VibratorTracker') .insert({ 'gpsType': _gpsTypeExternal, 'vibratorNumber': vehicleNumber, 'gpsQuality': gnss.gpsQuality.value, 'gpsDateTime': gnss.gpsDateTime.value.toUtc().toIso8601String(), 'latitude': gnss.latitude.value, 'longitude': gnss.longitude.value, 'altitude': gnss.altitude.value, 'eovY': p?.x, 'eovX': p?.y, 'horizontalError': math.max(gnss.latitudeError.value, gnss.longitudeError.value), 'verticalError': gnss.altitudeError.value, }); lastExternalUploadAt.value = DateTime.now(); _pulse(externalUploadPulse); } catch (e) { AppLogger.e( 'NavigationController', 'Külső GPS-pozíció feltöltési hiba: $e'); } } // ═════════════════════════════════════════════════════════════════ // Belső (telefon) GPS // ═════════════════════════════════════════════════════════════════ Future _startInternalGps() async { _internalPositionSub = _internalGps.positionStream.listen((pos) { _lastInternalPosition = pos; }); await _internalGps.connect('internal'); } Future _uploadInternal() async { final pos = _lastInternalPosition; if (pos == null) return; final vehicleNumber = _vehicleNumberFrom(VehicleIdentityService.to.selectedVehicle.value); if (vehicleNumber == null) return; try { final p = Get.isRegistered() ? CoordConverterService.to.wgsToEovPoint(pos.longitude, pos.latitude) : null; await Supabase.instance.client .from('TerepiSeged_VibratorTracker') .insert({ 'gpsType': _gpsTypeInternal, 'vibratorNumber': vehicleNumber, 'gpsQuality': 1, 'gpsDateTime': pos.timestamp.toUtc().toIso8601String(), 'latitude': pos.latitude, 'longitude': pos.longitude, 'altitude': pos.altitude, 'eovY': p?.x, 'eovX': p?.y, 'horizontalError': pos.accuracy, 'verticalError': pos.altitudeAccuracy, }); lastInternalUploadAt.value = DateTime.now(); _pulse(internalUploadPulse); } catch (e) { AppLogger.e('NavigationController', 'Belső (telefon) GPS-pozíció feltöltési hiba: $e'); } } // ═════════════════════════════════════════════════════════════════ // Segéd // ═════════════════════════════════════════════════════════════════ void _pulse(RxBool flag) { flag.value = true; Future.delayed(const Duration(milliseconds: 900), () => flag.value = false); } /// A "Teszt" egy jól elkülöníthető, a valódi 1-4-es járművekkel SOHA /// nem ütköző azonosítót kap (99) — így tesztelés közben is végigfut /// a teljes feltöltési lánc, csak a PC-szoftver oldalán egyértelműen /// megkülönböztethető a valódi terepi adatoktól. static const testVehicleNumber = 99; int? _vehicleNumberFrom(String? vehicleId) { if (vehicleId == null) return null; if (vehicleId == 'Teszt') return testVehicleNumber; final digits = vehicleId.replaceAll(RegExp(r'[^0-9]'), ''); return int.tryParse(digits); } void watchSourcePoints() { _sourcePointsSub?.cancel(); final projectId = ProjectService.to.activeProject.value?.uuid; if (projectId == null) { sourcePoints.clear(); geoSourcePoints.clear(); return; } isLoadingSourcePoints.value = true; _sourcePointsSub = MeasureSourcePointService.to.watchTodayOrUndated(projectId).listen( (result) { sourcePoints.value = result.points; geoSourcePoints.value = _toGeo(result.points); isLoadingSourcePoints.value = false; if (result.capped) { Get.snackbar( 'Sok forráspont', 'A megjelenített pontok száma korlátozva lett — érdemes ' 'kitölteni a tervezett dátumot a projektben.', snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 5), ); } }, onError: (e) { isLoadingSourcePoints.value = false; AppLogger.e('NavigationController', 'Forráspont-stream hiba: $e'); }, ); } void toggleReservePoints() { showReservePoints.value = !showReservePoints.value; } /// Átváltás pontonként EGYSZER. A nem véges eredményeket eldobjuk, /// mert a LatLng NaN összeomlasztja a kamerát. List _toGeo(List pts) { if (!Get.isRegistered()) return const []; final conv = CoordConverterService.to; final out = []; for (final p in pts) { final key = '${p.eovX}_${p.eovY}'; final ll = _geoCache.putIfAbsent(key, () { try { final w = conv.eovToWgsPoint(p.eovY, p.eovX); if (w.x.isFinite && w.y.isFinite) return LatLng(w.y, w.x); } catch (_) {} return null; }); if (ll != null) out.add(GeoSourcePoint(p, ll)); } return out; } List get visibleGeoSourcePoints => showReservePoints.value ? geoSourcePoints : geoSourcePoints.where((g) => g.point.isShotLocation).toList(); }