Közös projekt szinkronizációja

This commit is contained in:
2026-07-07 02:21:08 +02:00
parent 2a2ce4bb92
commit ecb4892bc8
9 changed files with 969 additions and 48 deletions
+5 -1
View File
@@ -17,12 +17,14 @@ import 'package:terepi_seged/services/firebase_logger.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/layer_import_service.dart';
import 'package:terepi_seged/services/layer_sync_service.dart';
import 'package:terepi_seged/services/note_audio_service.dart';
import 'package:terepi_seged/services/note_photo_service.dart';
import 'package:terepi_seged/services/ntrip_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart';
import 'package:terepi_seged/services/track_sync_service.dart';
import 'package:terepi_seged/services/ts_sync_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -45,6 +47,7 @@ Future<void> main() async {
anonKey: dotenv.env['SUPABASE_ANON_KEY']!);
Get.put(AuthService());
Get.put(DeviceIdentityService(), permanent: true);
await AppDatabase.instance.database;
Get.put(ProjectService(), permanent: true);
@@ -61,8 +64,9 @@ Future<void> main() async {
Get.put(NotePhotoService(), permanent: true);
Get.put(NoteAudioService(), permanent: true);
Get.put(LayerImportService(), permanent: true);
Get.put(DeviceIdentityService(), permanent: true);
Get.put(TrackSyncService(), permanent: true);
Get.put(LayerSyncService());
Get.put(TsSyncService());
runApp(const MyApp());
}
+5
View File
@@ -12,6 +12,9 @@ class DeviceInfoModel {
/// Reinstall után új értéket kap.
final String appInstanceId;
// Android vagy IOS
final String platform;
// ── Hardver ───────────────────────────────────────────────────────
final String manufacturer; // "Samsung"
@@ -36,6 +39,7 @@ class DeviceInfoModel {
const DeviceInfoModel({
required this.deviceId,
required this.appInstanceId,
required this.platform,
required this.manufacturer,
required this.model,
required this.brand,
@@ -52,6 +56,7 @@ class DeviceInfoModel {
Map<String, dynamic> toRegistrationMap({required String label}) => {
'device_id': deviceId,
'app_instance_id': appInstanceId,
'platform': platform,
'label': label,
'manufacturer': manufacturer,
'model': model,
+236 -27
View File
@@ -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 {
+37 -4
View File
@@ -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),
+161
View File
@@ -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);
}
}
+76 -5
View File
@@ -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) =>
+435
View File
@@ -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();
}
}
+8 -11
View File
@@ -32,28 +32,25 @@ class _JoinProjectSheetState extends State<JoinProjectSheet> {
@override
void initState() {
super.initState();
// TODO
// _future = ProjectService.to.fetchSharedProjects();
_future = ProjectService.to.fetchSharedProjects();
}
void _reload() {
setState(() {
// TODO
//_future = ProjectService.to.fetchSharedProjects();
_future = ProjectService.to.fetchSharedProjects();
});
}
Future<void> _join(Map<String, dynamic> row) async {
setState(() => _joiningId = row['id'] as String);
try {
// TODO
//final project = await ProjectService.to.joinSharedProject(row);
final project = await ProjectService.to.joinSharedProject(row);
Get.back();
// Get.snackbar(
// 'Csatlakozva',
// '„${project.name}" — az adatok letöltése a háttérben fut.',
// snackPosition: SnackPosition.BOTTOM,
// );
Get.snackbar(
'Csatlakozva',
'${project.name}" — az adatok letöltése a háttérben fut.',
snackPosition: SnackPosition.BOTTOM,
);
} catch (e) {
Get.snackbar(
'Hiba a csatlakozáskor',
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:terepi_seged/widgets/project/join_project_sheet.dart';
import '../../models/project.dart';
import '../../services/project_service.dart';
@@ -24,6 +25,11 @@ class ProjectPickerView extends StatelessWidget {
appBar: AppBar(
title: const Text('Projekt választó'),
actions: [
IconButton(
icon: const Icon(Icons.group_outlined),
tooltip: 'Csatlakozás közös projekthez',
onPressed: () => JoinProjectSheet.show(),
),
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Új projekt',