Kitűzés: pontok importja, szervízek, kitüző panel

This commit is contained in:
2026-07-06 11:47:41 +02:00
parent c01fdcf012
commit 6706b2b1ba
12 changed files with 2482 additions and 4 deletions
+118 -1
View File
@@ -9,7 +9,9 @@ import 'package:terepi_seged/models/measured_point.dart';
import 'package:terepi_seged/models/note_item.dart';
import 'package:terepi_seged/models/note_item_audio.dart';
import 'package:terepi_seged/models/note_item_photo.dart';
import 'package:terepi_seged/models/stakeout_point.dart';
import 'package:terepi_seged/models/track.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:uuid/uuid.dart';
import '../models/project.dart';
@@ -38,7 +40,7 @@ class AppDatabase {
final path = p.join(dbDir.path, 'terepi_seged.db');
return openDatabase(path,
version: 2,
version: 3,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: _onCreate,
onUpgrade: _onUpgrade);
@@ -214,6 +216,8 @@ class AppDatabase {
await db.execute(
'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)');
await _createStakeoutTable(db);
// Alap projekt létrehozása az első indításhoz
final now = DateTime.now().toIso8601String();
await db.insert('projects', {
@@ -239,6 +243,9 @@ class AppDatabase {
ALTER TABLE imported_layers ADD COLUMN stroke_width REAL;
''');
}
if (oldVersion < 3) {
await _createStakeoutTable(db);
}
}
// ── Projects CRUD ─────────────────────────────────────────────────
@@ -670,4 +677,114 @@ class AppDatabase {
);
return rows.map(MeasuredPoint.fromMap).toList();
}
// ═══════════════════════════════════════════════════════════════
// KITŰZÉS (stakeout_points) — szeizmikus line/station modell
// ═══════════════════════════════════════════════════════════════
Future<void> _createStakeoutTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS stakeout_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
line_id TEXT NOT NULL DEFAULT '',
station INTEGER NOT NULL,
name TEXT NOT NULL,
point_type TEXT NOT NULL DEFAULT 'geofon',
source TEXT NOT NULL DEFAULT 'csv',
plan_eov_y REAL NOT NULL,
plan_eov_x REAL NOT NULL,
plan_eov_z REAL,
plan_lat REAL NOT NULL,
plan_lon REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
measured_eov_y REAL,
measured_eov_x REAL,
measured_eov_z REAL,
measured_lat REAL,
measured_lon REAL,
dev_inline REAL,
dev_crossline REAL,
dev_dz REAL,
fix_quality INTEGER,
accuracy REAL,
tilt_deg REAL,
staked_at TEXT,
is_offset INTEGER NOT NULL DEFAULT 0,
parent_uuid TEXT,
offset_dist REAL,
offset_bearing REAL,
created_by TEXT,
device_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending'
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_proj_line '
'ON stakeout_points(project_id, line_id, station)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_status '
'ON stakeout_points(status)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_sync '
'ON stakeout_points(sync_status)');
}
Future<int> insertStakeoutPoint(StakeoutPoint p) async {
final db = await database;
final map = p.toMap();
map['device_id'] ??= DeviceIdentityService.to.deviceId;
return db.insert('stakeout_points', map);
}
/// Tömeges beszúrás importhoz — tranzakcióban; az azonos
/// (projekt, vonal, station) sorokat kihagyja. Visszaadja a
/// ténylegesen beszúrt darabszámot.
Future<int> insertStakeoutPoints(List<StakeoutPoint> points) async {
final db = await database;
var inserted = 0;
await db.transaction((txn) async {
for (final p in points) {
final dup = await txn.query('stakeout_points',
columns: ['id'],
where: 'project_id = ? AND line_id = ? AND station = ? '
'AND deleted_at IS NULL',
whereArgs: [p.projectId, p.lineId, p.station],
limit: 1);
if (dup.isNotEmpty) continue;
final map = p.toMap();
map['device_id'] ??= DeviceIdentityService.to.deviceId;
await txn.insert('stakeout_points', map);
inserted++;
}
});
return inserted;
}
Future<void> updateStakeoutPoint(StakeoutPoint p) async {
final db = await database;
final map = p.toMap()
..['updated_at'] = DateTime.now().toIso8601String()
..['sync_status'] = 'pending';
await db.update('stakeout_points', map, where: 'id = ?', whereArgs: [p.id]);
}
/// Soft delete — a törlés is szinkronizálható lesz (4. ütem).
Future<void> softDeleteStakeoutPoint(int id) async {
final db = await database;
final now = DateTime.now().toIso8601String();
await db.update('stakeout_points',
{'deleted_at': now, 'updated_at': now, 'sync_status': 'pending'},
where: 'id = ?', whereArgs: [id]);
}
Future<List<StakeoutPoint>> listStakeoutPoints(int projectId) async {
final db = await database;
final rows = await db.query('stakeout_points',
where: 'project_id = ? AND deleted_at IS NULL',
whereArgs: [projectId],
orderBy: 'line_id ASC, station ASC');
return rows.map(StakeoutPoint.fromMap).toList();
}
}
+481
View File
@@ -0,0 +1,481 @@
import 'dart:convert';
import 'dart:io';
import 'package:terepi_seged/services/coord_converter_service.dart';
import '../../models/stakeout_point.dart';
/// Oszlop-szerepek a CSV-megfeleltetéshez.
enum ColumnRole {
ignore(''),
station('Állomás / pontszám'),
line('Vonal'),
name('Megnevezés'),
eovY('EOV Y (kelet)'),
eovX('EOV X (észak)'),
lat('Szélesség (lat)'),
lon('Hosszúság (lon)'),
elevation('Magasság');
final String label;
const ColumnRole(this.label);
}
/// Az elemzés eredménye — ebből épül az előnézeti képernyő.
class CsvPreview {
final String fileName;
final String delimiter;
final bool hasHeader;
final bool decimalComma;
final List<String> headers; // fejléc vagy "1. oszlop" ...
final List<List<String>> sampleRows; // első max. 6 adatsor
final List<List<String>> allRows; // minden adatsor (nyers)
final List<ColumnRole> guessedRoles;
final String detectedCrs; // 'EOV' | 'WGS84' | '?'
final int badRowCount;
CsvPreview({
required this.fileName,
required this.delimiter,
required this.hasHeader,
required this.decimalComma,
required this.headers,
required this.sampleRows,
required this.allRows,
required this.guessedRoles,
required this.detectedCrs,
required this.badRowCount,
});
int get rowCount => allRows.length;
}
/// CSV / GeoJSON kitűzési pont import — magyar terepi sajátosságokkal:
/// * elválasztó-felismerés (';' a magyar Excel alapértelmezése, ',', tab)
/// * tizedesVESSZŐ kezelése
/// * kódolás: UTF-8, hibánál Latin-1 visszaesés (a koordináták így is
/// hibátlanok, legfeljebb az ő/ű torzulhat a nevekben)
/// * oszlopszerep-felismerés ÉRTÉKTARTOMÁNY alapján — Magyarországon az
/// EOV Y (421960 ezer), EOV X (48384 ezer), lat (45,548,8) és
/// lon (1623) tartományok páronként diszjunktak, így a felismerés
/// nagyon megbízható; a fejlécnevek csak megerősítésként számítanak.
class StakeoutImportService {
StakeoutImportService._();
// ── Magyar értéktartományok ──────────────────────────────────────
static bool _isEovY(double v) => v >= 421000 && v <= 960000;
static bool _isEovX(double v) => v >= 48000 && v <= 384000;
static bool _isLat(double v) => v >= 45.5 && v <= 48.8;
static bool _isLon(double v) => v >= 16.0 && v <= 23.0;
static bool _isElev(double v) => v >= -50 && v <= 3000;
// ═════════════════════════════════════════════════════════════════
// CSV elemzés
// ═════════════════════════════════════════════════════════════════
static Future<CsvPreview> analyzeCsv(File file) async {
final bytes = await file.readAsBytes();
String text;
try {
text = utf8.decode(bytes);
} catch (_) {
text = latin1.decode(bytes); // Windows-1250 közelítése
}
// BOM eltávolítás
if (text.isNotEmpty && text.codeUnitAt(0) == 0xFEFF) {
text = text.substring(1);
}
final lines = text
.split(RegExp(r'\r\n|\r|\n'))
.where((l) => l.trim().isNotEmpty)
.toList();
if (lines.isEmpty) {
throw const FormatException('A fájl üres.');
}
// 1. Elválasztó: amelyikből a legtöbb van KONZISZTENSEN a sorokban.
final delimiter = _detectDelimiter(lines.take(20).toList());
// 2. Sorok felbontása.
var rows = lines.map((l) => _splitLine(l, delimiter)).toList();
final colCount = rows.map((r) => r.length).reduce((a, b) => a > b ? a : b);
// Rövid sorok kipótlása üres cellákkal, hogy a táblázat téglalap legyen.
rows = rows
.map((r) => [...r, ...List.filled(colCount - r.length, '')])
.toList();
// 3. Tizedesvessző? Ha ';' az elválasztó és sok "123,45" mintájú cella
// van, akkor a vessző tizedesjel.
final decimalComma = _detectDecimalComma(rows.take(30).toList());
// 4. Fejléc: az első sor akkor fejléc, ha a cellái NEM számok, de a
// második soréi többségében igen.
final hasHeader = rows.length > 1 &&
_numericRatio(rows.first, decimalComma) < 0.5 &&
_numericRatio(rows[1], decimalComma) >= 0.5;
final headers = hasHeader
? rows.first.map((h) => h.trim()).toList()
: List.generate(colCount, (i) => '${i + 1}. oszlop');
final dataRows = hasHeader ? rows.sublist(1) : rows;
// 5. Oszlopszerepek felismerése.
final roles = _guessRoles(headers, dataRows, decimalComma);
final crs =
roles.contains(ColumnRole.eovY) && roles.contains(ColumnRole.eovX)
? 'EOV'
: roles.contains(ColumnRole.lat) && roles.contains(ColumnRole.lon)
? 'WGS84'
: '?';
return CsvPreview(
fileName: file.uri.pathSegments.last,
delimiter: delimiter,
hasHeader: hasHeader,
decimalComma: decimalComma,
headers: headers,
sampleRows: dataRows.take(6).toList(),
allRows: dataRows,
guessedRoles: roles,
detectedCrs: crs,
badRowCount: 0, // a tényleges építéskor derül ki
);
}
static String _detectDelimiter(List<String> lines) {
var best = ';';
var bestScore = -1;
for (final d in [';', ',', '\t']) {
final counts = lines.map((l) => d.allMatches(l).length).toList();
final min = counts.reduce((a, b) => a < b ? a : b);
// Pontszám: minden sorban legyen legalább 1, és konzisztens legyen.
final score = min > 0 ? min * 10 - (counts.toSet().length - 1) : -1;
if (score > bestScore) {
bestScore = score;
best = d;
}
}
return best;
}
/// Egyszerű felbontás idézőjel-kezeléssel ("a;b" egyben marad).
static List<String> _splitLine(String line, String delimiter) {
final cells = <String>[];
final sb = StringBuffer();
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
final c = line[i];
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == delimiter && !inQuotes) {
cells.add(sb.toString().trim());
sb.clear();
} else {
sb.write(c);
}
}
cells.add(sb.toString().trim());
return cells;
}
static final _decimalCommaRe = RegExp(r'^-?\d+,\d+$');
static final _decimalDotRe = RegExp(r'^-?\d+\.\d+$');
static bool _detectDecimalComma(List<List<String>> rows) {
var comma = 0, dot = 0;
for (final row in rows) {
for (final cell in row) {
if (_decimalCommaRe.hasMatch(cell)) comma++;
if (_decimalDotRe.hasMatch(cell)) dot++;
}
}
return comma > dot;
}
/// Cella → szám, a tizedesjel-beállítás figyelembevételével.
static double? parseNum(String cell, bool decimalComma) {
var s = cell.trim();
if (s.isEmpty) return null;
if (decimalComma) {
s = s.replaceAll(' ', '').replaceAll(',', '.');
}
return double.tryParse(s);
}
static double _numericRatio(List<String> row, bool decimalComma) {
if (row.isEmpty) return 0;
final n = row.where((c) => parseNum(c, decimalComma) != null).length;
return n / row.length;
}
// ── Szerepfelismerés ─────────────────────────────────────────────
static List<ColumnRole> _guessRoles(
List<String> headers, List<List<String>> rows, bool decimalComma) {
final n = headers.length;
final sample = rows.take(200).toList();
final roles = List<ColumnRole>.filled(n, ColumnRole.ignore);
// Oszloponkénti statisztika.
final stats = List.generate(n, (c) {
final values = <double>[];
final raw = <String>[];
for (final row in sample) {
if (c >= row.length || row[c].isEmpty) continue;
raw.add(row[c]);
final v = parseNum(row[c], decimalComma);
if (v != null) values.add(v);
}
final distinct = raw.toSet().length;
final allInt =
values.isNotEmpty && values.every((v) => v == v.roundToDouble());
double share(bool Function(double) test) =>
values.isEmpty ? 0 : values.where(test).length / values.length;
return (
numericRatio: raw.isEmpty ? 0.0 : values.length / raw.length,
values: values,
distinct: distinct,
count: raw.length,
allInt: allInt,
eovY: share(_isEovY),
eovX: share(_isEovX),
lat: share(_isLat),
lon: share(_isLon),
elev: share(_isElev),
);
});
String h(int c) => headers[c].toLowerCase();
bool hHas(int c, List<String> keys) => keys.any((k) => h(c).contains(k));
// 1. Koordináták — tartomány alapján (95% feletti találat kell),
// fejlécnév csak döntetlennél számít.
int pick(double Function(int) score, List<String> headerKeys) {
var best = -1;
var bestScore = 0.94;
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
var s = score(c);
if (s > 0.94 && hHas(c, headerKeys)) s += 0.05;
if (s > bestScore) {
bestScore = s;
best = c;
}
}
return best;
}
final cEovY = pick((c) => stats[c].eovY, ['y', 'kelet', 'east']);
if (cEovY >= 0) roles[cEovY] = ColumnRole.eovY;
final cEovX = pick((c) => stats[c].eovX, ['x', 'észak', 'eszak', 'north']);
if (cEovX >= 0) roles[cEovX] = ColumnRole.eovX;
final cLat = pick((c) => stats[c].lat, ['lat', 'fi', 'szél', 'szel']);
if (cLat >= 0) roles[cLat] = ColumnRole.lat;
final cLon = pick((c) => stats[c].lon, ['lon', 'lambda', 'hossz']);
if (cLon >= 0) roles[cLon] = ColumnRole.lon;
// 2. Magasság: numerikus, elfogadható tartomány, NEM egész-azonosító
// jellegű; fejléc segít.
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
final s = stats[c];
if (s.numericRatio > 0.9 &&
s.elev > 0.94 &&
(hHas(c, ['z', 'mag', 'elev', 'h']) || !s.allInt)) {
roles[c] = ColumnRole.elevation;
break;
}
}
// 3. Állomás/pontszám: egész, (közel) egyedi értékek.
var bestStation = -1;
var bestUnique = 0.9;
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
final s = stats[c];
if (s.numericRatio > 0.95 && s.allInt && s.count > 0) {
var unique = s.distinct / s.count;
if (hHas(c, ['psz', 'pont', 'station', 'áll', 'all', 'id'])) {
unique += 0.05;
}
if (unique > bestUnique) {
bestUnique = unique;
bestStation = c;
}
}
}
if (bestStation >= 0) roles[bestStation] = ColumnRole.station;
// 4. Vonal: kevés egyedi értékű oszlop (a pontszám sokszorosa tartozik
// egy vonalhoz); fejléc segít.
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
final s = stats[c];
final lowCardinality =
s.count >= 10 && s.distinct <= (s.count / 4).ceil();
if (hHas(c, ['line', 'vonal', 'ln']) || lowCardinality) {
roles[c] = ColumnRole.line;
break;
}
}
// 5. Név: az első még szabad, többségében szöveges oszlop.
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
if (stats[c].numericRatio < 0.5 && stats[c].count > 0) {
roles[c] = ColumnRole.name;
break;
}
}
return roles;
}
// ═════════════════════════════════════════════════════════════════
// Pontok építése a megfeleltetés alapján
// ═════════════════════════════════════════════════════════════════
/// A (kézzel jóváhagyott) szerep-kiosztás alapján felépíti a pontokat.
/// Visszaadja a pontokat és a kihagyott (hibás) sorok számát.
static ({List<StakeoutPoint> points, int skipped}) buildPoints({
required CsvPreview preview,
required List<ColumnRole> roles,
required int projectId,
}) {
int col(ColumnRole r) => roles.indexOf(r);
final cStation = col(ColumnRole.station);
final cLine = col(ColumnRole.line);
final cName = col(ColumnRole.name);
final cEovY = col(ColumnRole.eovY);
final cEovX = col(ColumnRole.eovX);
final cLat = col(ColumnRole.lat);
final cLon = col(ColumnRole.lon);
final cElev = col(ColumnRole.elevation);
final isEov = cEovY >= 0 && cEovX >= 0;
if (!isEov && !(cLat >= 0 && cLon >= 0)) {
throw const FormatException(
'Hiányzó koordináta-oszlopok: EOV Y+X vagy lat+lon kell.');
}
final conv = CoordConverterService.to;
final points = <StakeoutPoint>[];
var skipped = 0;
var autoStation = 1;
for (final row in preview.allRows) {
double? get(int c) => c >= 0 && c < row.length
? parseNum(row[c], preview.decimalComma)
: null;
double eovY, eovX, lat, lon;
if (isEov) {
final y = get(cEovY), x = get(cEovX);
if (y == null || x == null || !_isEovY(y) || !_isEovX(x)) {
skipped++;
continue;
}
eovY = y;
eovX = x;
final p = conv.eovToWgsPoint(y, x);
lon = p.x;
lat = p.y;
} else {
final la = get(cLat), lo = get(cLon);
if (la == null || lo == null || !_isLat(la) || !_isLon(lo)) {
skipped++;
continue;
}
lat = la;
lon = lo;
final p = conv.wgsToEovPoint(lo, la);
eovY = p.x;
eovX = p.y;
}
final stationVal = get(cStation);
final station = stationVal?.round() ?? autoStation++;
final line = cLine >= 0 && cLine < row.length ? row[cLine].trim() : '';
final name = cName >= 0 && cName < row.length && row[cName].isNotEmpty
? row[cName].trim()
: station.toString();
points.add(StakeoutPoint(
projectId: projectId,
lineId: line,
station: station,
name: name,
source: 'csv',
planEovY: eovY,
planEovX: eovX,
planEovZ: get(cElev),
planLat: lat,
planLon: lon,
));
}
return (points: points, skipped: skipped);
}
// ═════════════════════════════════════════════════════════════════
// GeoJSON
// ═════════════════════════════════════════════════════════════════
/// GeoJSON → CsvPreview-kompatibilis táblázat: a property-k az oszlopok,
/// plusz a kinyert lon/lat. Így ugyanaz az előnézeti/megfeleltetési
/// képernyő szolgálja ki, mint a CSV-t.
static Future<CsvPreview> analyzeGeojson(File file) async {
final json = jsonDecode(await file.readAsString());
final features = (json['features'] as List?) ?? [];
final propKeys = <String>{};
final rows = <List<String>>[];
for (final f in features) {
final geom = f['geometry'];
if (geom == null || geom['type'] != 'Point') continue;
propKeys.addAll(
((f['properties'] as Map?) ?? {}).keys.map((k) => k.toString()));
}
final keys = propKeys.toList();
for (final f in features) {
final geom = f['geometry'];
if (geom == null || geom['type'] != 'Point') continue;
final coords = geom['coordinates'] as List;
final props = (f['properties'] as Map?) ?? {};
rows.add([
for (final k in keys) '${props[k] ?? ''}',
'${coords[0]}', // lon
'${coords[1]}', // lat
if (coords.length > 2) '${coords[2]}' else '',
]);
}
if (rows.isEmpty) {
throw const FormatException('A GeoJSON nem tartalmaz Point elemet.');
}
final headers = [...keys, 'lon', 'lat', 'z'];
final roles = _guessRoles(headers, rows, false);
// A GeoJSON szabvány szerint mindig WGS84 — a koordináta-oszlopokat
// ismerjük, kényszerítjük.
roles[headers.length - 3] = ColumnRole.lon;
roles[headers.length - 2] = ColumnRole.lat;
if (rows.any((r) => r.last.isNotEmpty)) {
roles[headers.length - 1] = ColumnRole.elevation;
}
return CsvPreview(
fileName: file.uri.pathSegments.last,
delimiter: ',',
hasHeader: true,
decimalComma: false,
headers: headers,
sampleRows: rows.take(6).toList(),
allRows: rows,
guessedRoles: roles,
detectedCrs: 'WGS84',
badRowCount: 0,
);
}
}
+469
View File
@@ -0,0 +1,469 @@
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;
}
}