Kitűzés: kényelmi funkciók, pontok listája, export

This commit is contained in:
2026-07-06 14:26:16 +02:00
parent 6706b2b1ba
commit 2a2ce4bb92
9 changed files with 1238 additions and 33 deletions
+253
View File
@@ -277,6 +277,14 @@ class AppDatabase {
return rows.isEmpty ? null : Project.fromMap(rows.first);
}
/// Projekt keresése a globális uuid alapján — közös projekthez
/// csatlakozáskor ezzel derül ki, megvan-e már lokálisan.
Future<Project?> getProjectByUuid(String uuid) async {
final rows = await (await database)
.query('projects', where: 'uuid = ?', whereArgs: [uuid], limit: 1);
return rows.isEmpty ? null : Project.fromMap(rows.first);
}
// Projekt statisztikák — a listázáshoz
Future<Map<String, int>> getProjectStats(int projectId) async {
final db = await database;
@@ -787,4 +795,249 @@ class AppDatabase {
orderBy: 'line_id ASC, station ASC');
return rows.map(StakeoutPoint.fromMap).toList();
}
/// Feltöltésre váró kitűzési pontok — a projekt uuid-jával együtt.
/// A csak lokális projektek SQL-szinten kizárva.
Future<List<Map<String, dynamic>>> pendingStakeoutPoints() async {
final db = await database;
return db.rawQuery('''
SELECT sp.*, pr.uuid AS project_uuid
FROM stakeout_points sp
JOIN projects pr ON pr.id = sp.project_id
WHERE sp.sync_status = 'pending' AND pr.is_local_only = 0
ORDER BY sp.id ASC
''');
}
// ═══════════════════════════════════════════════════════════════
// SZINKRON-SEGÉDEK — a TsSyncService használja őket
// ═══════════════════════════════════════════════════════════════
//
// FONTOS: minden lekérdezés kihagyja az is_local_only = 1 projekteket
// és azok adatait — a "csak lokális" projekt SOHA nem hagyja el az
// eszközt, semmilyen szinkron-útvonalon.
/// Feltöltésre váró (nem lokális) projektek.
Future<List<Map<String, dynamic>>> pendingProjects() async {
final db = await database;
return db.query('projects',
where: "is_local_only = 0 AND sync_status = 'pending'");
}
/// Feltöltésre váró bemért pontok — a projekt uuid-jával együtt.
Future<List<Map<String, dynamic>>> pendingMeasuredPoints() async {
final db = await database;
return db.rawQuery('''
SELECT mp.*, pr.uuid AS project_uuid
FROM measured_points mp
JOIN projects pr ON pr.id = mp.project_id
WHERE mp.sync_status = 'pending' AND pr.is_local_only = 0
ORDER BY mp.id ASC
''');
}
/// Feltöltésre váró trackek — a projekt uuid-jával együtt.
Future<List<Map<String, dynamic>>> pendingTracks() async {
final db = await database;
return db.rawQuery('''
SELECT t.*, pr.uuid AS project_uuid
FROM tracks t
JOIN projects pr ON pr.id = t.project_id
WHERE t.sync_status = 'pending' AND pr.is_local_only = 0
ORDER BY t.id ASC
''');
}
/// Feltöltésre váró track-pontok, batch-ben — track- és projekt-uuid-val.
/// Csak lezárt vagy futó, NEM törölt, NEM lokális trackek pontjai.
Future<List<Map<String, dynamic>>> pendingTrackPoints(
{int limit = 500}) async {
final db = await database;
return db.rawQuery('''
SELECT tp.*, t.uuid AS track_uuid, pr.uuid AS project_uuid
FROM track_points tp
JOIN tracks t ON t.id = tp.track_id
JOIN projects pr ON pr.id = t.project_id
WHERE tp.sync_status = 'pending'
AND t.deleted_at IS NULL
AND t.sync_status != 'pending'
AND pr.is_local_only = 0
ORDER BY tp.id ASC
LIMIT ?
''', [limit]);
}
/// Feltöltésre váró terepbejárás-elemek (csak projekthez rendeltek).
Future<List<Map<String, dynamic>>> pendingNoteItems() async {
final db = await database;
return db.rawQuery('''
SELECT n.*, pr.uuid AS project_uuid
FROM note_items n
JOIN projects pr ON pr.id = n.project_id
WHERE n.sync_status = 'pending' AND pr.is_local_only = 0
ORDER BY n.id ASC
''');
}
/// Összes feltöltésre váró sor száma (UI-jelvényhez).
Future<int> pendingSyncCount() async {
final db = await database;
final res = await db.rawQuery('''
SELECT
(SELECT COUNT(*) FROM projects p
WHERE p.is_local_only = 0 AND p.sync_status = 'pending') +
(SELECT COUNT(*) FROM measured_points mp
JOIN projects pr ON pr.id = mp.project_id
WHERE mp.sync_status = 'pending' AND pr.is_local_only = 0) +
(SELECT COUNT(*) FROM tracks t
JOIN projects pr ON pr.id = t.project_id
WHERE t.sync_status = 'pending' AND pr.is_local_only = 0) +
(SELECT COUNT(*) FROM track_points tp
JOIN tracks t ON t.id = tp.track_id
JOIN projects pr ON pr.id = t.project_id
WHERE tp.sync_status = 'pending' AND pr.is_local_only = 0)
AS cnt
''');
return (res.first['cnt'] as int?) ?? 0;
}
/// Sorok megjelölése szinkronizáltként, uuid alapján (500-as darabokban,
/// az SQLite paraméter-limit miatt).
Future<void> markSyncedByUuid(String table, List<String> uuids) async {
if (uuids.isEmpty) return;
final db = await database;
for (var i = 0; i < uuids.length; i += 500) {
final chunk =
uuids.sublist(i, i + 500 > uuids.length ? uuids.length : i + 500);
final placeholders = List.filled(chunk.length, '?').join(',');
await db.rawUpdate(
"UPDATE $table SET sync_status = 'synced' WHERE uuid IN ($placeholders)",
chunk,
);
}
}
// ── Szinkron-kurzor (projektenként) ──────────────────────────────
Future<String?> getProjectCursor(int localProjectId) async {
final db = await database;
final rows = await db.query('projects',
columns: ['last_pulled_at'],
where: 'id = ?',
whereArgs: [localProjectId],
limit: 1);
return rows.isEmpty ? null : rows.first['last_pulled_at'] as String?;
}
Future<void> setProjectCursor(int localProjectId, String cursor) async {
final db = await database;
await db.update('projects', {'last_pulled_at': cursor},
where: 'id = ?', whereArgs: [localProjectId]);
}
// ── Távoli sorok lokális alkalmazása ─────────────────────────────
/// Távoli projekt-sor lokális upsertje uuid alapján.
/// Visszaadja a lokális id-t. Csatlakozáskor és letöltéskor is ez fut.
Future<int> upsertProjectFromRemote(Map<String, dynamic> remote) async {
final db = await database;
final existing = await getProjectByUuid(remote['id'] as String);
final map = <String, dynamic>{
'uuid': remote['id'],
'name': remote['name'] ?? '',
'client': remote['client'] ?? '',
'description': remote['description'] ?? '',
'crs': remote['crs'] ?? 'eov',
'color': remote['color'] ?? '#185FA5',
'status': remote['status'] ?? 'active',
'is_local_only': 0,
'updated_at': remote['updated_at'] ?? DateTime.now().toIso8601String(),
'deleted_at': remote['deleted_at'],
'sync_status': 'synced',
'last_synced_at': DateTime.now().toIso8601String(),
};
if (existing == null) {
map['created_at'] =
remote['created_at'] ?? DateTime.now().toIso8601String();
return db.insert('projects', map);
}
// Lokális, még fel nem töltött módosítás nyer (push fut előbb).
final localPending = await db.query('projects',
columns: ['sync_status'],
where: 'id = ?',
whereArgs: [existing.id],
limit: 1);
if (localPending.first['sync_status'] == 'pending') return existing.id!;
await db.update('projects', map, where: 'id = ?', whereArgs: [existing.id]);
return existing.id!;
}
/// Általános távoli-sor alkalmazás uuid-kulcsú táblára
/// (measured_points / tracks / note_items).
///
/// LWW-szabály: ha a lokális sor 'pending' (van el nem küldött helyi
/// módosítás), a távolit kihagyjuk — a push úgyis előbb fut, mint a pull.
Future<void> applyRemoteRow(
String table, Map<String, dynamic> localMap) async {
final db = await database;
final uuid = localMap['uuid'] as String;
final rows = await db.query(table,
columns: ['id', 'sync_status'],
where: 'uuid = ?',
whereArgs: [uuid],
limit: 1);
if (rows.isEmpty) {
// Ismeretlen sor tombstone-ja: nincs mit törölni, kihagyjuk.
if (localMap['deleted_at'] != null) return;
await db.insert(table, localMap);
return;
}
if (rows.first['sync_status'] == 'pending') return;
await db.update(table, localMap,
where: 'id = ?', whereArgs: [rows.first['id']]);
}
/// Track lokális id-ja uuid alapján (a track-pontok letöltéséhez).
Future<int?> getTrackIdByUuid(String uuid) async {
final db = await database;
final rows = await db.query('tracks',
columns: ['id'], where: 'uuid = ?', whereArgs: [uuid], limit: 1);
return rows.isEmpty ? null : rows.first['id'] as int;
}
/// Távoli track-pontok beszúrása — a uuid unique index miatt az
/// ismétlődések csendben kimaradnak (idempotens).
Future<void> insertRemoteTrackPoints(List<Map<String, dynamic>> maps) async {
if (maps.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final m in maps) {
batch.insert('track_points', m,
conflictAlgorithm: ConflictAlgorithm.ignore);
}
await batch.commit(noResult: true);
// A point_count/distance a saját eszközön mérésnél frissül; letöltött
// pontoknál a track sora már a helyes összesítőkkel érkezik a szerverről.
}
// ── Réteg-szinkron segédek ───────────────────────────────────────
Future<int> getImportedLayerVersion(String id) async {
final db = await database;
final rows = await db.query('imported_layers',
columns: ['version'], where: 'id = ?', whereArgs: [id], limit: 1);
return rows.isEmpty ? -1 : (rows.first['version'] as int? ?? 0);
}
Future<void> upsertImportedLayerRaw(Map<String, dynamic> map) async {
final db = await database;
await db.insert('imported_layers', map,
conflictAlgorithm: ConflictAlgorithm.replace);
}
}
+1
View File
@@ -357,6 +357,7 @@ class NtripService extends GetxService {
void _forwardRtcm(Uint8List data) {
receivedBytes.value += data.length; // kumulált (a korábbi = felülírt)
packetCount.value++;
lastRtcmAt.value = DateTime.now();
onRtcmData?.call(data);
}
+143
View File
@@ -0,0 +1,143 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../models/stakeout_point.dart';
/// Kitűzési jegyzőkönyv CSV-export.
///
/// Két dialektus:
/// * MAGYAR (Excel-barát): pontosvessző elválasztó, tizedesVESSZŐ,
/// UTF-8 BOM-mal (enélkül az Excel elrontja az ékezeteket)
/// * NEMZETKÖZI: vessző elválasztó, tizedespont
///
/// A jegyzőkönyv a teljes dokumentációt tartalmazza: terv- ÉS mért
/// koordináták, inline/crossline/dZ eltérések, fix-minőség, pontosság,
/// időpont, eszköz, valamint az eltolt pontoknál az eltolás-vektor.
class StakeoutExportService {
StakeoutExportService._();
static Future<void> exportAndShare({
required List<StakeoutPoint> points,
required bool hungarian,
required bool stakedOnly,
String projectName = '',
}) async {
final rows = stakedOnly
? points.where((p) => p.status == StakeoutStatus.staked).toList()
: List.of(points);
if (rows.isEmpty) {
throw StateError('Nincs exportálható pont.');
}
rows.sort((a, b) => a.lineId != b.lineId
? a.lineId.compareTo(b.lineId)
: a.station.compareTo(b.station));
final sep = hungarian ? ';' : ',';
String num(double? v, [int dec = 3]) {
if (v == null) return '';
final s = v.toStringAsFixed(dec);
return hungarian ? s.replaceAll('.', ',') : s;
}
String txt(String s) =>
s.contains(sep) || s.contains('"') || s.contains('\n')
? '"${s.replaceAll('"', '""')}"'
: s;
String status(StakeoutStatus s) => switch (s) {
StakeoutStatus.pending => 'varakozik',
StakeoutStatus.staked => 'kituzve',
StakeoutStatus.skipped => 'kihagyva',
};
String fix(int? q) => switch (q) {
4 => 'RTK_FIXED',
5 => 'RTK_FLOAT',
2 => 'DGPS',
1 => 'GPS',
0 => 'NO_FIX',
null => '',
_ => 'Q$q',
};
final header = [
'Vonal',
'Allomas',
'Nev',
'Tipus',
'Statusz',
'Terv_EOV_Y',
'Terv_EOV_X',
'Terv_EOV_Z',
'Mert_EOV_Y',
'Mert_EOV_X',
'Mert_EOV_Z',
'Inline_m',
'Crossline_m',
'dZ_m',
'Fix',
'Pontossag_m',
'Kituzes_ideje',
'Eltolt',
'Eltolas_m',
'Eltolas_irany_fok',
'Eszkoz_id',
'Forras',
].join(sep);
final sb = StringBuffer();
sb.write('\uFEFF'); // UTF-8 BOM — az Excel enélkül elrontja az ékezeteket
sb.writeln(header);
for (final r in rows) {
sb.writeln([
txt(r.lineId),
r.station.toString(),
txt(r.name),
txt(r.pointType),
status(r.status),
num(r.planEovY),
num(r.planEovX),
num(r.planEovZ),
num(r.measuredEovY),
num(r.measuredEovX),
num(r.measuredEovZ),
num(r.devInline),
num(r.devCrossline),
num(r.devDz),
fix(r.fixQuality),
num(r.accuracy, 2),
r.stakedAt?.toIso8601String() ?? '',
r.isOffset ? '1' : '0',
num(r.offsetDist, 2),
num(r.offsetBearing, 1),
txt(r.deviceId ?? ''),
txt(r.source),
].join(sep));
}
final dir = await getTemporaryDirectory();
final stamp = DateTime.now()
.toIso8601String()
.substring(0, 16)
.replaceAll(':', '')
.replaceAll('-', '')
.replaceAll('T', '_');
final safeName = projectName.isEmpty
? 'kituzes'
: projectName.replaceAll(RegExp(r'[^\w\-]'), '_');
final path = p.join(dir.path, '${safeName}_$stamp.csv');
await File(path).writeAsString(sb.toString(), flush: true);
await SharePlus.instance.share(ShareParams(
files: [XFile(path, mimeType: 'text/csv')],
subject: 'Kitűzési jegyzőkönyv — $projectName',
text: '${rows.length} pont'
'${stakedOnly ? ' (csak kitűzöttek)' : ''}',
));
}
}
+129 -16
View File
@@ -3,10 +3,13 @@ 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';
@@ -49,11 +52,13 @@ class StakeoutService extends GetxService {
if (active.value == value) return;
active.value = value;
if (value) {
WakelockPlus.enable();
await load();
_applyPhase();
_scheduleHaptic();
} else {
_hapticTimer?.cancel();
WakelockPlus.disable();
}
}
@@ -74,6 +79,18 @@ class StakeoutService extends GetxService {
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;
@@ -96,8 +113,11 @@ class StakeoutService extends GetxService {
bool _toleranceAnnounced = false;
@override
void onInit() {
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());
}
@@ -109,6 +129,24 @@ class StakeoutService extends GetxService {
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
// ═════════════════════════════════════════════════════════════════
@@ -227,12 +265,18 @@ class StakeoutService extends GetxService {
!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(gnss.longitude.value, gnss.latitude.value);
final p = CoordConverterService.to.wgsToEovPoint(lon, lat);
curEovY.value = p.x;
curEovX.value = p.y;
curAlt.value = gnss.altitude.value;
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).
@@ -287,7 +331,7 @@ class StakeoutService extends GetxService {
if (withinTolerance.value && !wasWithin && !_toleranceAnnounced) {
_toleranceAnnounced = true;
if (hapticsEnabled.value) HapticFeedback.heavyImpact();
SystemSound.play(SystemSoundType.alert);
if (soundEnabled.value) SystemSound.play(SystemSoundType.alert);
} else if (!withinTolerance.value) {
_toleranceAnnounced = false;
}
@@ -330,6 +374,7 @@ class StakeoutService extends GetxService {
} else {
HapticFeedback.lightImpact();
}
if (soundEnabled.value) SystemSound.play(SystemSoundType.click);
_scheduleHaptic();
});
}
@@ -380,15 +425,29 @@ class StakeoutService extends GetxService {
// 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.
/// 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) return null;
if (t == null || !hasPosition.value || isAveraging.value) return null;
final gnss = GnssService.to;
final dy = t.planEovY - curEovY.value;
final dx = t.planEovX - curEovX.value;
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 =
@@ -396,16 +455,18 @@ class StakeoutService extends GetxService {
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: curEovY.value,
measuredEovX: curEovX.value,
measuredEovZ: curAlt.value,
measuredLat: gnss.latitude.value,
measuredLon: gnss.longitude.value,
measuredEovY: useY,
measuredEovX: useX,
measuredEovZ: useAlt,
measuredLat: w.y,
measuredLon: w.x,
devInline: inline,
devCrossline: crossline,
devDz: t.planEovZ != null ? t.planEovZ! - curAlt.value : null,
devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null,
fixQuality: gnss.gpsQuality.value,
accuracy: gnss.horizontalAccuracy,
stakedAt: DateTime.now(),
@@ -417,6 +478,36 @@ class StakeoutService extends GetxService {
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;
@@ -428,6 +519,28 @@ class StakeoutService extends GetxService {
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.
+188
View File
@@ -0,0 +1,188 @@
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/services/app_database.dart';
import 'stakeout_service.dart';
/// Kitűzési pontok Supabase-szinkronja — a meglévő outbox-mintára.
///
/// A TsSyncService hívja minden ciklus végén (mint a LayerSyncService-t),
/// de kézzel (syncNow) is futtatható. PUSH: a 'pending' sorok uuid-ra
/// upserttel (idempotens); PULL: projektenként inkrementálisan
/// (updated_at > kurzor). A csak-lokális projektek kizárását az
/// AppDatabase.pendingStakeoutPoints() végzi SQL-szinten, a pull pedig
/// csak a nem-lokális projekteken iterál.
///
/// SAJÁT kurzort használ (SharedPreferences, projektenként), mert a
/// TsSyncService a projects.last_pulled_at kurzort a saját pullja végén
/// már előretolta — közös kurzorral a kitűzés-sorok kimaradhatnának.
///
/// Csapat-láthatóság: a letöltött sorok a lokális DB-be kerülnek, a
/// StakeoutService.load() frissíti a listát, így a MÁS ESZKÖZÖN kitűzött
/// pont a térképen magától zöldre vált.
class StakeoutSyncService extends GetxService {
static StakeoutSyncService get to => Get.find();
final isSyncing = false.obs;
final lastError = ''.obs;
SupabaseClient get _client => Supabase.instance.client;
AppDatabase get _db => AppDatabase.instance;
Future<void> sync() async {
if (_client.auth.currentUser == null) return;
if (isSyncing.value) return;
isSyncing.value = true;
lastError.value = '';
try {
final pulledAny = await _pushThenPull();
// Ha aktív a kitűzés mód és jött új adat, frissítjük a nézetet —
// itt válik láthatóvá a többiek munkája (zöld markerek).
if (pulledAny &&
Get.isRegistered<StakeoutService>() &&
StakeoutService.to.active.value) {
await StakeoutService.to.load();
}
} catch (e) {
lastError.value = e.toString();
} finally {
isSyncing.value = false;
}
}
Future<bool> _pushThenPull() async {
await _push();
return _pull();
}
// ── PUSH ─────────────────────────────────────────────────────────
Future<void> _push() async {
final rows = await _db.pendingStakeoutPoints();
if (rows.isEmpty) return;
final payload = rows
.map((r) => {
'id': r['uuid'],
'project_id': r['project_uuid'],
'line_id': r['line_id'],
'station': r['station'],
'name': r['name'],
'point_type': r['point_type'],
'source': r['source'],
'plan_eov_y': r['plan_eov_y'],
'plan_eov_x': r['plan_eov_x'],
'plan_eov_z': r['plan_eov_z'],
'plan_lat': r['plan_lat'],
'plan_lon': r['plan_lon'],
'status': r['status'],
'measured_eov_y': r['measured_eov_y'],
'measured_eov_x': r['measured_eov_x'],
'measured_eov_z': r['measured_eov_z'],
'measured_lat': r['measured_lat'],
'measured_lon': r['measured_lon'],
'dev_inline': r['dev_inline'],
'dev_crossline': r['dev_crossline'],
'dev_dz': r['dev_dz'],
'fix_quality': r['fix_quality'],
'accuracy': r['accuracy'],
'tilt_deg': r['tilt_deg'],
'staked_at': _toUtc(r['staked_at'] as String?),
'is_offset': (r['is_offset'] as int? ?? 0) == 1,
'parent_uuid': r['parent_uuid'],
'offset_dist': r['offset_dist'],
'offset_bearing': r['offset_bearing'],
'device_id': r['device_id'],
'deleted_at': _toUtc(r['deleted_at'] as String?),
})
.toList();
await _client.from('ts_stakeout_points').upsert(payload);
await _db.markSyncedByUuid(
'stakeout_points', rows.map((r) => r['uuid'] as String).toList());
}
// ── PULL ─────────────────────────────────────────────────────────
Future<bool> _pull() async {
final prefs = await SharedPreferences.getInstance();
var pulledAny = false;
final projects = await _db.listProjects();
for (final project in projects.where((p) => !p.isLocalOnly)) {
final cursorKey = 'stakeout_cursor_${project.uuid}';
final cursor = prefs.getString(cursorKey) ?? '1970-01-01T00:00:00Z';
var maxSeen = cursor;
final rows = await _client
.from('ts_stakeout_points')
.select()
.eq('project_id', project.uuid)
.gt('updated_at', cursor)
.order('updated_at', ascending: true)
.limit(1000);
for (final r in rows) {
final ts = r['updated_at'] as String?;
if (ts != null && DateTime.parse(ts).isAfter(DateTime.parse(maxSeen))) {
maxSeen = ts;
}
// Az AppDatabase generikus applyRemoteRow-ja: uuid alapján
// beszúr/frissít, a lokális 'pending' sort nem írja felül
// (a push előbb futott).
await _db.applyRemoteRow('stakeout_points', {
'uuid': r['id'],
'project_id': project.id,
'line_id': r['line_id'] ?? '',
'station': r['station'],
'name': r['name'] ?? '${r['station']}',
'point_type': r['point_type'] ?? 'geofon',
'source': r['source'] ?? 'supabase',
'plan_eov_y': r['plan_eov_y'],
'plan_eov_x': r['plan_eov_x'],
'plan_eov_z': r['plan_eov_z'],
'plan_lat': r['plan_lat'],
'plan_lon': r['plan_lon'],
'status': r['status'] ?? 'pending',
'measured_eov_y': r['measured_eov_y'],
'measured_eov_x': r['measured_eov_x'],
'measured_eov_z': r['measured_eov_z'],
'measured_lat': r['measured_lat'],
'measured_lon': r['measured_lon'],
'dev_inline': r['dev_inline'],
'dev_crossline': r['dev_crossline'],
'dev_dz': r['dev_dz'],
'fix_quality': r['fix_quality'],
'accuracy': r['accuracy'],
'tilt_deg': r['tilt_deg'],
'staked_at': r['staked_at'],
'is_offset': (r['is_offset'] == true) ? 1 : 0,
'parent_uuid': r['parent_uuid'],
'offset_dist': r['offset_dist'],
'offset_bearing': r['offset_bearing'],
'created_by': r['created_by'],
'device_id': r['device_id'],
'created_at': r['created_at'] ?? DateTime.now().toIso8601String(),
'updated_at': r['updated_at'],
'deleted_at': r['deleted_at'],
'sync_status': 'synced',
});
pulledAny = true;
}
if (maxSeen != cursor) {
await prefs.setString(cursorKey, maxSeen);
}
}
return pulledAny;
}
/// A lokális, zóna nélküli időbélyeg a Postgres-ben UTC-nek számítana
/// → küldés előtt explicit UTC-re konvertálunk.
String? _toUtc(String? localIso) {
if (localIso == null || localIso.isEmpty) return null;
return DateTime.parse(localIso).toUtc().toIso8601String();
}
}