diff --git a/android/app/build.gradle b/android/app/build.gradle index 85153fd..4178c0e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,7 +38,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. minSdkVersion 26 - targetSdkVersion 35 + targetSdkVersion 36 versionCode flutter.versionCode versionName flutter.versionName diff --git a/lib/main.dart b/lib/main.dart index c5ee17c..e60f2ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/layer_import_service.dart'; import 'package:terepi_seged/services/layer_sync_service.dart'; +import 'package:terepi_seged/services/measure_source_point_service.dart'; import 'package:terepi_seged/services/measured_data_service.dart'; import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart'; @@ -119,6 +120,7 @@ Future main() async { Get.put(FieldPropertyService()); Get.put(ParcelGeometryService()); Get.put(MeasuredDataService()); + Get.put(MeasureSourcePointService()); runApp(const MyApp()); } diff --git a/lib/models/measure_source_point.dart b/lib/models/measure_source_point.dart new file mode 100644 index 0000000..4842275 --- /dev/null +++ b/lib/models/measure_source_point.dart @@ -0,0 +1,70 @@ +/// Egy jelgerjesztési/tartalék forráspont a `terepi_seged_measure_source_points` +/// táblából. Tisztán online — nincs helyi tükör. +class MeasureSourcePoint { + final String id; + final String projectId; + final double lineNumber; + final double pointNumber; + final int sequenceIndex; + final double eovX; + final double eovY; + final double? elevation; + final double? linkedReceiverLineNumber; + + /// 0 = tartalék pont, 1 = jelgerjesztés helye. + final int planState; + final DateTime? plannedAcquisitionDate; + final int? fieldSequence; + final String? sourceType; + final bool isActive; + + const MeasureSourcePoint({ + required this.id, + required this.projectId, + required this.lineNumber, + required this.pointNumber, + required this.sequenceIndex, + required this.eovX, + required this.eovY, + this.elevation, + this.linkedReceiverLineNumber, + required this.planState, + this.plannedAcquisitionDate, + this.fieldSequence, + this.sourceType, + required this.isActive, + }); + + bool get isReserve => planState == 0; + bool get isShotLocation => planState == 1; + + factory MeasureSourcePoint.fromMap(Map m) => + MeasureSourcePoint( + id: m['id'] as String, + projectId: m['project_id'] as String, + lineNumber: (m['line_number'] as num).toDouble(), + pointNumber: (m['point_number'] as num).toDouble(), + sequenceIndex: m['sequence_index'] as int, + + // FIGYELEM: a terepi_seged_measure_source_points táblában az + // eov_x/eov_y oszlopnevek FEL VANNAK CSERÉLVE a valódi magyar + // EOV-konvencióhoz képest (az "eov_x" oszlop tartalmazza a + // ténylegesen Y-nak/keletinek számító, ~640 000 körüli + // értéket, az "eov_y" a ténylegesen X-nek/északinak számítót, + // ~185 000 körülit) — itt kompenzáljuk, hogy a modell mezői + // (eovX/eovY) a kód többi részével KONZISZTENS, valódi EOV- + // jelentést hordozzanak. + eovX: (m['eov_y'] as num).toDouble(), + eovY: (m['eov_x'] as num).toDouble(), + elevation: (m['elevation'] as num?)?.toDouble(), + linkedReceiverLineNumber: + (m['linked_receiver_line_number'] as num?)?.toDouble(), + planState: m['plan_state'] as int, + plannedAcquisitionDate: m['planned_acquisition_date'] != null + ? DateTime.parse(m['planned_acquisition_date'] as String) + : null, + fieldSequence: m['field_sequence'] as int?, + sourceType: m['source_type'] as String?, + isActive: m['is_active'] as bool, + ); +} diff --git a/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart index a9b9f5b..81d032d 100644 --- a/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart +++ b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart @@ -1,119 +1,155 @@ 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:terepi_seged/models/source_point.dart'; -import 'package:terepi_seged/models/vechicle_position_log.dart'; -import 'package:terepi_seged/services/app_database.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/device_identity_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'; -/// Egyszerű navigációs panel a jelgerjesztő (vibrátor) járműhöz. +/// 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. /// -/// A pozíció a KITŰZÉSSEL AZONOS forrásból jön (GnssService — külső -/// BT/BLE GNSS-egység a járműben), semmilyen új eszköz-integráció nem -/// kell hozzá. A forráspontok tisztán referencia-réteg (nincs mutálható -/// "meglőve" állapotuk); az ellenőrzés a periodikus pozíciónapló és a -/// terv UTÓLAGOS összevetéséből adódik (lásd isVerified). +/// 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 { - AppDatabase get _db => AppDatabase.instance; + static VibroNavController get to => Get.find(); - // ── Forráspontok ───────────────────────────────────────────────── - final sourcePoints = [].obs; + static const _gpsTypeExternal = 1; + static const _gpsTypeInternal = 2; - /// Egyezés-tűrés (m) a naplózott pozíció és a terv-forráspont között. - final tolerance = 15.0.obs; + static const _trailMaxPoints = 300; + static const _trailMinDistM = 3.0; - // ── Élő pozíció (a GnssService-ből, EOV-ra konvertálva) ─────────── + final mapController = MapController(); + final mapReady = false.obs; final hasPosition = false.obs; - final curEovY = 0.0.obs; - final curEovX = 0.0.obs; - final speedKmh = 0.0.obs; - final sessionDistanceM = 0.0.obs; - - /// Haladási irány (fok) — pozíció-előzményből számolva, ALACSONY - /// SEBESSÉGNÉL BEFAGYASZTVA (a nyers GPS-heading álló/lassú helyzetben - /// zajos/értelmetlen — ugyanez a minta, mint a Kitűzésnél). final travelHeading = Rxn(); - static const _headingFreezeSpeedKmh = 5.0; + 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; - - // ── Legközelebbi forráspont ─────────────────────────────────────── - final nearestPoint = Rxn(); - final nearestDistance = 0.0.obs; - - // ── Periodikus napló ─────────────────────────────────────────────── - final isLogging = false.obs; - final logIntervalSec = 5.obs; // felhasználó által állítható - final logs = [].obs; - Timer? _logTimer; - - // ── Térkép — a MapController itt él, nem a View State-jében ──────── - final mapController = MapController(); - - StreamSubscription? _gnssUpdateSub; - - /// A kezdő térképközéppont lekérdezése (gyors, telefon-GPS-alapú) - /// befejeződött-e — a View ez alapján dönti el, mikor építse fel a - /// térképet, hogy ne induljon rossz (pl. budapesti) középponttal. - final mapReady = false.obs; + static const _headingFreezeSpeedKmh = 5.0; @override void onInit() { super.onInit(); - load(); - refreshLogs(); + + watchSourcePoints(); + ever(ProjectService.to.activeProject, (_) => watchSourcePoints()); + if (Get.isRegistered()) { - // FONTOS: a lastGgaLine csak NMEA (külső BT/BLE vevő) esetén - // frissül — telefon GPS-nél sosem. Az onDataUpdated viszont - // MINDKÉT forrásnál lefut, ez a helyes, egységes jelzés. _gnssUpdateSub = - GnssService.to.onDataUpdated.listen((_) => _onPosition()); + 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; } - // Automatikus indítás: amint van pozíció ÉS ki van választva jármű, - // induljon a rögzítés — korábban ez a bekötés hiányzott, a - // _tryAutoStart() metódust semmi nem hívta meg. - ever(hasPosition, (_) => _tryAutoStart()); - ever(VehicleIdentityService.to.selectedVehicle, (_) => _tryAutoStart()); + + // 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() { - _logTimer?.cancel(); _gnssUpdateSub?.cancel(); + _sourcePointsSub?.cancel(); + _externalUploadTimer?.cancel(); + _internalUploadTimer?.cancel(); + _internalPositionSub?.cancel(); + _internalGps.disconnect().whenComplete(_internalGps.dispose); super.onClose(); } - Future refreshLogs() async { - logs.value = await loadLogs(); + void resumeFollowing() { + followMode.value = true; } - Future load() async { - final projectId = ProjectService.to.activeProjectId; - if (projectId == null) { - sourcePoints.clear(); - return; - } - sourcePoints.value = await _db.listSourcePoints(projectId); + void toggleTrackUp() { + trackUpEnabled.value = !trackUpEnabled.value; } // ═════════════════════════════════════════════════════════════════ - // Pozíció-feldolgozás + // Külső (fő) pozíció // ═════════════════════════════════════════════════════════════════ - void _onPosition() { + void _onExternalPosition() { final gnss = GnssService.to; if (gnss.gpsQuality.value <= 0 || gnss.latitude.value == 0 || @@ -123,6 +159,7 @@ class VibroNavController extends GetxController { 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) { @@ -130,128 +167,194 @@ class VibroNavController extends GetxController { 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; // m/s - speedKmh.value = v * 3.6; - sessionDistanceM.value += dist; - - // Irány csak akkor frissül, ha a sebesség a fagyasztási küszöb - // felett van — álló/nagyon lassú helyzetben a nyers irány zajos. - if (speedKmh.value >= _headingFreezeSpeedKmh && dist > 0.3) { + 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; - curEovY.value = p.x; - curEovX.value = p.y; hasPosition.value = true; - - _updateNearest(); + _addTrailPoint(LatLng(gnss.latitude.value, gnss.longitude.value)); } - void _updateNearest() { - if (sourcePoints.isEmpty) { - nearestPoint.value = null; + 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; } - SourcePoint? best; - var bestDist = double.infinity; - for (final sp in sourcePoints) { - final dy = sp.planEovY - curEovY.value; - final dx = sp.planEovX - curEovX.value; - final d = math.sqrt(dy * dy + dx * dx); - if (d < bestDist) { - bestDist = d; - best = sp; - } - } - nearestPoint.value = best; - nearestDistance.value = bestDist; - } - - // ═════════════════════════════════════════════════════════════════ - // Terv/napló összevetés — melyik forráspont "igazolt" - // ═════════════════════════════════════════════════════════════════ - - /// Egyszerű, szinkron ellenőrzés a már betöltött [logs] lista alapján - /// (a nézet előre lekéri, hogy ne fusson adatbázis-lekérdezés minden - /// egyes marker kirajzolásakor). - bool isVerified(SourcePoint sp, List logs) { - for (final log in logs) { - final dy = sp.planEovY - log.eovY; - final dx = sp.planEovX - log.eovX; - if (math.sqrt(dy * dy + dx * dx) <= tolerance.value) return true; - } - return false; - } - - Future> loadLogs() async { - final projectId = ProjectService.to.activeProjectId; - if (projectId == null) return []; - return _db.listVehiclePositionLogs(projectId); - } - - // ═════════════════════════════════════════════════════════════════ - // Periodikus rögzítés - // ═════════════════════════════════════════════════════════════════ - - void startLogging() { - if (isLogging.value) return; - isLogging.value = true; - _logTick(); // azonnali első pont - _logTimer = Timer.periodic( - Duration(seconds: logIntervalSec.value), (_) => _logTick()); - } - - void stopLogging() { - isLogging.value = false; - _logTimer?.cancel(); - _logTimer = null; - } - - void _tryAutoStart() { - if (isLogging.value) return; - if (!hasPosition.value) return; - if (VehicleIdentityService.to.selectedVehicle.value == null) return; - startLogging(); - } - - Future _logTick() async { - if (!hasPosition.value) return; - final projectId = ProjectService.to.activeProjectId; - final vehicleId = VehicleIdentityService.to.selectedVehicle.value; - if (projectId == null || vehicleId == null) return; - - final gnss = GnssService.to; - final entry = VehiclePositionLog( - projectId: projectId, - vehicleId: vehicleId, - eovY: curEovY.value, - eovX: curEovX.value, - lat: gnss.latitude.value, - lon: gnss.longitude.value, - altitude: gnss.altitude.value, - speedKmh: speedKmh.value, - heading: travelHeading.value, - fixQuality: gnss.gpsQuality.value, - accuracy: gnss.horizontalAccuracy, - deviceId: Get.isRegistered() && - DeviceIdentityService.to.isReady - ? DeviceIdentityService.to.deviceId - : null, - appInstanceId: Get.isRegistered() && - DeviceIdentityService.to.isReady - ? DeviceIdentityService.to.appInstanceId - : null, + 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'); + }, ); - await _db.insertVehiclePositionLog(entry); - logs.add(entry); } + + 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(); } diff --git a/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart b/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart index b9a416f..4e192ca 100644 --- a/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart +++ b/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart @@ -1,493 +1,404 @@ -import 'dart:io'; +import 'dart:math' as math; -import 'package:file_picker/file_picker.dart'; 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/models/source_point.dart'; -import 'package:terepi_seged/models/vechicle_position_log.dart'; -import 'package:terepi_seged/services/app_database.dart'; -import 'package:terepi_seged/services/coord_converter_service.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/layer_import_service.dart'; -import 'package:terepi_seged/services/project_service.dart'; -import 'package:terepi_seged/services/sps_import_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'; -import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart'; -import '../controllers/vibro_nav_controller.dart'; - -/// Egyszerű navigációs oldal a vibrátor-járműhöz. ÖNÁLLÓ oldal, nem -/// MapSurveyMode — saját MapController kell a forgó (track-up) -/// térképhez, hogy ez semmilyen más módot ne érintsen. class VibroNavView extends GetView { const VibroNavView({super.key}); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Navigáció'), - actions: [ - IconButton( - icon: const Icon(Icons.bluetooth_connected), - tooltip: "Gyors újracsatlakozás a GNSS vevőhöz", - onPressed: _quickReconnectGnss, - ), - IconButton( - icon: const Icon(Icons.layers_outlined), - tooltip: 'Réteg importja (GeoJSON/KML/KMZ)', - onPressed: _importLayer, - ), - IconButton( - icon: const Icon(Icons.upload_file), - tooltip: 'Forráspontok importja (SPS S-fájl)', - onPressed: _importSourcePoints, - ), - IconButton( - icon: const Icon(Icons.local_shipping_outlined), - tooltip: 'Jármű kiválasztása', - onPressed: _pickVehicle, - ), - ], - ), - 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: [ - _buildMap(), - Builder(builder: (context) { - final target = controller.hasPosition.value && - Get.isRegistered() - ? LatLng(GnssService.to.latitude.value, - GnssService.to.longitude.value) - : null; - return AnimatedMapFollow( - mapController: controller.mapController, - target: target, - heading: controller.travelHeading.value, - ); - }), - Positioned( - left: 8, - right: 8, - top: 8, - child: Row( - children: [ - Expanded(child: _VehicleBadge()), - const SizedBox(width: 8), - const _GnssStatusBadge(), - ], - ), - ), - Positioned( - left: 8, - right: 8, - bottom: 8, - child: _NavPanel(controller: controller), - ), - ], - ); - })); - } - - Widget _buildMap() { - return Obx(() { - final points = controller.sourcePoints; - final nearest = controller.nearestPoint.value; - - final gnssPos = - Get.isRegistered() && GnssService.to.latitude.value != 0 - ? LatLng( - GnssService.to.latitude.value, GnssService.to.longitude.value) - : null; - final center = gnssPos ?? const LatLng(47.5, 19.05); - - return FlutterMap( - mapController: controller.mapController, - options: MapOptions( - initialCenter: center, - initialZoom: 15, - ), - children: [ - // TileLayer( - // urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - // userAgentPackageName: 'hu.app_dev.terepi_seged', - // ), - TileLayer( - urlTemplate: 'http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', - subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'], - maxNativeZoom: 18, + // 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(), ), - - // Tervezett útvonal + minden más importált háttérréteg — a - // MEGLÉVŐ rétegimport-mechanizmus, nincs hozzá új kód. - const ImportedLayerOverlay(), - MarkerLayer(markers: [ - for (final sp in points) - Marker( - point: LatLng(sp.planLat, sp.planLon), - width: 40, - height: 40, - child: _SourcePointMarker( - point: sp, - isNearest: sp.uuid == nearest?.uuid, - isVerified: controller.isVerified(sp, controller.logs), - ), - ), - if (controller.hasPosition.value) - Marker( - point: LatLng(GnssService.to.latitude.value, - GnssService.to.longitude.value), - width: 34, - height: 34, - child: const _VehicleMarker(), - ), - ]), - ], - ); - }); - } - - // ── Jármű-választás ──────────────────────────────────────────────── - - Future _pickVehicle() async { - final chosen = await Get.dialog(AlertDialog( - title: const Text('Melyik járműben van ez a tablet?'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final v in VehicleIdentityService.availableVehicles) - RadioListTile( - title: Text(v), - value: v, - groupValue: VehicleIdentityService.to.selectedVehicle.value, - onChanged: (val) => Get.back(result: val), - ), ], ), - )); - if (chosen != null) { - await VehicleIdentityService.to.setVehicle(chosen); - } + 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), + ), + )), + ], + ); + }), + ); } - Future _quickReconnectGnss() async { + 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 Kitűzés/Mérés beállításaiban.', - snackPosition: SnackPosition.BOTTOM, - ); + 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}" (${device.typeLabel}) — folyamatban…', - snackPosition: SnackPosition.BOTTOM, - duration: const Duration(seconds: 2), - ); - await GnssService.to.reconnect(); - } - - Future _importLayer() async { - try { - final layer = await LayerImportService.to.importFile(); - if (layer != null) { - Get.snackbar('Réteg importálva', layer.name, - snackPosition: SnackPosition.BOTTOM); - } - } catch (e) { - Get.snackbar('Import hiba', e.toString(), - snackPosition: SnackPosition.BOTTOM); - } - } - - // ── SPS S-fájl (forráspont) import — kompakt, egyetlen dialógus ──── - - Future _importSourcePoints() async { - final result = await FilePicker.platform.pickFiles(type: FileType.any); - final path = result?.files.single.path; - if (path == null) return; - - final projectId = ProjectService.to.activeProjectId; - if (projectId == null) return; - - try { - final content = await File(path).readAsString(); - final points = SpsParser.parseSourceFile(content); - if (points.isEmpty) { - Get.snackbar( - 'Import', - 'Nem sikerült forráspontot beolvasni ebből a fájlból — ' - 'ellenőrizd, hogy valódi SPS S-fájl-e.', - snackPosition: SnackPosition.BOTTOM); - return; - } - - final withPos = points.where((p) => p.eovY != null && p.eovX != null); - final ok = await Get.dialog(AlertDialog( - title: const Text('Forráspontok importja'), - content: Text('${points.length} pont az S-fájlban, ' - '${withPos.length} érvényes koordinátával.\n\n' - 'Első néhány: ${points.take(3).map((p) => '${p.lineId}·${p.station}').join(', ')}…'), - actions: [ - TextButton( - onPressed: () => Get.back(result: false), - child: const Text('Mégse')), - FilledButton( - onPressed: () => Get.back(result: true), - child: const Text('Import')), - ], - )); - if (ok != true) return; - - final conv = CoordConverterService.to; - final batch = DateTime.now().toIso8601String(); - final saved = []; - for (final p in withPos) { - final w = conv.eovToWgsPoint(p.eovY!, p.eovX!); - saved.add(SourcePoint( - projectId: projectId, - lineId: p.lineId, - station: p.station, - planEovY: p.eovY!, - planEovX: p.eovX!, - planLat: w.y, - planLon: w.x, - importBatch: batch, - )); - } - final inserted = await AppDatabase.instance.insertSourcePoints(saved); - await controller.load(); - await controller.refreshLogs(); - Get.snackbar('Import kész', '$inserted forráspont importálva', - snackPosition: SnackPosition.BOTTOM); - } catch (e) { - Get.snackbar('Import hiba', e.toString(), - snackPosition: SnackPosition.BOTTOM); - } + Get.snackbar('Újracsatlakozás', '„${device.name}" — folyamatban…', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2)); + GnssService.to.reconnect(); } } -// ═══════════════════════════════════════════════════════════════════ -// HUD panel: sebesség, táv, legközelebbi pont, rögzítés vezérlés -// ═══════════════════════════════════════════════════════════════════ +Color _qualityColor(int quality) => + quality >= 4 ? Colors.green : (quality > 0 ? Colors.orange : Colors.grey); -class _NavPanel extends StatelessWidget { - final VibroNavController controller; - const _NavPanel({required this.controller}); +/// 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) { - return Obx(() { - final nearest = controller.nearestPoint.value; - return Card( - elevation: 6, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _Stat( - label: 'Sebesség', - value: - '${controller.speedKmh.value.toStringAsFixed(0)} km/h'), - _Stat( - label: 'Megtett táv', - value: _fmtDist(controller.sessionDistanceM.value)), - if (nearest != null) - _Stat( - label: 'Legközelebbi VP', - value: nearest.displayId, - highlight: true), - if (nearest != null) - _Stat( - label: 'Táv odáig', - value: _fmtDist(controller.nearestDistance.value)), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: controller.isLogging.value - ? OutlinedButton.icon( - icon: const Icon(Icons.stop_circle_outlined), - label: Text('Rögzítés leállítása ' - '(${controller.logIntervalSec.value} mp)'), - onPressed: () { - controller.stopLogging(); - }, - ) - : FilledButton.icon( - icon: const Icon(Icons.fiber_manual_record, - color: Colors.red), - label: Text('Rögzítés indítása ' - '(${controller.logIntervalSec.value} mp-enként)'), - onPressed: () { - controller.startLogging(); - }, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.timer_outlined), - tooltip: 'Rögzítési időköz', - onPressed: controller.isLogging.value - ? null - : () => _pickInterval(controller), - ), - ], - ), - ], - ), - ), - ); - }); - } + 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); - Future _pickInterval(VibroNavController c) async { - final opts = [15, 30, 60, 120, 300]; - final chosen = await Get.dialog(AlertDialog( - title: const Text('Rögzítési időköz'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final s in opts) - RadioListTile( - title: Text(s < 60 ? '$s másodperc' : '${s ~/ 60} perc'), - value: s, - groupValue: c.logIntervalSec.value, - onChanged: (v) => Get.back(result: v), + 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), + ), ), ], - ), - )); - if (chosen != null) c.logIntervalSec.value = chosen; + ); + }); } - - static String _fmtDist(double m) => m < 1000 - ? '${m.toStringAsFixed(0)} m' - : '${(m / 1000).toStringAsFixed(2)} km'; } -class _Stat extends StatelessWidget { - final String label; - final String value; - final bool highlight; - const _Stat( - {required this.label, required this.value, this.highlight = false}); +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 Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(value, - style: TextStyle( - fontSize: highlight ? 16 : 18, - fontWeight: FontWeight.w700, - color: - highlight ? Theme.of(context).colorScheme.primary : null)), - Text(label, - style: TextStyle(fontSize: 10, color: Colors.grey.shade600)), - ], + 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(); -class _VehicleBadge extends StatelessWidget { @override Widget build(BuildContext context) { + final controller = VibroNavController.to; return Obx(() { - final v = VehicleIdentityService.to.selectedVehicle.value; - return Align( - alignment: Alignment.topLeft, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: v == null - ? Colors.orange.withOpacity(0.9) - : Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - v == null ? 'Nincs jármű kiválasztva!' : 'Jármű: $v', - style: const TextStyle(color: Colors.white, fontSize: 12), - ), + 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 _SourcePointMarker extends StatelessWidget { - final SourcePoint point; - final bool isNearest; - final bool isVerified; - const _SourcePointMarker( - {required this.point, required this.isNearest, required this.isVerified}); +class _VehicleBadge extends StatelessWidget { + const _VehicleBadge(); @override Widget build(BuildContext context) { - final color = isVerified ? Colors.green : Colors.grey.shade600; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isVerified ? Icons.check_circle : Icons.radio_button_unchecked, - color: isNearest ? Colors.red : color, - size: isNearest ? 26 : 18, + 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)), + ), + ], + ), + ), ), - Text(point.displayId, - style: TextStyle( - fontSize: 9, - fontWeight: isNearest ? FontWeight.w700 : FontWeight.w400, - color: isNearest ? Colors.red : Colors.black87)), - ], - ); + ); + }); } -} -class _VehicleMarker extends StatelessWidget { - const _VehicleMarker(); - - @override - Widget build(BuildContext context) { - // Track-up módban a szimbólum mindig "felfelé" mutat — a világ - // forog körülötte, nem a szimbólum a világ körül. - return Container( - decoration: BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), - boxShadow: [ - BoxShadow(color: Colors.blue.withOpacity(0.5), blurRadius: 8) - ], + 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(); + }, + ), + ], + ), ), - child: const Icon(Icons.navigation, color: Colors.white, size: 18), ); } } @@ -527,23 +438,213 @@ class _GnssStatusBadge extends StatelessWidget { } return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color, width: 1), + 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: 14, color: color), + Icon(icon, size: 16, color: color), const SizedBox(width: 4), Text(label, style: TextStyle( - fontSize: 11, fontWeight: FontWeight.w600, color: color)), + 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, + ), + ), + ], + ); + } +} diff --git a/lib/services/gnss/phone_gps_connection.dart b/lib/services/gnss/phone_gps_connection.dart index 16d08d8..35301ef 100644 --- a/lib/services/gnss/phone_gps_connection.dart +++ b/lib/services/gnss/phone_gps_connection.dart @@ -85,7 +85,9 @@ class PhoneGpsConnection implements GnssConnection { Future disconnect() async { _retryTimer?.cancel(); await _positionSub?.cancel(); - _stateController.add(GnssConnectionState.disconnected); + if (!_stateController.isClosed) { + _stateController.add(GnssConnectionState.disconnected); + } } @override diff --git a/lib/services/measure_source_point_service.dart b/lib/services/measure_source_point_service.dart new file mode 100644 index 0000000..42abdf3 --- /dev/null +++ b/lib/services/measure_source_point_service.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/measure_source_point.dart'; + +class SourcePointFetchResult { + final List points; + final bool capped; + const SourcePointFetchResult({required this.points, required this.capped}); +} + +/// Forráspontok (jelgerjesztés/tartalék) lekérdezése a navigációs +/// térképhez. SZÁNDÉKOSAN tisztán online — nincs helyi tükör. +class MeasureSourcePointService extends GetxService { + static MeasureSourcePointService get to => Get.find(); + + SupabaseClient get _client => Supabase.instance.client; + + /// Ha egy pontnak VAN megadott tervezett dátuma, csak a mait mutatjuk; + /// aminek NINCS dátuma, azt mindig — plusz egy kemény felső korlát, + /// nehogy egy dátum nélküli projektnél ezerszámra terhelje a térképet. + static const _safetyLimit = 6000; + + /// Élő frissítés a stream() helyett: a tényleges adatot SZERVER-oldali + /// szűréssel, lapozva kérjük le (a PostgREST 1000 soros felső + /// korlátja miatt a .stream() csonkított, rendezetlen részhalmazt + /// adott), a realtime csatorna pedig csak jelez, hogy változott + /// valami — ilyenkor újra lekérünk. + Stream watchTodayOrUndated(String projectId) { + late StreamController controller; + RealtimeChannel? channel; + Timer? debounce; + var disposed = false; + + Future refetch() async { + if (disposed) return; + try { + final result = await _fetchAll(projectId); + if (!disposed) controller.add(result); + } catch (e, s) { + if (!disposed) controller.addError(e, s); + } + } + + controller = StreamController( + onListen: () { + refetch(); + channel = _client + .channel('source_points_$projectId') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'terepi_seged_measure_source_points', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'project_id', + value: projectId, + ), + callback: (_) { + // Sok egyidejű változásnál (pl. tömeges import) ne + // kérjünk le minden egyes sorra újra. + debounce?.cancel(); + debounce = Timer(const Duration(milliseconds: 600), refetch); + }, + ) + .subscribe(); + }, + onCancel: () async { + disposed = true; + debounce?.cancel(); + if (channel != null) await _client.removeChannel(channel!); + }, + ); + return controller.stream; + } + + Future _fetchAll(String projectId) async { + final now = DateTime.now(); + final todayStr = '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + + const pageSize = 1000; + final all = []; + var from = 0; + while (all.length <= _safetyLimit) { + final rows = await _client + .from('terepi_seged_measure_source_points') + .select() + .eq('project_id', projectId) + .eq('is_active', true) + .or('planned_acquisition_date.eq.$todayStr,' + 'planned_acquisition_date.is.null') + .order('line_number', ascending: true) + .order('point_number', ascending: true) + .range(from, from + pageSize - 1); + all.addAll(rows.map((r) => MeasureSourcePoint.fromMap(r))); + if (rows.length < pageSize) break; + from += pageSize; + } + + final capped = all.length > _safetyLimit; + if (capped) all.removeRange(_safetyLimit, all.length); + return SourcePointFetchResult(points: all, capped: capped); + } +} diff --git a/lib/services/measured_data_service.dart b/lib/services/measured_data_service.dart index 53e8e10..53d2cec 100644 --- a/lib/services/measured_data_service.dart +++ b/lib/services/measured_data_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:get/get.dart'; @@ -17,16 +18,92 @@ class MeasuredDataService extends GetxService { /// TODO: erősítsd meg a valós bucket-nevet, ha eltér ettől. static const _bucket = 'terepi_seged-measure-images'; - /// Élő, folyamatosan frissülő stream — a Supabase Realtime-on keresztül, - /// pontosan úgy, ahogy korábban a Firestore .snapshots() működött. Új - /// felvétel a PC-szoftverből AZONNAL megjelenik, frissítés nélkül. + static const _pageSize = 1000; + + /// A legfrissebb ennyi felvételt töltjük be (a PostgREST 1000-es + /// max-rows miatt lapozva). + static const _maxRecords = 3000; + + /// SZÁNDÉKOSAN nem `.stream()`: az rendezetlen, 1000 sorra vágott + /// lekérdezést futtat, ezért sorok tűnnek el. Helyette szerveroldalon + /// szűrt, lapozott lekérdezés + Realtime csatorna, ami csak jelzés + /// (debounce-olt újratöltés). Stream> watchRecords(String projectId) { - return _client - .from('terepi_seged_measure_records') - .stream(primaryKey: ['id']) - .eq('project_id', projectId) - .order('measured_at', ascending: false) - .map((rows) => rows.map((r) => MeasuredRecord.fromMap(r)).toList()); + late StreamController> controller; + RealtimeChannel? channel; + Timer? debounce; + var disposed = false; + var fetching = false; + var again = false; + + Future refetch() async { + if (disposed) return; + if (fetching) { + again = true; + return; + } + fetching = true; + try { + do { + again = false; + final list = await _fetchAll(projectId); + if (!disposed) controller.add(list); + } while (again && !disposed); + } catch (e, s) { + if (!disposed) controller.addError(e, s); + } finally { + fetching = false; + } + } + + controller = StreamController>( + onListen: () { + refetch(); + channel = _client + .channel('measure_records_$projectId') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'terepi_seged_measure_records', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'project_id', + value: projectId, + ), + callback: (_) { + debounce?.cancel(); + debounce = Timer(const Duration(milliseconds: 800), refetch); + }, + ) + .subscribe(); + }, + onCancel: () async { + disposed = true; + debounce?.cancel(); + if (channel != null) await _client.removeChannel(channel!); + }, + ); + return controller.stream; + } + + Future> _fetchAll(String projectId) async { + final out = []; + var from = 0; + while (out.length < _maxRecords) { + final rows = await _client + .from('terepi_seged_measure_records') + .select() + .eq('project_id', projectId) + .order('measured_at', ascending: false) + .order('id', ascending: false) + .range(from, from + _pageSize - 1); + for (final r in rows) { + out.add(MeasuredRecord.fromMap(r)); + } + if (rows.length < _pageSize) break; + from += _pageSize; + } + return out; } /// A felvételhez tartozó kép betöltése — elsőbbséget adva az image_url-nek diff --git a/lib/services/vechicle_identity_service.dart b/lib/services/vechicle_identity_service.dart index adc50ee..478b8a5 100644 --- a/lib/services/vechicle_identity_service.dart +++ b/lib/services/vechicle_identity_service.dart @@ -10,7 +10,7 @@ class VehicleIdentityService extends GetxService { static const _key = 'vehicle_id'; /// Az alapértelmezett választható lista — igény szerint bővíthető. - static const availableVehicles = ['V1', 'V2', 'V3']; + static const availableVehicles = ['V1', 'V2', 'V3', 'V4', 'Teszt']; final selectedVehicle = Rxn(); diff --git a/lib/widgets/map/animated_map_follow.dart b/lib/widgets/map/animated_map_follow.dart index 0947065..7a931be 100644 --- a/lib/widgets/map/animated_map_follow.dart +++ b/lib/widgets/map/animated_map_follow.dart @@ -42,9 +42,18 @@ class AnimatedMapFollow extends StatelessWidget { duration: duration, curve: Curves.easeOut, builder: (context, animatedPos, _) { - try { - mapController.move(animatedPos, mapController.camera.zoom); - } catch (_) {} + // NE hívjuk a .move()-ot közvetlenül itt — ez a builder BUILD + // közben fut, a mapController.move() pedig a flutter_map + // belső gesztus-kezelő widgetjén (MapInteractiveViewer) vált + // ki setState()-et, ami "setState() called during build" + // hibát ad. A jelenlegi képkocka VÉGÉRE halasztjuk, ahol ez + // már biztonságos — ugyanaz az elv, mint az + // onPositionChanged-nél. + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + mapController.move(animatedPos, mapController.camera.zoom); + } catch (_) {} + }); return const SizedBox.shrink(); }, ), @@ -55,9 +64,11 @@ class AnimatedMapFollow extends StatelessWidget { duration: duration, curve: Curves.easeOut, builder: (context, animatedRotation, _) { - try { - mapController.rotate(animatedRotation); - } catch (_) {} + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + mapController.rotate(animatedRotation); + } catch (_) {} + }); return const SizedBox.shrink(); }, ),