Files
MobilApp/lib/services/stakeout_service.dart
T

470 lines
16 KiB
Dart
Raw Normal View History

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<void> setActive(bool value) async {
if (active.value == value) return;
active.value = value;
if (value) {
await load();
_applyPhase();
_scheduleHaptic();
} else {
_hapticTimer?.cancel();
}
}
// ── Állapot ──────────────────────────────────────────────────────
final points = <StakeoutPoint>[].obs;
final target = Rxn<StakeoutPoint>();
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<double>(); // 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<double>();
final withinTolerance = false.obs;
double? _histY, _histX;
Timer? _hapticTimer;
bool _toleranceAnnounced = false;
@override
void onInit() {
super.onInit();
if (Get.isRegistered<GnssService>()) {
ever(GnssService.to.lastGgaLine, (_) => _onPosition());
}
}
@override
void onClose() {
_hapticTimer?.cancel();
super.onClose();
}
// ═════════════════════════════════════════════════════════════════
// Betöltés / cél-kezelés
// ═════════════════════════════════════════════════════════════════
Future<void> 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<String> get lines =>
points.map((p) => p.lineId).toSet().toList()..sort();
Map<String, ({int total, int staked})> get lineProgress {
final m = <String, ({int total, int staked})>{};
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<CoordConverterService>()) {
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<StakeoutPoint> _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<StakeoutPoint?> 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<void> 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<StakeoutPoint?> 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;
}
}