Files

356 lines
13 KiB
Dart
Raw Permalink Normal View History

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<bool> 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<List<ContactWithState>> 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<ContactWithState> 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<bool> 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<void> 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<void> 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<int> 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<int> pendingCount(String projectId) =>
_db.countPendingContacts(projectId: projectId);
// ═════════════════════════════════════════════════════════════════
// CSV import (változatlan a korábbihoz képest)
// ═════════════════════════════════════════════════════════════════
static Future<List<Contact>> 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<String> split(String line) {
final cells = <String>[];
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<String> 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<String> 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<String> r, int i) => (i >= 0 && i < r.length) ? r[i] : '';
final contacts = <Contact>[];
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<Contact> 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;
}