import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:uuid/uuid.dart'; import '../models/project.dart'; import 'app_database.dart'; import 'ts_sync_service.dart'; class ProjectRequiresLoginException implements Exception { final String message; ProjectRequiresLoginException( [this.message = 'Felhős projekt használatához be kell jelentkezni.']); @override String toString() => message; } class ProjectArchiveBlockedException implements Exception { final String message; ProjectArchiveBlockedException(this.message); @override String toString() => message; } class ProjectNoLongerExistsException implements Exception { final String message; ProjectNoLongerExistsException([ this.message = 'Ez a projekt már nem érhető el — törölték, vagy ' 'kikerültél a tagságából.', ]); @override String toString() => message; } class ProjectService extends GetxService { static ProjectService get to => Get.find(); final activeProject = Rxn(); final projects = [].obs; int? get activeProjectId => activeProject.value?.id; @override Future onInit() async { super.onInit(); await _loadProjects(); await _restoreActiveProject(); Supabase.instance.client.auth.onAuthStateChange.listen((data) { final loggedOut = data.session == null; final active = activeProject.value; if (loggedOut && active != null && !active.isLocalOnly) { final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly); if (fallback != null) { setActiveProject(fallback); } else { activeProject.value = null; } } }); } Future _loadProjects() async { projects.value = await AppDatabase.instance.listProjects(); } // Az utoljára aktív projektet tárolja a SharedPreferences Future _restoreActiveProject() async { final prefs = await SharedPreferences.getInstance(); final savedId = prefs.getInt('active_project_id'); if (savedId != null) { final project = await AppDatabase.instance.getProject(savedId); if (project != null && project.status == ProjectStatus.active) { activeProject.value = project; return; } } // Fallback: elsőként lokális projektet próbálunk (mindig biztonságos), // csak ha nincs, esünk vissza bármelyikre — és a hibát itt is elkapjuk, // hogy egy bejelentkezés-igénylő projekt ne akassza meg az indulást. final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ?? (projects.isNotEmpty ? projects.first : null); if (fallback != null) { try { await setActiveProject(fallback); } catch (_) { // Nincs aktiválható projekt most (pl. csak felhős van, bejelentkezés // nélkül) — activeProject marad null, a UI ezt már kezeli. } } } Future setActiveProject(Project project) async { if (!project.isLocalOnly && Supabase.instance.client.auth.currentUser == null) { AppLogger.event('project_activate_blocked_no_login', '', { 'device_id': DeviceIdentityService.to.deviceId, 'device_name': DeviceIdentityService.to.deviceLabel.value, }); throw ProjectRequiresLoginException(); } if (!project.isLocalOnly) { // Az élő ellenőrzés CSAK olyan projektnél fusson, ami már egyszer // BIZONYÍTOTTAN felkerült a szerverre (sync_status == 'synced'). // Egy ÉPP MOST létrehozott, még feltöltés alatt álló projektnél a // szerveren természetesen még nem létezik a sor — ez NEM törlést // jelent, csak azt, hogy a háttér-feltöltés még fut/nem futott le, // és tévesen "törölve" hibát adna, ha itt is lefutna a check. final syncStatus = await AppDatabase.instance.getProjectSyncStatus(project.id!); if (syncStatus == 'synced') { try { final row = await Supabase.instance.client .from('terepi_seged_projects') .select('deleted_at') .eq('id', project.uuid) .maybeSingle() .timeout(const Duration(seconds: 6)); if (row == null || row['deleted_at'] != null) { AppLogger.event('project_activate_blocked_deleted', '', { 'project_id': project.id, 'device_id': DeviceIdentityService.to.deviceId, 'device_name': DeviceIdentityService.to.deviceLabel.value, 'user_id': Supabase.instance.client.auth.currentUser?.id, }); throw ProjectNoLongerExistsException(); } } on ProjectNoLongerExistsException { rethrow; } catch (_) { // Hálózati hiba/időtúllépés → NEM blokkolunk emiatt (fail-open). } } } // Helyi (SQLite) meglét ellenőrzése — ne aktiváljunk egy már a // háttér-reconcile által helyileg törölt projektet sem. final stillExists = await AppDatabase.instance.getProject(project.id!); if (stillExists == null) { await _loadProjects(); // a lista is frissüljön, ne maradjon árva AppLogger.event('project_activate_blocked_gone', '', { 'project_id': project.id, 'device_id': DeviceIdentityService.to.deviceId, 'device_name': DeviceIdentityService.to.deviceLabel.value, 'user_id': Supabase.instance.client.auth.currentUser?.id, }); throw ProjectNoLongerExistsException(); } activeProject.value = project; final prefs = await SharedPreferences.getInstance(); await prefs.setInt('active_project_id', project.id!); // Csak lokális projektnél frissítjük az updated_at-et (lista-sorrendhez) // — felhősnél ez feleslegesen szinkron-jelet váltana ki, ütközési // kockázattal egy másik eszköz közbeni, valódi módosításával szemben. if (project.isLocalOnly) { await AppDatabase.instance.updateProject(project.copyWith()); } await _loadProjects(); } Future createProject({ required String name, String client = '', String description = '', ProjectCrs crs = ProjectCrs.eov, String color = '#185FA5', }) async { final now = DateTime.now(); final project = Project( uuid: const Uuid().v4(), name: name, client: client, description: description, crs: crs, color: color, createdAt: now, updatedAt: now, ); final id = await AppDatabase.instance.insertProject(project); final saved = project.copyWith(); await _loadProjects(); return await AppDatabase.instance.getProject(id) ?? saved; } /// 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 = '', ProjectCrs crs = ProjectCrs.eov, String color = '#185FA5', }) async { final project = Project( uuid: const Uuid().v4(), name: name, client: client, crs: crs, color: color, isLocalOnly: false, // ← szinkronizált createdAt: DateTime.now(), updatedAt: DateTime.now(), ); // Lokálisan mentjük final id = await AppDatabase.instance.insertProject(project); await _loadProjects(); // Ha van net, azonnal fel is megy (és owner-tagság is létrejön). if (Get.isRegistered()) { TsSyncService.to.syncNow(); } AppLogger.event('project_created_online', project.uuid, {'name': name, 'client': client}); return await AppDatabase.instance.getProject(id) ?? project; } /// Lokális projekt — soha nem kerül Supabase-be Future createLocalProject({ required String name, String client = '', ProjectCrs crs = ProjectCrs.eov, String color = '#185FA5', }) async { final project = Project( uuid: const Uuid().v4(), name: name, client: client, crs: crs, color: color, isLocalOnly: true, // ← csak lokális createdAt: DateTime.now(), updatedAt: DateTime.now(), ); // Csak lokálisan final id = await AppDatabase.instance.insertProject(project); await _loadProjects(); AppLogger.event('project_created_local', project.uuid, {'name': name, 'client': client}); 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 user = client.auth.currentUser; if (user == null) { AppLogger.event('project_join_blocked_no_login'); throw ProjectRequiresLoginException( 'Közös projekthez csatlakozáshoz be kell jelentkezni.'); } final projectUuid = sharedRow['id'] as String; try { await client.from('terepi_seged_project_members').upsert( { 'project_id': projectUuid, 'user_id': user.id, 'role': 'editor', }, ignoreDuplicates: true, ); } catch (e) { AppLogger.e('ProjectService.joinSharedProject', 'Tagság-beszúrás hiba (project=$projectUuid): $e'); rethrow; } final localId = await AppDatabase.instance.upsertProjectFromRemote(sharedRow); await _loadProjects(); AppLogger.event( 'project_joined', projectUuid, {'user_id': user.id, 'role': 'editor'}); 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; final user = client.auth.currentUser; if (user == null) return; try { await client .from('terepi_seged_project_members') .delete() .eq('project_id', project.uuid) .eq('user_id', user.id); AppLogger.event( 'shared_project_left', project.uuid, {'user_id': user.id}); } catch (e) { AppLogger.e('ProjectService.leaveSharedProject', 'Kilépés hiba (project=${project.uuid}): $e'); rethrow; } } // ═════════════════════════════════════════════════════════════════ /// A projekt-lista frissítése — a háttér-szinkron hívja, miután a /// reconcile-mechanizmus esetleg helyileg törölt egy, a szerveren már /// nem létező projektet. Ha épp az AKTÍV projekt tűnt el, biztonságos /// másikra váltunk, ne maradjon egy már nem létező projektre mutatva. Future reloadProjects() async { await _loadProjects(); final active = activeProject.value; if (active == null) return; final stillThere = projects.firstWhereOrNull((p) => p.id == active.id); if (stillThere == null) { // A projekt eltűnt — biztonságos másikra váltunk. final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ?? (projects.isNotEmpty ? projects.first : null); if (fallback != null) { try { await setActiveProject(fallback); } catch (_) { activeProject.value = null; } } else { activeProject.value = null; } } else { // A projekt megvan — de az ADATAI (pl. a neve) változhattak a // szerveren. Az activeProject-et is friss példányra cseréljük, // hogy minden, közvetlenül ezt figyelő UI (appbar, drawer) azonnal // lássa a változást, ne csak a projekt-választó lista. activeProject.value = stillThere; } } Future> getStats(int projectId) => AppDatabase.instance.getProjectStats(projectId); Future archiveProject(int id) async { await _loadProjects(); // friss állapot a döntéshez final target = projects.firstWhereOrNull((p) => p.id == id); if (target == null) return; // Sose maradjon nulla aktív HELYI projekt — mindig kell legyen // legalább egy, bejelentkezés nélkül is használható projekt. if (target.isLocalOnly) { final otherLocal = projects.where((p) => p.isLocalOnly && p.id != id && p.status == ProjectStatus.active); if (otherLocal.isEmpty) { AppLogger.event('project_archive_blocked_last_local', '', { 'project_id': id, 'user_id': Supabase.instance.client.auth.currentUser?.id, 'device_name': DeviceIdentityService.to.deviceLabel.value, 'device_id': DeviceIdentityService.to.deviceId, }); throw ProjectArchiveBlockedException( 'Ez az utolsó helyi projekt — legalább egynek meg kell ' 'maradnia, hogy bejelentkezés nélkül is legyen elérhető ' 'projekt.'); } } await AppDatabase.instance.archiveProject(id); AppLogger.event('project_archived', '', { 'project_id': id, 'user_id': Supabase.instance.client.auth.currentUser?.id, 'device_name': DeviceIdentityService.to.deviceLabel.value, 'device_id': DeviceIdentityService.to.deviceId, }); await _loadProjects(); if (activeProject.value?.id == id) { final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ?? projects.firstWhereOrNull((p) => p.id != id); if (fallback != null) { try { await setActiveProject(fallback); } catch (_) { activeProject.value = null; } } else { activeProject.value = null; } } } Future updateProject(Project project) async { await AppDatabase.instance.updateProject(project); await _loadProjects(); if (activeProject.value?.id == project.id) { activeProject.value = project; } } }