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 {
|
||||
|
||||
Reference in New Issue
Block a user