356 lines
13 KiB
Dart
356 lines
13 KiB
Dart
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;
|
|
}
|