Közös projekt szinkronizációja
This commit is contained in:
+236
-27
@@ -20,6 +20,8 @@ class AppDatabase {
|
||||
static final instance = AppDatabase._();
|
||||
static Database? _db;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
Future<Database> get database async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
@@ -40,7 +42,7 @@ class AppDatabase {
|
||||
final path = p.join(dbDir.path, 'terepi_seged.db');
|
||||
|
||||
return openDatabase(path,
|
||||
version: 3,
|
||||
version: 4,
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade);
|
||||
@@ -48,6 +50,10 @@ class AppDatabase {
|
||||
|
||||
Future<void> _onCreate(Database db, int _) async {
|
||||
// ── Projects ────────────────────────────────────────────────────
|
||||
// uuid: a projekt globális azonosítója. Közös projekthez csatlakozáskor
|
||||
// a Supabase-en lévő projekt uuid-jával jön létre a lokális sor, így a
|
||||
// lokális és a távoli projekt ugyanaz az entitás.
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -62,7 +68,10 @@ class AppDatabase {
|
||||
is_local_only INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_pulled_at TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
@@ -70,6 +79,7 @@ class AppDatabase {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS measured_points (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
eov_y REAL,
|
||||
@@ -81,16 +91,24 @@ class AppDatabase {
|
||||
accuracy REAL,
|
||||
fix_quality INTEGER,
|
||||
timestamp TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT,
|
||||
device_id TEXT,
|
||||
updated_at TEXT,
|
||||
deleted_at TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
''');
|
||||
await db
|
||||
.execute('CREATE INDEX idx_mp_project ON measured_points(project_id)');
|
||||
await db
|
||||
.execute('CREATE INDEX idx_mp_sync ON measured_points(sync_status)');
|
||||
|
||||
// ── Track-ek ─────────────────────────────────────────────────────
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
@@ -101,15 +119,23 @@ class AppDatabase {
|
||||
point_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_local_only INTEGER NOT NULL DEFAULT 0,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
supabase_id TEXT
|
||||
supabase_id TEXT,
|
||||
created_by TEXT,
|
||||
device_id TEXT,
|
||||
updated_at TEXT,
|
||||
deleted_at TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX idx_tracks_project ON tracks(project_id)');
|
||||
|
||||
// ── Track pontok ─────────────────────────────────────────────────
|
||||
// uuid pontonként: az upsert-alapú feltöltés így idempotens —
|
||||
// ha egy batch kétszer megy fel (pl. hálózati hiba után), nem duplikál.
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS track_points (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
@@ -117,16 +143,19 @@ class AppDatabase {
|
||||
accuracy REAL,
|
||||
speed REAL,
|
||||
heading REAL,
|
||||
timestamp TEXT NOT NULL
|
||||
timestamp TEXT NOT NULL,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_tp_track ON track_points(track_id, timestamp)');
|
||||
await db.execute('CREATE INDEX idx_tp_sync ON track_points(sync_status)');
|
||||
|
||||
// ── Terepbejárás elemek ──────────────────────────────────────────
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS note_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
points_json TEXT NOT NULL,
|
||||
@@ -136,6 +165,10 @@ class AppDatabase {
|
||||
stroke_color TEXT NOT NULL DEFAULT '#FFD700',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
created_by TEXT,
|
||||
updated_at TEXT,
|
||||
deleted_at TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending'
|
||||
)
|
||||
''');
|
||||
await db
|
||||
@@ -210,7 +243,8 @@ class AppDatabase {
|
||||
is_visible INTEGER NOT NULL DEFAULT 1,
|
||||
project_id INTEGER,
|
||||
imported_at TEXT NOT NULL,
|
||||
synced_at TEXT
|
||||
synced_at TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
@@ -221,10 +255,10 @@ class AppDatabase {
|
||||
// Alap projekt létrehozása az első indításhoz
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await db.insert('projects', {
|
||||
'uuid': const Uuid().v4(),
|
||||
'uuid': _uuid.v4(),
|
||||
'name': 'Alapértelmezett projekt',
|
||||
'is_default': 1,
|
||||
'is_local_only': 0,
|
||||
'is_local_only': 1,
|
||||
'status': 'active',
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
@@ -246,26 +280,169 @@ class AppDatabase {
|
||||
if (oldVersion < 3) {
|
||||
await _createStakeoutTable(db);
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
await _migrateToV4(db);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateToV4(Database db) async {
|
||||
// Új oszlopok. (Az SQLite ALTER TABLE nem tud UNIQUE megkötést
|
||||
// hozzáadni, ezért a uuid-egyediséget külön unique index adja
|
||||
// a backfill UTÁN.)
|
||||
await db.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT");
|
||||
await db.execute(
|
||||
"ALTER TABLE projects ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||
|
||||
await db.execute("ALTER TABLE measured_points ADD COLUMN uuid TEXT");
|
||||
await db.execute("ALTER TABLE measured_points ADD COLUMN created_by TEXT");
|
||||
await db.execute("ALTER TABLE measured_points ADD COLUMN updated_at TEXT");
|
||||
await db.execute("ALTER TABLE measured_points ADD COLUMN deleted_at TEXT");
|
||||
await db.execute(
|
||||
"ALTER TABLE measured_points ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||
// A meglévő pontoknál a mérés időpontja jó kiindulás updated_at-nek.
|
||||
await db.execute(
|
||||
"UPDATE measured_points SET updated_at = timestamp WHERE updated_at IS NULL");
|
||||
|
||||
await db.execute("ALTER TABLE tracks ADD COLUMN uuid TEXT");
|
||||
await db.execute("ALTER TABLE tracks ADD COLUMN created_by TEXT");
|
||||
await db.execute("ALTER TABLE tracks ADD COLUMN updated_at TEXT");
|
||||
await db.execute("ALTER TABLE tracks ADD COLUMN deleted_at TEXT");
|
||||
await db.execute(
|
||||
"UPDATE tracks SET updated_at = COALESCE(end_time, start_time) "
|
||||
"WHERE updated_at IS NULL");
|
||||
// Ha egy tracknek már van Supabase-azonosítója, azt örökli uuid-ként,
|
||||
// így a korábban feltöltött track ugyanaz az entitás marad.
|
||||
await db.execute(
|
||||
"UPDATE tracks SET uuid = supabase_id WHERE uuid IS NULL AND supabase_id IS NOT NULL");
|
||||
|
||||
await db.execute("ALTER TABLE track_points ADD COLUMN uuid TEXT");
|
||||
await db.execute(
|
||||
"ALTER TABLE track_points ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||
|
||||
await db.execute("ALTER TABLE note_items ADD COLUMN uuid TEXT");
|
||||
await db.execute("ALTER TABLE note_items ADD COLUMN created_by TEXT");
|
||||
await db.execute("ALTER TABLE note_items ADD COLUMN updated_at TEXT");
|
||||
await db.execute("ALTER TABLE note_items ADD COLUMN deleted_at TEXT");
|
||||
await db.execute(
|
||||
"ALTER TABLE note_items ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'");
|
||||
await db.execute(
|
||||
"UPDATE note_items SET updated_at = created_at WHERE updated_at IS NULL");
|
||||
|
||||
await db.execute('ALTER TABLE projects ADD COLUMN last_pulled_at TEXT');
|
||||
await db.execute('ALTER TABLE measured_points ADD COLUMN device_id TEXT');
|
||||
await db.execute('ALTER TABLE tracks ADD COLUMN device_id TEXT');
|
||||
await db.execute(
|
||||
'ALTER TABLE imported_layers ADD COLUMN version INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// uuid-backfill a meglévő sorokra (Dartból, mert az SQLite nem tud
|
||||
// uuid-t generálni). Batch-elve, hogy sok sor esetén is gyors legyen.
|
||||
await _backfillUuids(db, 'measured_points');
|
||||
await _backfillUuids(db, 'tracks');
|
||||
await _backfillUuids(db, 'track_points');
|
||||
await _backfillUuids(db, 'note_items');
|
||||
|
||||
// Egyediség kikényszerítése a backfill után.
|
||||
await db.execute(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_mp_uuid ON measured_points(uuid)');
|
||||
await db.execute(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_tracks_uuid ON tracks(uuid)');
|
||||
await db.execute(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_tp_uuid ON track_points(uuid)');
|
||||
await db.execute(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_uuid ON note_items(uuid)');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_mp_sync ON measured_points(sync_status)');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_tp_sync ON track_points(sync_status)');
|
||||
|
||||
// Egyszeri árva-takarítás: a foreign key-ek eddig nem érvényesültek,
|
||||
// így a korábbi törlések árva sorokat hagyhattak.
|
||||
await db.execute('''
|
||||
DELETE FROM tracks
|
||||
WHERE project_id IS NOT NULL
|
||||
AND project_id NOT IN (SELECT id FROM projects)
|
||||
''');
|
||||
await db.execute('''
|
||||
DELETE FROM track_points
|
||||
WHERE track_id NOT IN (SELECT id FROM tracks)
|
||||
''');
|
||||
await db.execute('''
|
||||
DELETE FROM measured_points
|
||||
WHERE project_id NOT IN (SELECT id FROM projects)
|
||||
''');
|
||||
await db.execute('''
|
||||
DELETE FROM note_items
|
||||
WHERE project_id IS NOT NULL
|
||||
AND project_id NOT IN (SELECT id FROM projects)
|
||||
''');
|
||||
await db.execute('''
|
||||
DELETE FROM note_item_photos
|
||||
WHERE note_item_id NOT IN (SELECT id FROM note_items)
|
||||
''');
|
||||
await db.execute('''
|
||||
DELETE FROM note_item_audios
|
||||
WHERE note_item_id NOT IN (SELECT id FROM note_items)
|
||||
''');
|
||||
}
|
||||
|
||||
/// uuid-t ad minden olyan sornak, ahol még NULL — 500-as batch-ekben.
|
||||
Future<void> _backfillUuids(Database db, String table) async {
|
||||
while (true) {
|
||||
final rows = await db.query(table,
|
||||
columns: ['id'], where: 'uuid IS NULL', limit: 500);
|
||||
if (rows.isEmpty) break;
|
||||
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.update(table, {'uuid': _uuid.v4()},
|
||||
where: 'id = ?', whereArgs: [row['id']]);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Segédfüggvények a szinkron-oszlopokhoz
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// Beszúrandó map kiegészítése a szinkron-oszlopokkal, ha hiányoznak.
|
||||
/// Így a modellek egyelőre változatlanok maradhatnak — a DB-réteg
|
||||
/// garantálja, hogy minden új sor szinkron-képes legyen.
|
||||
Map<String, dynamic> _withSyncColumns(Map<String, dynamic> map) {
|
||||
final m = Map<String, dynamic>.from(map);
|
||||
m['uuid'] ??= _uuid.v4();
|
||||
m['updated_at'] ??= DateTime.now().toIso8601String();
|
||||
m['sync_status'] ??= 'pending';
|
||||
return m;
|
||||
}
|
||||
|
||||
String get _now => DateTime.now().toIso8601String();
|
||||
|
||||
// ── Projects CRUD ─────────────────────────────────────────────────
|
||||
|
||||
Future<int> insertProject(Project p) async =>
|
||||
(await database).insert('projects', p.toMap());
|
||||
|
||||
Future<void> updateProject(Project p) async => (await database)
|
||||
.update('projects', p.toMap(), where: 'id = ?', whereArgs: [p.id]);
|
||||
Future<void> updateProject(Project p) async => (await database).update(
|
||||
'projects', {...p.toMap(), 'sync_status': 'pending'},
|
||||
where: 'id = ?', whereArgs: [p.id]);
|
||||
|
||||
Future<void> archiveProject(int id) async => (await database).update(
|
||||
'projects',
|
||||
{'status': 'archived', 'updated_at': DateTime.now().toIso8601String()},
|
||||
{
|
||||
'status': 'archived',
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
'sync_status': 'pending'
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id]);
|
||||
|
||||
Future<List<Project>> listProjects({bool includeArchived = false}) async {
|
||||
final rows = await (await database).query(
|
||||
'projects',
|
||||
where: includeArchived ? null : "status = 'active'",
|
||||
where: includeArchived
|
||||
? 'deleted_at IS NULL'
|
||||
: "status = 'active' AND deleted_at IS NULL",
|
||||
orderBy: 'updated_at DESC',
|
||||
);
|
||||
return rows.map(Project.fromMap).toList();
|
||||
@@ -304,27 +481,37 @@ class AppDatabase {
|
||||
|
||||
Future<int> insertTrack(Track track) async {
|
||||
final db = await database;
|
||||
return db.insert('tracks', track.toMap());
|
||||
final map = _withSyncColumns(track.toMap());
|
||||
map['device_id'] ??= DeviceIdentityService.to.deviceId;
|
||||
return db.insert('tracks', map);
|
||||
}
|
||||
|
||||
Future<void> updateTrack(Track track) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'tracks',
|
||||
track.toMap(),
|
||||
{...track.toMap(), 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'id = ?',
|
||||
whereArgs: [track.id],
|
||||
);
|
||||
}
|
||||
|
||||
/// Soft delete — a törlés ténye is szinkronizálódik a többi eszközre.
|
||||
/// (A track pontjai a szerveroldalon CASCADE-del törlődnek majd.)
|
||||
Future<void> deleteTrack(int id) async {
|
||||
final db = await database;
|
||||
await db.delete('tracks', where: 'id = ?', whereArgs: [id]);
|
||||
await db.update(
|
||||
'tracks',
|
||||
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Track>> listTracks() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('tracks', orderBy: 'start_time DESC');
|
||||
final rows = await db.query('tracks',
|
||||
where: 'deleted_at IS NULL', orderBy: 'start_time DESC');
|
||||
return rows.map(Track.fromMap).toList();
|
||||
}
|
||||
|
||||
@@ -347,7 +534,7 @@ class AppDatabase {
|
||||
final db = await database;
|
||||
try {
|
||||
await db.transaction((txn) async {
|
||||
await txn.insert('track_points', point.toMap());
|
||||
await txn.insert('track_points', _withTrackPointColumns(point.toMap()));
|
||||
await txn.rawUpdate('''
|
||||
UPDATE tracks
|
||||
SET distance_m = ?,
|
||||
@@ -361,6 +548,14 @@ class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// track_points: nincs updated_at (append-only), csak uuid + sync_status.
|
||||
Map<String, dynamic> _withTrackPointColumns(Map<String, dynamic> map) {
|
||||
final m = Map<String, dynamic>.from(map);
|
||||
m['uuid'] ??= _uuid.v4();
|
||||
m['sync_status'] ??= 'pending';
|
||||
return m;
|
||||
}
|
||||
|
||||
Future<List<TrackPoint>> getPoints(int trackId) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -472,7 +667,7 @@ class AppDatabase {
|
||||
/// Elem mentése - visszaadja a kapott AQLite id-t
|
||||
Future<int> insertNoteItem(NoteItem item) async {
|
||||
final db = await database;
|
||||
return db.insert('note_items', item.toMap());
|
||||
return db.insert('note_items', _withSyncColumns(item.toMap()));
|
||||
}
|
||||
|
||||
/// Elem frissítése (szín, label, koordináták módosítása után).
|
||||
@@ -480,7 +675,7 @@ class AppDatabase {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'note_items',
|
||||
item.toMap(),
|
||||
{...item.toMap(), 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'id = ?',
|
||||
whereArgs: [item.id],
|
||||
);
|
||||
@@ -489,8 +684,9 @@ class AppDatabase {
|
||||
/// Egy elem törlése.
|
||||
Future<void> deleteNoteItem(int id) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
await db.update(
|
||||
'note_items',
|
||||
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
@@ -533,12 +729,13 @@ class AppDatabase {
|
||||
return rows.isEmpty ? null : NoteItem.fromMap(rows.first);
|
||||
}
|
||||
|
||||
/// Projekt összes elemének törlése.
|
||||
/// Projekt összes elemének törlése — soft delete.
|
||||
Future<void> deleteAllNoteItems(int projectId) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
await db.update(
|
||||
'note_items',
|
||||
where: 'project_id = ?',
|
||||
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'project_id = ? AND deleted_at IS NULL',
|
||||
whereArgs: [projectId],
|
||||
);
|
||||
}
|
||||
@@ -661,18 +858,30 @@ class AppDatabase {
|
||||
|
||||
Future<int> insertMeasuredPoint(MeasuredPoint point) async {
|
||||
final db = await database;
|
||||
return db.insert('measured_points', point.toMap());
|
||||
final map = _withSyncColumns(point.toMap());
|
||||
map['device_id'] ??= DeviceIdentityService.to.deviceId;
|
||||
return db.insert('measured_points', map);
|
||||
}
|
||||
|
||||
/// Soft delete — a törlés is szinkronizálódik.
|
||||
Future<void> deleteMeasuredPoint(int id) async {
|
||||
final db = await database;
|
||||
await db.delete('measured_points', where: 'id = ?', whereArgs: [id]);
|
||||
await db.update(
|
||||
'measured_points',
|
||||
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteAllMeasuredPoints(int projectId) async {
|
||||
final db = await database;
|
||||
await db.delete('measured_points',
|
||||
where: 'project_id = ?', whereArgs: [projectId]);
|
||||
await db.update(
|
||||
'measured_points',
|
||||
{'deleted_at': _now, 'updated_at': _now, 'sync_status': 'pending'},
|
||||
where: 'project_id = ? AND deleted_at IS NULL',
|
||||
whereArgs: [projectId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<MeasuredPoint>> listMeasuredPoints(int projectId) async {
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/services/app_logger.dart';
|
||||
import 'package:terepi_seged/services/firebase_logger.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -58,8 +59,8 @@ class DeviceIdentityService extends GetxService {
|
||||
super.onReady();
|
||||
await _load();
|
||||
// Háttérben regisztrálás — nem blokkolja az UI-t
|
||||
unawaited(_registerDevice());
|
||||
_isReady = true;
|
||||
unawaited(registerDevice());
|
||||
|
||||
FirebaseLogger.setDeviceIdentifier(info.deviceId, deviceLabel.value);
|
||||
FirebaseLogger.setKey('model', model);
|
||||
@@ -84,6 +85,15 @@ class DeviceIdentityService extends GetxService {
|
||||
final storedLabel = results[2] as String?;
|
||||
final android = results[3] as AndroidDeviceInfo;
|
||||
final pkg = results[4] as PackageInfo;
|
||||
String platform = '';
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
platform = 'Android';
|
||||
} else if (Platform.isIOS) {
|
||||
platform = 'iOS';
|
||||
} else if (Platform.isMacOS) {
|
||||
platform = 'macOS';
|
||||
}
|
||||
|
||||
// ANDROID_ID — MethodChannel-en keresztül (Settings.Secure.ANDROID_ID)
|
||||
final androidId = await _getAndroidId() ?? android.fingerprint;
|
||||
@@ -92,6 +102,7 @@ class DeviceIdentityService extends GetxService {
|
||||
info = DeviceInfoModel(
|
||||
deviceId: androidId,
|
||||
appInstanceId: instanceId,
|
||||
platform: platform,
|
||||
manufacturer: android.manufacturer,
|
||||
model: android.model,
|
||||
brand: android.brand,
|
||||
@@ -106,6 +117,12 @@ class DeviceIdentityService extends GetxService {
|
||||
// Label: tárolt érték > rendszer neve
|
||||
deviceLabel.value = storedLabel ?? systemName;
|
||||
print('Device label: ${deviceLabel.value}');
|
||||
print('DeviceId: ${info.deviceId}');
|
||||
print('AppInstanceId: ${info.appInstanceId}');
|
||||
print('SystemDeviceName: ${info.systemDeviceName}');
|
||||
print('AppVersion: ${info.appVersion}');
|
||||
print('buildNumber: ${info.buildNumber}');
|
||||
print('model: ${info.model}');
|
||||
}
|
||||
|
||||
// ── SecureStorage műveletek ───────────────────────────────────────
|
||||
@@ -162,13 +179,29 @@ class DeviceIdentityService extends GetxService {
|
||||
deviceLabel.value = trimmed;
|
||||
}
|
||||
|
||||
unawaited(_registerDevice());
|
||||
unawaited(registerDevice());
|
||||
}
|
||||
|
||||
// ── Supabase regisztráció ─────────────────────────────────────────
|
||||
|
||||
Future<void> _registerDevice() async {
|
||||
// await Supabase.instance.client
|
||||
Future<void> registerDevice() async {
|
||||
final user = Supabase.instance.client.auth.currentUser;
|
||||
if (user == null || deviceId.isEmpty) return;
|
||||
|
||||
await Supabase.instance.client.from('terepi_seged_devices').upsert({
|
||||
'id': appInstanceId,
|
||||
'user_id': user.id,
|
||||
'name': info.systemDeviceName,
|
||||
'device_id': deviceId,
|
||||
'platform': info.platform,
|
||||
'model': model,
|
||||
'os_version': info.osVersion,
|
||||
'app_version': appInfo,
|
||||
'last_seen_at': DateTime.now().toUtc().toIso8601String()
|
||||
});
|
||||
|
||||
//
|
||||
// // await Supabase.instance.client
|
||||
// .from('devices')
|
||||
// .upsert(
|
||||
// info.toRegistrationMap(label: deviceLabel.value),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
import '../models/imported_layer_meta.dart';
|
||||
|
||||
/// Megosztott referencia-rétegek (pl. gázhálózat nyomvonala) kezelése.
|
||||
///
|
||||
/// Modell: a réteg egy FÁJL (GeoJSON) a Supabase Storage-ban + egy
|
||||
/// metaadat-sor a ts_layers táblában, verziószámmal.
|
||||
///
|
||||
/// * PULL: minden tagsági projekt rétegeinél a verziószámot hasonlítjuk a
|
||||
/// lokálishoz — csak akkor töltünk le, ha nőtt. A letöltött fájl a
|
||||
/// meglévő imported_layers mechanizmusba kerül, így a térkép ugyanúgy
|
||||
/// jeleníti meg, mint a kézzel importált rétegeket.
|
||||
/// * OFFLINE: a lokálisan cache-elt fájl net nélkül is ott van — a réteg
|
||||
/// mindig a legutóbb letöltött állapotában látszik.
|
||||
/// * PUBLISH: egy lokálisan importált réteg megosztása = fájl feltöltése
|
||||
/// a Storage-ba + ts_layers sor (verzió +1 újrapublikáláskor).
|
||||
class LayerSyncService extends GetxService {
|
||||
static LayerSyncService get to => Get.find();
|
||||
|
||||
static const bucket = 'ts-layers';
|
||||
|
||||
final isSyncing = false.obs;
|
||||
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
AppDatabase get _db => AppDatabase.instance;
|
||||
|
||||
/// A rétegfájlok lokális cache-könyvtára.
|
||||
Future<Directory> _layerDir() async {
|
||||
final base = await getExternalStorageDirectory() ??
|
||||
await getApplicationDocumentsDirectory();
|
||||
final dir = Directory(p.join(base.path, 'layers'));
|
||||
if (!await dir.exists()) await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// PULL — a TsSyncService hívja minden szinkron végén
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> pullAll() async {
|
||||
if (_client.auth.currentUser == null) return;
|
||||
if (isSyncing.value) return;
|
||||
isSyncing.value = true;
|
||||
|
||||
try {
|
||||
// Csak a szinkronizált (nem lokális) projektek rétegei érdekelnek.
|
||||
final projects = await _db.listProjects();
|
||||
for (final project in projects.where((pr) => !pr.isLocalOnly)) {
|
||||
await _pullProjectLayers(project.id!, project.uuid);
|
||||
}
|
||||
} finally {
|
||||
isSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullProjectLayers(
|
||||
int localProjectId, String projectUuid) async {
|
||||
final rows = await _client
|
||||
.from('ts_layers')
|
||||
.select()
|
||||
.eq('project_id', projectUuid)
|
||||
.filter('deleted_at', 'is', null);
|
||||
|
||||
for (final r in rows) {
|
||||
final id = r['id'] as String;
|
||||
final remoteVersion = (r['version'] as int?) ?? 1;
|
||||
final localVersion = await _db.getImportedLayerVersion(id);
|
||||
|
||||
// Már megvan a legfrissebb verzió → nincs teendő.
|
||||
if (localVersion >= remoteVersion) continue;
|
||||
|
||||
// Letöltés a Storage-ból, lokális cache-be.
|
||||
final storagePath = r['storage_path'] as String;
|
||||
final bytes = await _client.storage.from(bucket).download(storagePath);
|
||||
|
||||
final dir = await _layerDir();
|
||||
final localPath = p.join(dir.path, '$id.geojson');
|
||||
await File(localPath).writeAsBytes(bytes, flush: true);
|
||||
|
||||
// Metaadat upsert a meglévő imported_layers táblába — a térkép
|
||||
// rétegkezelője innentől ugyanúgy látja, mint a kézi importot.
|
||||
await _db.upsertImportedLayerRaw({
|
||||
'id': id,
|
||||
'name': r['name'],
|
||||
'source_type': r['source_type'] ?? 'geojson',
|
||||
'local_path': localPath,
|
||||
'storage_path': storagePath,
|
||||
'is_visible': 1,
|
||||
'project_id': localProjectId,
|
||||
'imported_at': r['created_at'] ?? DateTime.now().toIso8601String(),
|
||||
'synced_at': DateTime.now().toIso8601String(),
|
||||
'version': remoteVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// PUBLISH — lokális réteg megosztása a projekt tagjaival
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Feltölti a réteg fájlját és létrehozza/frissíti a ts_layers sorát.
|
||||
/// Újrapublikáláskor a verziószám nő → a többi eszköz a következő
|
||||
/// szinkronnál automatikusan frissíti a példányát.
|
||||
Future<void> publishLayer({
|
||||
required ImportedLayerMeta layer,
|
||||
required String projectUuid,
|
||||
}) async {
|
||||
final id = layer.id.isNotEmpty ? layer.id : const Uuid().v4();
|
||||
final fileName = p.basename(layer.localPath);
|
||||
final storagePath = '$projectUuid/$id/$fileName';
|
||||
|
||||
// 1. Fájl a Storage-ba (upsert: újrapublikálásnál felülírja).
|
||||
await _client.storage.from(bucket).upload(
|
||||
storagePath,
|
||||
File(layer.localPath),
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
|
||||
// 2. Verzió léptetése, ha már létezik a sor.
|
||||
final existing = await _client
|
||||
.from('ts_layers')
|
||||
.select('version')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
final newVersion = ((existing?['version'] as int?) ?? 0) + 1;
|
||||
|
||||
await _client.from('ts_layers').upsert({
|
||||
'id': id,
|
||||
'project_id': projectUuid,
|
||||
'name': layer.name,
|
||||
'source_type': layer.sourceType.name,
|
||||
'storage_path': storagePath,
|
||||
'version': newVersion,
|
||||
});
|
||||
|
||||
// 3. Lokális meta frissítése, hogy a saját eszköz ne töltse le újra.
|
||||
await _db.upsertImportedLayerRaw({
|
||||
...layer.toMap(),
|
||||
'id': id,
|
||||
'storage_path': storagePath,
|
||||
'synced_at': DateTime.now().toIso8601String(),
|
||||
'version': newVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/// Réteg visszavonása a megosztásból (soft delete a szerveren;
|
||||
/// a lokális cache-példányok megmaradnak az eszközökön).
|
||||
Future<void> unpublishLayer(String layerId) async {
|
||||
await _client.from('ts_layers').update({
|
||||
'deleted_at': DateTime.now().toUtc().toIso8601String(),
|
||||
}).eq('id', layerId);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/project.dart';
|
||||
import 'app_database.dart';
|
||||
import 'ts_sync_service.dart';
|
||||
|
||||
class ProjectService extends GetxService {
|
||||
static ProjectService get to => Get.find();
|
||||
@@ -76,7 +77,13 @@ class ProjectService extends GetxService {
|
||||
return await AppDatabase.instance.getProject(id) ?? saved;
|
||||
}
|
||||
|
||||
/// Online projekt — szinkronizál Supabase-szel
|
||||
/// Közös (szinkronizált) projekt.
|
||||
///
|
||||
/// OFFLINE-FIRST: csak lokálisan jön létre (sync_status = 'pending'),
|
||||
/// a feltöltést a TsSyncService végzi, amint van hálózat — így a
|
||||
/// projekt-létrehozás net nélkül is működik, és nem ír többé
|
||||
/// közvetlenül a régi Supabase-táblába.
|
||||
|
||||
Future<Project> createOnlineProject({
|
||||
required String name,
|
||||
String client = '',
|
||||
@@ -97,12 +104,18 @@ class ProjectService extends GetxService {
|
||||
// Lokálisan mentjük
|
||||
final id = await AppDatabase.instance.insertProject(project);
|
||||
|
||||
// Supabase-be is
|
||||
await Supabase.instance.client
|
||||
.from('TerepiSeged_Projects')
|
||||
.insert(project.toMap());
|
||||
// // Supabase-be is
|
||||
// await Supabase.instance.client
|
||||
// .from('TerepiSeged_Projects')
|
||||
// .insert(project.toMap());
|
||||
|
||||
await _loadProjects();
|
||||
|
||||
// Ha van net, azonnal fel is megy (és owner-tagság is létrejön).
|
||||
if (Get.isRegistered<TsSyncService>()) {
|
||||
TsSyncService.to.syncNow();
|
||||
}
|
||||
|
||||
return await AppDatabase.instance.getProject(id) ?? project;
|
||||
}
|
||||
|
||||
@@ -130,6 +143,64 @@ class ProjectService extends GetxService {
|
||||
return await AppDatabase.instance.getProject(id) ?? project;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Közös projektek — listázás és csatlakozás
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A Supabase-en elérhető közös projektek listája
|
||||
/// (név, tulajdonos, tagszám, is_member) — a ts_shared_projects view-ból.
|
||||
Future<List<Map<String, dynamic>>> fetchSharedProjects() async {
|
||||
final rows = await Supabase.instance.client
|
||||
.from('terepi_seged_shared_projects')
|
||||
.select()
|
||||
.order('name', ascending: true);
|
||||
return List<Map<String, dynamic>>.from(rows);
|
||||
}
|
||||
|
||||
/// Csatlakozás egy közös projekthez (egyszerűsített modell):
|
||||
/// 1. tagság-sor beszúrása (az RLS csak saját magunkat engedi felvenni),
|
||||
/// 2. a projekt lokális sora létrejön a TÁVOLI uuid-dal — innentől a
|
||||
/// szinkron ugyanúgy kezeli, mint a sajátokat,
|
||||
/// 3. azonnali szinkron, hogy a projekt eddigi adatai lejöjjenek.
|
||||
Future<Project> joinSharedProject(Map<String, dynamic> sharedRow) async {
|
||||
final client = Supabase.instance.client;
|
||||
final projectUuid = sharedRow['id'] as String;
|
||||
|
||||
// 1. Tagság (idempotens: ha már tag, nem hiba).
|
||||
await client.from('terepi_seged_project_members').upsert(
|
||||
{
|
||||
'project_id': projectUuid,
|
||||
'user_id': client.auth.currentUser!.id,
|
||||
'role': 'editor',
|
||||
},
|
||||
ignoreDuplicates: true,
|
||||
);
|
||||
|
||||
// 2. Lokális projekt-sor a távoli uuid-dal.
|
||||
final localId =
|
||||
await AppDatabase.instance.upsertProjectFromRemote(sharedRow);
|
||||
await _loadProjects();
|
||||
|
||||
// 3. Adatok letöltése háttérben.
|
||||
if (Get.isRegistered<TsSyncService>()) {
|
||||
TsSyncService.to.syncNow();
|
||||
}
|
||||
|
||||
return (await AppDatabase.instance.getProject(localId))!;
|
||||
}
|
||||
|
||||
/// Kilépés egy közös projektből: a tagság törlődik a szerveren,
|
||||
/// a lokális adat megmarad (archiválható külön).
|
||||
Future<void> leaveSharedProject(Project project) async {
|
||||
final client = Supabase.instance.client;
|
||||
await client
|
||||
.from('terepi_seged_project_members')
|
||||
.delete()
|
||||
.eq('project_id', project.uuid)
|
||||
.eq('user_id', client.auth.currentUser!.id);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
Future<void> reloadProjects() => _loadProjects();
|
||||
|
||||
Future<Map<String, int>> getStats(int projectId) =>
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/services/device_identity_service.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
import 'layer_sync_service.dart';
|
||||
|
||||
/// Outbox-alapú, kétirányú szinkron a ts_* Supabase-táblákkal.
|
||||
///
|
||||
/// Elvek:
|
||||
/// * OFFLINE-FIRST: a UI mindig a lokális DB-ből dolgozik, a szinkron
|
||||
/// háttérfolyamat. Net nélkül minden működik, a változások sorban állnak.
|
||||
/// * A "csak lokális" (is_local_only = 1) projektek és adataik SOHA nem
|
||||
/// kerülnek feltöltésre — a kizárást az AppDatabase pending-lekérdezései
|
||||
/// végzik SQL-szinten, így nem múlhat hívási fegyelmen.
|
||||
/// * PUSH → PULL sorrend: előbb a saját 'pending' sorok mennek fel
|
||||
/// (uuid-ra upsert, ezért idempotens — ismételt feltöltés nem duplikál),
|
||||
/// utána jön le a többiek munkája (updated_at > kurzor).
|
||||
/// * LWW (last-write-wins) az updated_at alapján, amit a SZERVER állít.
|
||||
///
|
||||
/// Triggerek: hálózat visszatérése (connectivity_plus), periodikus időzítő,
|
||||
/// és kézi syncNow() (pl. pull-to-refresh, csatlakozás után).
|
||||
class TsSyncService extends GetxService {
|
||||
static TsSyncService get to => Get.find();
|
||||
|
||||
final isSyncing = false.obs;
|
||||
final lastSyncedAt = Rxn<DateTime>();
|
||||
final lastError = ''.obs;
|
||||
final pendingCount = 0.obs;
|
||||
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
AppDatabase get _db => AppDatabase.instance;
|
||||
|
||||
StreamSubscription? _connSub;
|
||||
Timer? _timer;
|
||||
bool _syncRequestedWhileRunning = false;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
||||
// Hálózat visszatérésekor azonnali szinkron.
|
||||
_connSub = Connectivity().onConnectivityChanged.listen((results) {
|
||||
final online = results.any((r) => r != ConnectivityResult.none);
|
||||
if (online) syncNow();
|
||||
});
|
||||
|
||||
// Biztonsági háló: 3 percenként akkor is, ha nem volt esemény.
|
||||
_timer = Timer.periodic(const Duration(minutes: 3), (_) => syncNow());
|
||||
|
||||
refreshPendingCount();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_connSub?.cancel();
|
||||
_timer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> refreshPendingCount() async {
|
||||
pendingCount.value = await _db.pendingSyncCount();
|
||||
}
|
||||
|
||||
/// Teljes szinkron-ciklus. Újrahívás futás közben nem indít párhuzamos
|
||||
/// ciklust, csak megjegyzi, hogy a végén még egyszer le kell futnia.
|
||||
Future<void> syncNow() async {
|
||||
if (_client.auth.currentUser == null) return;
|
||||
|
||||
if (isSyncing.value) {
|
||||
_syncRequestedWhileRunning = true;
|
||||
return;
|
||||
}
|
||||
isSyncing.value = true;
|
||||
lastError.value = '';
|
||||
|
||||
try {
|
||||
// Eszköz-regiszter frissítése (last_seen_at).
|
||||
if (Get.isRegistered<DeviceIdentityService>()) {
|
||||
await DeviceIdentityService.to.registerDevice();
|
||||
}
|
||||
|
||||
await _discoverMemberProjects();
|
||||
await _push();
|
||||
await _pull();
|
||||
|
||||
// Megosztott rétegek (5. lépés) — ha a service be van kötve.
|
||||
if (Get.isRegistered<LayerSyncService>()) {
|
||||
await LayerSyncService.to.pullAll();
|
||||
}
|
||||
|
||||
lastSyncedAt.value = DateTime.now();
|
||||
} catch (e) {
|
||||
lastError.value = e.toString();
|
||||
} finally {
|
||||
await refreshPendingCount();
|
||||
isSyncing.value = false;
|
||||
if (_syncRequestedWhileRunning) {
|
||||
_syncRequestedWhileRunning = false;
|
||||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Tagság-felderítés
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Ha a felhasználó egy MÁSIK eszközén csatlakozott egy közös
|
||||
/// projekthez, ez hozza létre a lokális projekt-sort itt is — így a
|
||||
/// több-eszközös használat magától konzisztens marad.
|
||||
Future<void> _discoverMemberProjects() async {
|
||||
final rows = await _client
|
||||
.from('terepi_seged_shared_projects')
|
||||
.select()
|
||||
.eq('is_member', true);
|
||||
|
||||
for (final row in rows) {
|
||||
await _db.upsertProjectFromRemote(Map<String, dynamic>.from(row));
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// PUSH — a 'pending' sorok feltöltése (upsert, idempotens)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> _push() async {
|
||||
await _pushProjects();
|
||||
await _pushMeasuredPoints();
|
||||
await _pushTracks();
|
||||
await _pushTrackPoints();
|
||||
await _pushNoteItems();
|
||||
}
|
||||
|
||||
Future<void> _pushProjects() async {
|
||||
final rows = await _db.pendingProjects();
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final payload = rows
|
||||
.map((r) => {
|
||||
'id': r['uuid'],
|
||||
'name': r['name'],
|
||||
'client': r['client'],
|
||||
'description': r['description'],
|
||||
'crs': r['crs'],
|
||||
'color': r['color'],
|
||||
'status': r['status'],
|
||||
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||
})
|
||||
.toList();
|
||||
|
||||
await _client.from('terepi_seged_projects').upsert(payload);
|
||||
await _db.markSyncedByUuid(
|
||||
'projects', rows.map((r) => r['uuid'] as String).toList());
|
||||
}
|
||||
|
||||
Future<void> _pushMeasuredPoints() async {
|
||||
final rows = await _db.pendingMeasuredPoints();
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final payload = rows
|
||||
.map((r) => {
|
||||
'id': r['uuid'],
|
||||
'project_id': r['project_uuid'],
|
||||
'name': r['name'],
|
||||
'eov_y': r['eov_y'],
|
||||
'eov_x': r['eov_x'],
|
||||
'eov_z': r['eov_z'],
|
||||
'latitude': r['latitude'],
|
||||
'longitude': r['longitude'],
|
||||
'altitude': r['altitude'],
|
||||
'accuracy': r['accuracy'],
|
||||
'fix_quality': r['fix_quality'],
|
||||
'measured_at': _toUtc(r['timestamp'] as String?),
|
||||
'note': r['note'],
|
||||
'device_id': r['device_id'],
|
||||
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||
})
|
||||
.toList();
|
||||
|
||||
await _client.from('terepi_seged_measured_points').upsert(payload);
|
||||
await _db.markSyncedByUuid(
|
||||
'measured_points', rows.map((r) => r['uuid'] as String).toList());
|
||||
}
|
||||
|
||||
Future<void> _pushTracks() async {
|
||||
final rows = await _db.pendingTracks();
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final payload = rows
|
||||
.map((r) => {
|
||||
'id': r['uuid'],
|
||||
'project_id': r['project_uuid'],
|
||||
'name': r['name'],
|
||||
'start_time': _toUtc(r['start_time'] as String?),
|
||||
'end_time': _toUtc(r['end_time'] as String?),
|
||||
'status': r['status'],
|
||||
'source': r['source'],
|
||||
'distance_m': r['distance_m'],
|
||||
'point_count': r['point_count'],
|
||||
'device_id': r['device_id'],
|
||||
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||
})
|
||||
.toList();
|
||||
|
||||
await _client.from('terepi_seged_tracks').upsert(payload);
|
||||
await _db.markSyncedByUuid(
|
||||
'tracks', rows.map((r) => r['uuid'] as String).toList());
|
||||
}
|
||||
|
||||
/// Track-pontok: nagy mennyiség lehet, ezért 500-as batch-ekben, ciklusban,
|
||||
/// amíg el nem fogy. A szerveren nincs UPDATE policy (append-only), ezért
|
||||
/// ignoreDuplicates — az ismételt feltöltés csendben kimarad.
|
||||
Future<void> _pushTrackPoints() async {
|
||||
while (true) {
|
||||
final rows = await _db.pendingTrackPoints(limit: 500);
|
||||
if (rows.isEmpty) break;
|
||||
|
||||
final payload = rows
|
||||
.map((r) => {
|
||||
'id': r['uuid'],
|
||||
'track_id': r['track_uuid'],
|
||||
'project_id': r['project_uuid'],
|
||||
'latitude': r['latitude'],
|
||||
'longitude': r['longitude'],
|
||||
'altitude': r['altitude'],
|
||||
'accuracy': r['accuracy'],
|
||||
'speed': r['speed'],
|
||||
'heading': r['heading'],
|
||||
'recorded_at': _toUtc(r['timestamp'] as String?),
|
||||
})
|
||||
.toList();
|
||||
|
||||
await _client
|
||||
.from('terepi_seged_track_points')
|
||||
.upsert(payload, ignoreDuplicates: true);
|
||||
await _db.markSyncedByUuid(
|
||||
'track_points', rows.map((r) => r['uuid'] as String).toList());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pushNoteItems() async {
|
||||
final rows = await _db.pendingNoteItems();
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final payload = rows
|
||||
.map((r) => {
|
||||
'id': r['uuid'],
|
||||
'project_id': r['project_uuid'],
|
||||
'type': r['type'],
|
||||
'points_json': jsonDecode(r['points_json'] as String),
|
||||
'color': r['color'],
|
||||
'opacity': r['opacity'],
|
||||
'stroke_width': r['stroke_width'],
|
||||
'stroke_color': r['stroke_color'],
|
||||
'label': r['label'],
|
||||
'deleted_at': _toUtc(r['deleted_at'] as String?),
|
||||
})
|
||||
.toList();
|
||||
|
||||
await _client.from('terepi_seged_note_items').upsert(payload);
|
||||
await _db.markSyncedByUuid(
|
||||
'note_items', rows.map((r) => r['uuid'] as String).toList());
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// PULL — inkrementális letöltés projektenként (updated_at > kurzor)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> _pull() async {
|
||||
// Minden szinkronizált (nem lokális) projekt.
|
||||
final projects = await AppDatabase.instance.listProjects();
|
||||
for (final p in projects.where((p) => !p.isLocalOnly)) {
|
||||
await _pullProject(p.id!, p.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullProject(int localId, String projectUuid) async {
|
||||
final cursor =
|
||||
await _db.getProjectCursor(localId) ?? '1970-01-01T00:00:00Z';
|
||||
String maxSeen = cursor;
|
||||
|
||||
String track(String? ts) {
|
||||
if (ts != null && DateTime.parse(ts).isAfter(DateTime.parse(maxSeen))) {
|
||||
maxSeen = ts;
|
||||
}
|
||||
return ts ?? '';
|
||||
}
|
||||
|
||||
// ── Bemért pontok ───────────────────────────────────────────────
|
||||
final mps = await _client
|
||||
.from('terepi_seged_measured_points')
|
||||
.select()
|
||||
.eq('project_id', projectUuid)
|
||||
.gt('updated_at', cursor)
|
||||
.order('updated_at', ascending: true)
|
||||
.limit(1000);
|
||||
|
||||
for (final r in mps) {
|
||||
track(r['updated_at'] as String?);
|
||||
await _db.applyRemoteRow('measured_points', {
|
||||
'uuid': r['id'],
|
||||
'project_id': localId,
|
||||
'name': r['name'],
|
||||
'eov_y': r['eov_y'],
|
||||
'eov_x': r['eov_x'],
|
||||
'eov_z': r['eov_z'],
|
||||
'latitude': r['latitude'],
|
||||
'longitude': r['longitude'],
|
||||
'altitude': r['altitude'],
|
||||
'accuracy': r['accuracy'],
|
||||
'fix_quality': r['fix_quality'],
|
||||
'timestamp': r['measured_at'],
|
||||
'note': r['note'] ?? '',
|
||||
'created_by': r['created_by'],
|
||||
'device_id': r['device_id'],
|
||||
'updated_at': r['updated_at'],
|
||||
'deleted_at': r['deleted_at'],
|
||||
'sync_status': 'synced',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Trackek ─────────────────────────────────────────────────────
|
||||
final tracks = await _client
|
||||
.from('terepi_seged_tracks')
|
||||
.select()
|
||||
.eq('project_id', projectUuid)
|
||||
.gt('updated_at', cursor)
|
||||
.order('updated_at', ascending: true)
|
||||
.limit(1000);
|
||||
|
||||
for (final r in tracks) {
|
||||
track(r['updated_at'] as String?);
|
||||
await _db.applyRemoteRow('tracks', {
|
||||
'uuid': r['id'],
|
||||
'project_id': localId,
|
||||
'name': r['name'],
|
||||
'start_time': r['start_time'],
|
||||
'end_time': r['end_time'],
|
||||
'status': r['status'],
|
||||
'source': r['source'] ?? 'Telefon GPS',
|
||||
'distance_m': r['distance_m'] ?? 0,
|
||||
'point_count': r['point_count'] ?? 0,
|
||||
'is_local_only': 0,
|
||||
'created_by': r['created_by'],
|
||||
'device_id': r['device_id'],
|
||||
'updated_at': r['updated_at'],
|
||||
'deleted_at': r['deleted_at'],
|
||||
'sync_status': 'synced',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Track-pontok (append-only; created_at a szűrő) ─────────────
|
||||
final tps = await _client
|
||||
.from('terepi_seged_track_points')
|
||||
.select()
|
||||
.eq('project_id', projectUuid)
|
||||
.gt('created_at', cursor)
|
||||
.order('created_at', ascending: true)
|
||||
.limit(2000);
|
||||
|
||||
// Távoli track-uuid → lokális track-id feloldás.
|
||||
final trackIds = <String, int?>{};
|
||||
final toInsert = <Map<String, dynamic>>[];
|
||||
for (final r in tps) {
|
||||
track(r['created_at'] as String?);
|
||||
final tUuid = r['track_id'] as String;
|
||||
trackIds[tUuid] ??= await _db.getTrackIdByUuid(tUuid);
|
||||
final localTrackId = trackIds[tUuid];
|
||||
if (localTrackId == null) continue; // a track a következő körben jön
|
||||
|
||||
toInsert.add({
|
||||
'uuid': r['id'],
|
||||
'track_id': localTrackId,
|
||||
'latitude': r['latitude'],
|
||||
'longitude': r['longitude'],
|
||||
'altitude': r['altitude'],
|
||||
'accuracy': r['accuracy'],
|
||||
'speed': r['speed'],
|
||||
'heading': r['heading'],
|
||||
'timestamp': r['recorded_at'],
|
||||
'sync_status': 'synced',
|
||||
});
|
||||
}
|
||||
await _db.insertRemoteTrackPoints(toInsert);
|
||||
|
||||
// ── Terepbejárás-elemek ────────────────────────────────────────
|
||||
final notes = await _client
|
||||
.from('terepi_seged_note_items')
|
||||
.select()
|
||||
.eq('project_id', projectUuid)
|
||||
.gt('updated_at', cursor)
|
||||
.order('updated_at', ascending: true)
|
||||
.limit(1000);
|
||||
|
||||
for (final r in notes) {
|
||||
track(r['updated_at'] as String?);
|
||||
await _db.applyRemoteRow('note_items', {
|
||||
'uuid': r['id'],
|
||||
'project_id': localId,
|
||||
'type': r['type'],
|
||||
'points_json': jsonEncode(r['points_json']),
|
||||
'color': r['color'],
|
||||
'opacity': r['opacity'],
|
||||
'stroke_width': r['stroke_width'],
|
||||
'stroke_color': r['stroke_color'],
|
||||
'label': r['label'] ?? '',
|
||||
'created_at': r['created_at'],
|
||||
'created_by': r['created_by'],
|
||||
'updated_at': r['updated_at'],
|
||||
'deleted_at': r['deleted_at'],
|
||||
'sync_status': 'synced',
|
||||
});
|
||||
}
|
||||
|
||||
if (maxSeen != cursor) {
|
||||
await _db.setProjectCursor(localId, maxSeen);
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A lokális időbélyegek zóna nélküliek (DateTime.now().toIso8601String()),
|
||||
/// a Postgres timestamptz viszont zóna nélküli értéket UTC-nek értelmezne
|
||||
/// → küldés előtt explicit UTC-re konvertálunk, különben 1-2 órát csúszna
|
||||
/// minden időpont.
|
||||
String? _toUtc(String? localIso) {
|
||||
if (localIso == null || localIso.isEmpty) return null;
|
||||
return DateTime.parse(localIso).toUtc().toIso8601String();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user