diff --git a/lib/main.dart b/lib/main.dart index 1ff96c8..374f86c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -45,6 +47,7 @@ Future 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 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()); } diff --git a/lib/models/device_info_model.dart b/lib/models/device_info_model.dart index 0249d43..ca279fd 100644 --- a/lib/models/device_info_model.dart +++ b/lib/models/device_info_model.dart @@ -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 toRegistrationMap({required String label}) => { 'device_id': deviceId, 'app_instance_id': appInstanceId, + 'platform': platform, 'label': label, 'manufacturer': manufacturer, 'model': model, diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 411f7e1..877b942 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -20,6 +20,8 @@ class AppDatabase { static final instance = AppDatabase._(); static Database? _db; + static const _uuid = Uuid(); + Future 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 _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 _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 _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 _withSyncColumns(Map map) { + final m = Map.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 insertProject(Project p) async => (await database).insert('projects', p.toMap()); - Future updateProject(Project p) async => (await database) - .update('projects', p.toMap(), where: 'id = ?', whereArgs: [p.id]); + Future updateProject(Project p) async => (await database).update( + 'projects', {...p.toMap(), 'sync_status': 'pending'}, + where: 'id = ?', whereArgs: [p.id]); Future 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> 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 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 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 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> 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 _withTrackPointColumns(Map map) { + final m = Map.from(map); + m['uuid'] ??= _uuid.v4(); + m['sync_status'] ??= 'pending'; + return m; + } + Future> 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 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 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 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 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 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 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> listMeasuredPoints(int projectId) async { diff --git a/lib/services/device_identity_service.dart b/lib/services/device_identity_service.dart index fecaa32..c8dc103 100644 --- a/lib/services/device_identity_service.dart +++ b/lib/services/device_identity_service.dart @@ -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 _registerDevice() async { - // await Supabase.instance.client + Future 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), diff --git a/lib/services/layer_sync_service.dart b/lib/services/layer_sync_service.dart new file mode 100644 index 0000000..faf8cdd --- /dev/null +++ b/lib/services/layer_sync_service.dart @@ -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 _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 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 _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 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 unpublishLayer(String layerId) async { + await _client.from('ts_layers').update({ + 'deleted_at': DateTime.now().toUtc().toIso8601String(), + }).eq('id', layerId); + } +} diff --git a/lib/services/project_service.dart b/lib/services/project_service.dart index e8c4f52..e5415df 100644 --- a/lib/services/project_service.dart +++ b/lib/services/project_service.dart @@ -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 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.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>> fetchSharedProjects() async { + final rows = await Supabase.instance.client + .from('terepi_seged_shared_projects') + .select() + .order('name', ascending: true); + return List>.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 joinSharedProject(Map 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.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 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 reloadProjects() => _loadProjects(); Future> getStats(int projectId) => diff --git a/lib/services/ts_sync_service.dart b/lib/services/ts_sync_service.dart new file mode 100644 index 0000000..eb3e9d4 --- /dev/null +++ b/lib/services/ts_sync_service.dart @@ -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(); + 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 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 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()) { + 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()) { + 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 _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.from(row)); + } + } + + // ═════════════════════════════════════════════════════════════════ + // PUSH — a 'pending' sorok feltöltése (upsert, idempotens) + // ═════════════════════════════════════════════════════════════════ + + Future _push() async { + await _pushProjects(); + await _pushMeasuredPoints(); + await _pushTracks(); + await _pushTrackPoints(); + await _pushNoteItems(); + } + + Future _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 _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 _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 _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 _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 _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 _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 = {}; + final toInsert = >[]; + 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(); + } +} diff --git a/lib/widgets/project/join_project_sheet.dart b/lib/widgets/project/join_project_sheet.dart index 8497f94..ced96e6 100644 --- a/lib/widgets/project/join_project_sheet.dart +++ b/lib/widgets/project/join_project_sheet.dart @@ -32,28 +32,25 @@ class _JoinProjectSheetState extends State { @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 _join(Map 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', diff --git a/lib/widgets/project/project_picker_view.dart b/lib/widgets/project/project_picker_view.dart index f924bb4..6a9070c 100644 --- a/lib/widgets/project/project_picker_view.dart +++ b/lib/widgets/project/project_picker_view.dart @@ -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',