import 'dart:async'; import 'dart:math' as math; import 'package:flutter/services.dart'; import 'package:get/get.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 '../../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) { await load(); _applyPhase(); _scheduleHaptic(); } else { _hapticTimer?.cancel(); } } // ── Á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; // ── 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() { super.onInit(); if (Get.isRegistered()) { ever(GnssService.to.lastGgaLine, (_) => _onPosition()); } } @override void onClose() { _hapticTimer?.cancel(); super.onClose(); } // ═════════════════════════════════════════════════════════════════ // 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 p = CoordConverterService.to .wgsToEovPoint(gnss.longitude.value, gnss.latitude.value); curEovY.value = p.x; curEovX.value = p.y; 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(); 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(); } _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 mért pozícióval. 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) return null; final gnss = GnssService.to; final dy = t.planEovY - curEovY.value; final dx = t.planEovX - curEovX.value; 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 updated = t.copyWith( status: StakeoutStatus.staked, measuredEovY: curEovY.value, measuredEovX: curEovX.value, measuredEovZ: curAlt.value, measuredLat: gnss.latitude.value, measuredLon: gnss.longitude.value, devInline: inline, devCrossline: crossline, devDz: t.planEovZ != null ? t.planEovZ! - curAlt.value : 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; } 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(); } /// 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; } }