import 'dart:async'; import 'dart:math' as math; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:terepi_seged/controls/geoid_grid.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/project_service.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../models/stakeout_point.dart'; /// Kitűzési sorrend. enum StakeoutOrder { idAsc, idDesc, nearest } /// Eltérés-kijelzési mód a közeli fázisban. enum DeviationMode { line('Inline / crossline'), travel('Haladási irány'), northEast('Észak / kelet'); final String label; const DeviationMode(this.label); } /// A kitűzés "agya": célpont-kezelés, sorrend, vonal-geometria, /// eltérés-számítás, tárolás, haptikus visszajelzés. /// /// Az egyetlen térképnézetbe illeszkedik: a MapSurveyController a /// setMode()-ban hívja a [setActive]-ot (mode == MapSurveyMode.stakeout), /// a service pedig csak aktív állapotban dolgozik (haptika, fázis). /// Minden adatbázisművelet az AppDatabase-en keresztül megy. /// /// Geometria: minden számítás EOV-SÍKBAN, méterben. A crossline irány a /// szomszédos állomások szakaszaiból SZÁMÍTÓDIK — a vonal első pontjánál /// az első szakasz hátrafelé, az utolsónál az utolsó szakasz előre /// extrapolálásával, belső töréspontnál a két irány átlagával; virtuális /// segédpontot nem tárolunk. class StakeoutService extends GetxService { static StakeoutService get to => Get.find(); AppDatabase get _db => AppDatabase.instance; // ── Mód-aktiválás ──────────────────────────────────────────────── final active = false.obs; /// A MapSurveyController hívja módváltáskor. Future setActive(bool value) async { if (active.value == value) return; active.value = value; if (value) { WakelockPlus.enable(); await load(); _applyPhase(); _scheduleHaptic(); } else { _hapticTimer?.cancel(); WakelockPlus.disable(); } } // ── Állapot ────────────────────────────────────────────────────── final points = [].obs; final target = Rxn(); final orderMode = StakeoutOrder.idAsc.obs; final deviationMode = DeviationMode.line.obs; /// Tűrés (m) — tárolásnál és a céltábla belső körénél. final toleranceXY = 0.03.obs; /// Közeli fázis (céltábla-nézet); automatikus 5 m alatt, kézzel /// felülbírálható. final nearPhase = false.obs; static const nearPhaseDistance = 5.0; bool? _manualPhase; final hapticsEnabled = true.obs; final soundEnabled = true.obs; /// Átlagolás tároláskor final averagingSeconds = 0.obs; final isAveraging = false.obs; final averagingElapsed = 0.obs; /// Max. megengedett crossline eltolás (m; 0=nincs korlát) final maxOffset = 0.0.obs; late GeoidGrid geoidGrid; // ── Aktuális pozíció (EOV) és navigációs értékek ──────────────── final hasPosition = false.obs; final curEovY = 0.0.obs; final curEovX = 0.0.obs; final curAlt = 0.0.obs; final distance = 0.0.obs; final bearingToTarget = 0.0.obs; // fok, EOV-észak = 0, óramutató final travelHeading = Rxn(); // null = állunk /// Eltérés az aktuális [deviationMode] szerint: előre(+)/hátra(−), /// jobbra(+)/balra(−); dz: fel(+)/le(−). final devForward = 0.0.obs; final devRight = 0.0.obs; final devDz = Rxn(); final withinTolerance = false.obs; double? _histY, _histX; Timer? _hapticTimer; bool _toleranceAnnounced = false; @override void onInit() async { super.onInit(); await _loadSettings(); geoidGrid = await GeoidGrid.load('assets/Grids/geoid_eht2014.gtx'); if (Get.isRegistered()) { ever(GnssService.to.lastGgaLine, (_) => _onPosition()); } } @override void onClose() { _hapticTimer?.cancel(); super.onClose(); } Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); toleranceXY.value = prefs.getDouble('stakeout_tolerance') ?? 0.03; averagingSeconds.value = prefs.getInt('stakeout_avg_seconds') ?? 0; hapticsEnabled.value = prefs.getBool('stakeout_haptics') ?? true; soundEnabled.value = prefs.getBool('stakeout_sound') ?? true; maxOffset.value = prefs.getDouble('stakeout_max_offset') ?? 0; } Future saveSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble('stakeout_tolerance', toleranceXY.value); await prefs.setInt('stakeout_avg_seconds', averagingSeconds.value); await prefs.setBool('stakeout_haptics', hapticsEnabled.value); await prefs.setBool('stakeout_sound', soundEnabled.value); await prefs.setDouble('stakeout_max_offset', maxOffset.value); } // ═════════════════════════════════════════════════════════════════ // Betöltés / cél-kezelés // ═════════════════════════════════════════════════════════════════ Future load() async { final projectId = ProjectService.to.activeProjectId; if (projectId == null) { points.clear(); target.value = null; return; } points.value = await _db.listStakeoutPoints(projectId); if (target.value == null || !points.any((p) => p.uuid == target.value!.uuid)) { target.value = _firstPending(); } _recompute(); } List get lines => points.map((p) => p.lineId).toSet().toList()..sort(); Map get lineProgress { final m = {}; for (final p in points) { final cur = m[p.lineId] ?? (total: 0, staked: 0); m[p.lineId] = ( total: cur.total + 1, staked: cur.staked + (p.status == StakeoutStatus.staked ? 1 : 0), ); } return m; } void setTarget(StakeoutPoint p) { target.value = p; _manualPhase = null; // új célnál vissza automatikus fázisra _toleranceAnnounced = false; _recompute(); } void togglePhase() { _manualPhase = !nearPhase.value; _applyPhase(); } StakeoutPoint? _firstPending() { final pending = points.where((p) => p.status == StakeoutStatus.pending).toList(); if (pending.isEmpty) return null; pending.sort((a, b) => a.lineId != b.lineId ? a.lineId.compareTo(b.lineId) : a.station.compareTo(b.station)); return orderMode.value == StakeoutOrder.idDesc ? pending.last : pending.first; } /// Következő cél a sorrend-mód szerint — a CÉL VONALÁN belül lép, /// elfogyva a következő vonalra. StakeoutPoint? nextTarget({bool backwards = false}) { final cur = target.value; if (cur == null) return _firstPending(); final pending = points .where((p) => p.status == StakeoutStatus.pending && p.uuid != cur.uuid) .toList(); if (pending.isEmpty) return null; final sameLine = pending.where((p) => p.lineId == cur.lineId).toList() ..sort((a, b) => a.station.compareTo(b.station)); switch (orderMode.value) { case StakeoutOrder.nearest: final pool = sameLine.isNotEmpty ? sameLine : pending; pool.sort((a, b) => _distTo(a).compareTo(_distTo(b))); return pool.first; case StakeoutOrder.idAsc: case StakeoutOrder.idDesc: final asc = (orderMode.value == StakeoutOrder.idAsc) != backwards; final candidates = sameLine.where( (p) => asc ? p.station > cur.station : p.station < cur.station); if (candidates.isNotEmpty) { return asc ? candidates.reduce((a, b) => a.station < b.station ? a : b) : candidates.reduce((a, b) => a.station > b.station ? a : b); } final others = pending.where((p) => p.lineId != cur.lineId).toList(); if (others.isEmpty) return null; others.sort((a, b) => a.lineId != b.lineId ? a.lineId.compareTo(b.lineId) : a.station.compareTo(b.station)); return asc ? others.first : others.last; } } void advance({bool backwards = false}) { final n = nextTarget(backwards: backwards); if (n != null) setTarget(n); } double _distTo(StakeoutPoint p) { final dy = p.planEovY - curEovY.value; final dx = p.planEovX - curEovX.value; return math.sqrt(dy * dy + dx * dx); } // ═════════════════════════════════════════════════════════════════ // Pozíció + eltérés // ═════════════════════════════════════════════════════════════════ void _onPosition() { final gnss = GnssService.to; if (gnss.gpsQuality.value <= 0 || gnss.latitude.value == 0 || !Get.isRegistered()) { return; } final lat = gnss.latitude.value; final lon = gnss.longitude.value; final alt = gnss.altitude.value; final sep = gnss.geoidSeparation.value; final p = CoordConverterService.to.wgsToEovPoint(lon, lat); curEovY.value = p.x; curEovX.value = p.y; curAlt.value = geoidGrid.toEovHeight(lat, lon, alt, sep)!; //curAlt.value = gnss.altitude.value; hasPosition.value = true; // Haladási irány: legalább 0,5 m elmozdulásból (állva zajos lenne). if (_histY != null) { final dy = curEovY.value - _histY!; final dx = curEovX.value - _histX!; if (math.sqrt(dy * dy + dx * dx) >= 0.5) { travelHeading.value = _bearingDeg(dy, dx); _histY = curEovY.value; _histX = curEovX.value; } } else { _histY = curEovY.value; _histX = curEovX.value; } _recompute(); } void _recompute() { final t = target.value; if (t == null || !hasPosition.value) { withinTolerance.value = false; return; } final dy = t.planEovY - curEovY.value; final dx = t.planEovX - curEovX.value; distance.value = math.sqrt(dy * dy + dx * dx); bearingToTarget.value = _bearingDeg(dy, dx); final double fwdBearing; switch (deviationMode.value) { case DeviationMode.line: fwdBearing = lineBearingAt(t) ?? travelHeading.value ?? 0; case DeviationMode.travel: fwdBearing = travelHeading.value ?? 0; case DeviationMode.northEast: fwdBearing = 0; } final rad = fwdBearing * math.pi / 180; devForward.value = dy * math.sin(rad) + dx * math.cos(rad); devRight.value = dy * math.cos(rad) - dx * math.sin(rad); devDz.value = t.planEovZ != null ? t.planEovZ! - curAlt.value : null; final wasWithin = withinTolerance.value; withinTolerance.value = distance.value <= toleranceXY.value; if (active.value) { _applyPhase(); if (withinTolerance.value && !wasWithin && !_toleranceAnnounced) { _toleranceAnnounced = true; if (hapticsEnabled.value) HapticFeedback.heavyImpact(); if (soundEnabled.value) SystemSound.play(SystemSoundType.alert); } else if (!withinTolerance.value) { _toleranceAnnounced = false; } _scheduleHaptic(); } } void _applyPhase() { nearPhase.value = _manualPhase ?? (hasPosition.value && target.value != null && distance.value <= nearPhaseDistance); } /// Haptikus "geiger": közeledve sűrűsödő pulzus, tűrésen belül gyors. void _scheduleHaptic() { _hapticTimer?.cancel(); if (!active.value || !hapticsEnabled.value || !hasPosition.value || target.value == null) { return; } final d = distance.value; if (d > 30) return; final ms = withinTolerance.value ? 150 : d <= 1 ? 250 : d <= 3 ? 450 : d <= 10 ? 800 : 1500; _hapticTimer = Timer(Duration(milliseconds: ms), () { if (withinTolerance.value) { HapticFeedback.mediumImpact(); } else { HapticFeedback.lightImpact(); } if (soundEnabled.value) SystemSound.play(SystemSoundType.click); _scheduleHaptic(); }); } static double _bearingDeg(double dy, double dx) { final b = math.atan2(dy, dx) * 180 / math.pi; return (b + 360) % 360; } // ── Vonal-geometria ────────────────────────────────────────────── List _linePoints(String lineId) { final lp = points.where((p) => p.lineId == lineId && !p.isOffset).toList() ..sort((a, b) => a.station.compareTo(b.station)); return lp; } /// A vonal iránya (fok) az adott pontnál, a station-növekedés felé. double? lineBearingAt(StakeoutPoint p) { final lp = _linePoints(p.lineId); if (lp.length < 2) return null; var idx = lp.indexWhere((e) => e.uuid == p.uuid); if (idx < 0) { // Eltolt pont: a legközelebbi vonalpont szerint. var bestD = double.infinity; for (var k = 0; k < lp.length; k++) { final d = math.pow(lp[k].planEovY - p.planEovY, 2) + math.pow(lp[k].planEovX - p.planEovX, 2); if (d < bestD) { bestD = d.toDouble(); idx = k; } } } double segBearing(StakeoutPoint a, StakeoutPoint b) => _bearingDeg(b.planEovY - a.planEovY, b.planEovX - a.planEovX); if (idx == 0) return segBearing(lp[0], lp[1]); if (idx == lp.length - 1) return segBearing(lp[idx - 1], lp[idx]); final b1 = segBearing(lp[idx - 1], lp[idx]) * math.pi / 180; final b2 = segBearing(lp[idx], lp[idx + 1]) * math.pi / 180; return _bearingDeg( math.sin(b1) + math.sin(b2), math.cos(b1) + math.cos(b2)); } // ═════════════════════════════════════════════════════════════════ // Műveletek (adatbázis: AppDatabase) // ═════════════════════════════════════════════════════════════════ /// Tárolás a jelenlegi (vagy átlagolt) mért pozícióval. Ha az /// [averagingSeconds] > 0, a megadott ideig gyűjti és átlagolja a /// pozíciókat — csökkenti a pillanatnyi zaj hatását. A rekordba az /// eltérés mindig vonal-relatívan (inline/crossline) kerül; vonal /// híján É/K bontásban. Future storeCurrent() async { final t = target.value; if (t == null || !hasPosition.value || isAveraging.value) return null; final gnss = GnssService.to; double useY = curEovY.value; double useX = curEovX.value; double useAlt = curAlt.value; if (averagingSeconds.value > 0) { final avg = await _collectAverage(averagingSeconds.value); useY = avg.y; useX = avg.x; useAlt = avg.alt; } final dy = t.planEovY - useY; final dx = t.planEovX - useX; final lineBearing = lineBearingAt(t); final rad = (lineBearing ?? 0) * math.pi / 180; final inline = lineBearing != null ? dy * math.sin(rad) + dx * math.cos(rad) : dx; final crossline = lineBearing != null ? dy * math.cos(rad) - dx * math.sin(rad) : dy; final w = CoordConverterService.to.eovToWgsPoint(useY, useX); final updated = t.copyWith( status: StakeoutStatus.staked, measuredEovY: useY, measuredEovX: useX, measuredEovZ: useAlt, measuredLat: w.y, measuredLon: w.x, devInline: inline, devCrossline: crossline, devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null, fixQuality: gnss.gpsQuality.value, accuracy: gnss.horizontalAccuracy, stakedAt: DateTime.now(), ); await _db.updateStakeoutPoint(updated); final i = points.indexWhere((p) => p.uuid == t.uuid); if (i >= 0) points[i] = updated; points.refresh(); return updated; } /// Pozíció-átlagolás: [seconds] másodpercig minden GGA-pozíciót /// gyűjt, majd átlagol. Ha nem jött minta, a pillanatnyi értéket adja. Future<({double y, double x, double alt})> _collectAverage( int seconds) async { isAveraging.value = true; averagingElapsed.value = 0; final ys = [], xs = [], alts = []; final worker = ever(curEovY, (_) { ys.add(curEovY.value); xs.add(curEovX.value); alts.add(curAlt.value); }); final ticker = Timer.periodic( const Duration(seconds: 1), (_) => averagingElapsed.value++); try { await Future.delayed(Duration(seconds: seconds)); } finally { worker.dispose(); ticker.cancel(); isAveraging.value = false; } if (ys.isEmpty) { return (y: curEovY.value, x: curEovX.value, alt: curAlt.value); } double mean(List l) => l.reduce((a, b) => a + b) / l.length; return (y: mean(ys), x: mean(xs), alt: mean(alts)); } Future skipCurrent() async { final t = target.value; if (t == null) return; final updated = t.copyWith(status: StakeoutStatus.skipped); await _db.updateStakeoutPoint(updated); final i = points.indexWhere((p) => p.uuid == t.uuid); if (i >= 0) points[i] = updated; points.refresh(); advance(); } /// Státusz-állítás a listából (kihagyás / visszaállítás). Future setPointStatus(StakeoutPoint p, StakeoutStatus status) async { final updated = p.copyWith(status: status); await _db.updateStakeoutPoint(updated); final i = points.indexWhere((e) => e.uuid == p.uuid); if (i >= 0) points[i] = updated; points.refresh(); if (target.value?.uuid == p.uuid && status != StakeoutStatus.pending) { advance(); } } /// Pont törlése (soft delete — a szinkron viszi a többi eszközre is). Future deletePoint(StakeoutPoint p) async { await _db.softDeleteStakeoutPoint(p.id!); points.removeWhere((e) => e.uuid == p.uuid); if (target.value?.uuid == p.uuid) { target.value = _firstPending(); _recompute(); } } /// Transzverzális eltolt pont: a vonalra merőlegesen [dist] méterre /// ([toRight] = jobbra a station-növekedés irányából nézve). Az új /// pont lesz a cél; az eltolás-vektor a rekordba kerül. Future createOffset( {required double dist, required bool toRight}) async { final t = target.value; if (t == null) return null; final base = lineBearingAt(t) ?? travelHeading.value ?? 0; final bearing = (base + (toRight ? 90 : -90) + 360) % 360; final rad = bearing * math.pi / 180; final eovY = t.planEovY + dist * math.sin(rad); final eovX = t.planEovX + dist * math.cos(rad); final w = CoordConverterService.to.eovToWgsPoint(eovY, eovX); final offset = StakeoutPoint( projectId: t.projectId, lineId: t.lineId, station: t.station, name: '${t.name}/E', pointType: t.pointType, source: 'offset', planEovY: eovY, planEovX: eovX, planEovZ: t.planEovZ, planLat: w.y, planLon: w.x, isOffset: true, parentUuid: t.uuid, offsetDist: dist, offsetBearing: bearing, ); final id = await _db.insertStakeoutPoint(offset); final saved = offset.copyWith(id: id); points.add(saved); setTarget(saved); return saved; } }