Files
MobilApp/lib/services/stakeout_service.dart
T

583 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> 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 = <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;
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<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() async {
super.onInit();
await _loadSettings();
geoidGrid = await GeoidGrid.load('assets/Grids/geoid_eht2014.gtx');
if (Get.isRegistered<GnssService>()) {
ever(GnssService.to.lastGgaLine, (_) => _onPosition());
}
}
@override
void onClose() {
_hapticTimer?.cancel();
super.onClose();
}
Future<void> _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<void> 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<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 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<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 (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<StakeoutPoint?> 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 = <double>[], xs = <double>[], alts = <double>[];
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<double> l) => l.reduce((a, b) => a + b) / l.length;
return (y: mean(ys), x: mean(xs), alt: mean(alts));
}
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();
}
/// Státusz-állítás a listából (kihagyás / visszaállítás).
Future<void> 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<void> 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<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;
}
}