import 'dart:convert'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/services/app_logger.dart'; import 'package:uuid/uuid.dart'; import '../models/contact.dart'; import 'app_database.dart'; /// A `contacts` Supabase-tábla műveletei + OFFLINE OUTBOX. /// /// Modell: a kapcsolatok OTTHONA a Supabase (jogosultsághoz kötött, RLS). /// A lokális `contacts_outbox` NEM tükör, hanem átmeneti PUFFER: csak a /// még fel nem töltött, offline rögzített kapcsolatok élnek benne. /// /// * Íráskor van net → egyből a Supabase-re (mint eddig). /// * Íráskor nincs net → az outboxba `pending` jelöléssel; a lista a /// szerver-adatok MELLETT ezeket is mutatja (a UI "feltöltésre vár" /// jelzéssel). /// * Net visszatér → flush(): feltölt, és CSAK a Supabase visszaigazolása /// UTÁN törli a lokális példányt — így hálózati flikkernél sem vész el /// adat. A local_uuid kulcs idempotenssé teszi az ismételt próbát. /// /// Korlát (szándékos): ez csak LÉTREHOZÁSRA offline-képes. Egy már /// felküldött kapcsolat szerkesztése/törlése online művelet (a teljes /// kétirányú szinkron komplexitását nem hozzuk be). Az outboxban lévő, /// még fel nem töltött sor helyben szerkeszthető/törölhető. class ContactService extends GetxService { static ContactService get to => Get.find(); static const _uuid = Uuid(); SupabaseClient get _client => Supabase.instance.client; AppDatabase get _db => AppDatabase.instance; Future get _isOnline async { final r = await Connectivity().checkConnectivity(); return r.any((c) => c != ConnectivityResult.none); } // ═════════════════════════════════════════════════════════════════ // Olvasás — szerver + lokális pending összefésülve // ═════════════════════════════════════════════════════════════════ /// A megjelenítendő lista: a Supabase-ből lehúzott kapcsolatok ELÉ /// fűzve a még fel nem töltött (pending) helyi sorok. A pending sorokat /// az `isPending` jelöli — a UI kis felhő-ikonnal mutatja őket. Future> listMerged(String projectId) async { final pending = (await _db.listPendingContacts(projectId: projectId)) .map((m) => ContactWithState( contact: Contact.fromMap(m), isPending: true, localUuid: m['local_uuid'] as String, )) .toList(); List remote = []; try { final rows = await _client .from('terepi_seged_contacts') .select() .eq('project_id', projectId) .order('name', ascending: true); remote = rows .map((r) => ContactWithState(contact: Contact.fromMap(r), isPending: false)) .toList(); } catch (_) { // Offline / RLS: a szerver-lista nem érhető el, de a pendingeket // ilyenkor is meg tudjuk mutatni. if (pending.isEmpty) rethrow; } return [...pending, ...remote]; } // ═════════════════════════════════════════════════════════════════ // Írás — online egyből, offline pufferbe // ═════════════════════════════════════════════════════════════════ /// Új kapcsolat vagy szerver-oldali frissítés. /// Visszaadja, hogy a művelet OFFLINE pufferbe került-e (true = várólistán). Future save(Contact c, {String? existingLocalUuid}) async { // Meglévő (szerver-oldali) rekord frissítése CSAK online — lásd korlát. final isUpdate = c.id != null; if (await _isOnline) { try { await _client.from('terepi_seged_contacts').upsert(c.toWriteMap()); // Ha ez korábban egy PENDING (helyi puffer-) sorból indult, és // most sikerült felmenni, a régi helyi sort törölni kell — // különben örökre "függőben" maradna, és a flush() is // duplikálná. if (existingLocalUuid != null) { await _db.deletePendingContact(existingLocalUuid); } return false; // felment } catch (e) { if (isUpdate) rethrow; // frissítést nem pufferelünk if (e is PostgrestException) { // Válasz érkezett a szervertől, de hibás (séma, RLS, megkötés) — // ez NEM hálózati probléma. Ha csendben pufferbe tennénk, ez a // sor SOSEM jutna fel, és senki nem venné észre — inkább // azonnal, láthatóan hibázzon. AppLogger.e('ContactService.save', 'Supabase hiba új kapcsolat mentésekor: $e'); rethrow; } // Valódi hálózati/kapcsolati hiba (pl. pillanatnyi kiesés) → // pufferbe, a flush() majd újrapróbálja. // Új rekord + online hiba (pl. pillanatnyi kiesés) → pufferbe. } } else if (isUpdate) { throw ContactOfflineException( 'Meglévő kapcsolat szerkesztéséhez internet szükséges. ' 'Új kapcsolat rögzítése offline is működik.'); } // Offline (vagy online-hiba) új rekord → outbox. await _db.insertPendingContact({ 'local_uuid': existingLocalUuid ?? _uuid.v4(), 'project_id': c.projectId, 'name': c.name.trim(), 'address': c.address.trim(), 'phone': c.phone.trim(), 'email': c.email.trim(), 'note': c.note.trim(), 'lat': c.lat, 'lon': c.lon, 'eov_y': c.eovY, 'eov_x': c.eovX, 'created_at': DateTime.now().toIso8601String(), }); return true; // várólistán } /// Törlés. Pending (helyi) sor: azonnal a lokálisból. Szerver-oldali /// rekord: online művelet. Future delete({String? id, String? localUuid}) async { if (localUuid != null) { await _db.deletePendingContact(localUuid); return; } if (id != null) { await _client.from('terepi_seged_contacts').delete().eq('id', id); } } /// Egyetlen pending sor mezőinek frissítése (amíg helyben van). Future updatePending(String localUuid, Contact c) async { await _db.updatePendingContact(localUuid, { 'name': c.name.trim(), 'address': c.address.trim(), 'phone': c.phone.trim(), 'email': c.email.trim(), 'note': c.note.trim(), }); } // ═════════════════════════════════════════════════════════════════ // Flush — a TsSyncService hívja net-visszatéréskor // ═════════════════════════════════════════════════════════════════ /// Feltölti a pending kapcsolatokat, és MINDEGYIKET CSAK a Supabase /// visszaigazolása után törli a lokálisból. Visszaadja a feltöltöttek /// számát. Hiba (pl. RLS) esetén a sor a pufferben marad. Future flush() async { if (_client.auth.currentUser == null) return 0; if (!await _isOnline) return 0; final rows = await _db.listPendingContacts(); var uploaded = 0; for (final m in rows) { try { // local_uuid mint kliens-kulcs → az ismételt próba idempotens. await _client.from('terepi_seged_contacts').upsert({ 'client_uuid': m['local_uuid'], 'project_id': m['project_id'], 'name': m['name'], 'address': m['address'], 'phone': m['phone'], 'email': m['email'], 'note': m['note'], 'lat': m['lat'], 'lon': m['lon'], 'eov_y': m['eov_y'], 'eov_x': m['eov_x'], }, onConflict: 'client_uuid', ignoreDuplicates: true); // Sikeres felküldés → a lokális példány törölhető. await _db.deletePendingContact(m['local_uuid'] as String); uploaded++; } catch (e) { // A sor marad a pufferben (legközelebb újrapróbáljuk) — de // naplózzuk, hogy ne maradjon örökre észrevétlen, ha a hiba nem // hálózati, hanem tartós (pl. séma-eltérés, RLS). AppLogger.e('ContactService.flush', 'Kapcsolat feltöltési hiba (local_uuid=${m['local_uuid']}): $e'); } } return uploaded; } Future pendingCount(String projectId) => _db.countPendingContacts(projectId: projectId); // ═════════════════════════════════════════════════════════════════ // CSV import (változatlan a korábbihoz képest) // ═════════════════════════════════════════════════════════════════ static Future> parseCsv(File file, {required String projectId}) async { final bytes = await file.readAsBytes(); String text; try { text = utf8.decode(bytes); } catch (_) { text = latin1.decode(bytes); } if (text.isNotEmpty && text.codeUnitAt(0) == 0xFEFF) { text = text.substring(1); } final lines = text .split(RegExp(r'\r\n|\r|\n')) .where((l) => l.trim().isNotEmpty) .toList(); if (lines.isEmpty) return []; String delimiter = ';'; var best = -1; for (final d in [';', ',', '\t']) { final n = d.allMatches(lines.first).length; if (n > best) { best = n; delimiter = d; } } List split(String line) { final cells = []; final sb = StringBuffer(); var inQ = false; for (var i = 0; i < line.length; i++) { final ch = line[i]; if (ch == '"') { inQ = !inQ; } else if (ch == delimiter && !inQ) { cells.add(sb.toString().trim()); sb.clear(); } else { sb.write(ch); } } cells.add(sb.toString().trim()); return cells; } final rows = lines.map(split).toList(); final first = rows.first.map((c) => c.toLowerCase()).toList(); const nameKeys = ['name', 'nev', 'név', 'kapcsolat']; const addrKeys = ['address', 'cim', 'cím', 'lakcim', 'lakcím']; const phoneKeys = ['phone', 'telefon', 'tel', 'mobil']; const emailKeys = ['email', 'e-mail', 'mail']; const noteKeys = ['note', 'megjegyzes', 'megjegyzés', 'jegyzet']; bool hasAny(String c, List keys) => keys.any(c.contains); final hasHeader = first.any((c) => hasAny(c, nameKeys) || hasAny(c, addrKeys) || hasAny(c, phoneKeys) || hasAny(c, emailKeys)); int idx(List keys, int fallback) { if (!hasHeader) return fallback; return first.indexWhere((c) => hasAny(c, keys)); } final iName = idx(nameKeys, 0); final iAddr = idx(addrKeys, 1); final iPhone = idx(phoneKeys, 2); final iEmail = idx(emailKeys, 3); final iNote = idx(noteKeys, 4); final dataRows = hasHeader ? rows.skip(1) : rows; String cell(List r, int i) => (i >= 0 && i < r.length) ? r[i] : ''; final contacts = []; for (final r in dataRows) { final name = cell(r, iName); if (name.isEmpty) continue; contacts.add(Contact( projectId: projectId, name: name, address: cell(r, iAddr), phone: cell(r, iPhone), email: cell(r, iEmail), note: cell(r, iNote), )); } return contacts; } /// Import: online egyben a Supabase-be, offline az outboxba (soronként). /// Visszaadja (felküldött, várólistára tett) párt. Future<({int uploaded, int queued})> importMany( List contacts) async { if (contacts.isEmpty) return (uploaded: 0, queued: 0); if (await _isOnline) { try { await _client .from('terepi_seged_contacts') .insert(contacts.map((c) => c.toWriteMap()).toList()); return (uploaded: contacts.length, queued: 0); } catch (_) { // Online hiba → pufferbe esik vissza az egész batch. } } for (final c in contacts) { await _db.insertPendingContact({ 'local_uuid': _uuid.v4(), 'project_id': c.projectId, 'name': c.name.trim(), 'address': c.address.trim(), 'phone': c.phone.trim(), 'email': c.email.trim(), 'note': c.note.trim(), 'created_at': DateTime.now().toIso8601String(), }); } return (uploaded: 0, queued: contacts.length); } } /// Kapcsolat + megjelenítési állapot (pending-e, és ha igen, a helyi kulcs). class ContactWithState { final Contact contact; final bool isPending; final String? localUuid; const ContactWithState({ required this.contact, required this.isPending, this.localUuid, }); } class ContactOfflineException implements Exception { final String message; ContactOfflineException(this.message); @override String toString() => message; }