Kapcsolat menü és a hozzá tartozó funkciók.
This commit is contained in:
@@ -43,7 +43,7 @@ class AppDatabase {
|
||||
final path = p.join(dbDir.path, 'terepi_seged.db');
|
||||
|
||||
return openDatabase(path,
|
||||
version: 4,
|
||||
version: 5,
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade);
|
||||
@@ -252,6 +252,7 @@ class AppDatabase {
|
||||
'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)');
|
||||
|
||||
await _createStakeoutTable(db);
|
||||
await _createContactsOutbox(db);
|
||||
|
||||
// Alap projekt létrehozása az első indításhoz
|
||||
final now = DateTime.now().toIso8601String();
|
||||
@@ -284,6 +285,9 @@ class AppDatabase {
|
||||
if (oldVersion < 4) {
|
||||
await _migrateToV4(db);
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
_createContactsOutbox(db);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateToV4(Database db) async {
|
||||
@@ -1277,4 +1281,48 @@ class AppDatabase {
|
||||
[correctId]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createContactsOutbox(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS contacts_outbox (
|
||||
local_uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> insertPendingContact(Map<String, dynamic> row) async {
|
||||
final db = await database;
|
||||
await db.insert('contacts_outbox', row);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listPendingContacts() async {
|
||||
final db = await database;
|
||||
return db.query('contacts_outbox', orderBy: 'created_at ASC');
|
||||
}
|
||||
|
||||
Future<void> updatePendingContact(
|
||||
String localUuid, Map<String, dynamic> fields) async {
|
||||
final db = await database;
|
||||
await db.update('contacts_outbox', fields,
|
||||
where: 'local_uuid = ?', whereArgs: [localUuid]);
|
||||
}
|
||||
|
||||
Future<void> deletePendingContact(String localUuid) async {
|
||||
final db = await database;
|
||||
await db.delete('contacts_outbox',
|
||||
where: 'local_uuid = ?', whereArgs: [localUuid]);
|
||||
}
|
||||
|
||||
Future<int> countPendingContacts() async {
|
||||
final db = await database;
|
||||
return Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM contacts_outbox')) ??
|
||||
0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
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: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() async {
|
||||
final pending = (await _db.listPendingContacts())
|
||||
.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()
|
||||
.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) 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());
|
||||
return false; // felment
|
||||
} catch (e) {
|
||||
if (isUpdate) rethrow; // frissítést nem pufferelünk
|
||||
// Ú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': _uuid.v4(),
|
||||
'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 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'],
|
||||
'name': m['name'],
|
||||
'address': m['address'],
|
||||
'phone': m['phone'],
|
||||
'email': m['email'],
|
||||
'note': m['note'],
|
||||
}, 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 (_) {
|
||||
// A sor marad a pufferben, a következő flush újrapróbálja.
|
||||
}
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
Future<int> pendingCount() => _db.countPendingContacts();
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// CSV import (változatlan a korábbihoz képest)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
static Future<List<Contact>> parseCsv(File file) 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(
|
||||
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(),
|
||||
'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;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
/// Oldal-/funkció-szintű jogosultságok kezelése.
|
||||
///
|
||||
/// A jogosultságokat egy Supabase `app_permissions` tábla tárolja
|
||||
/// (user_id + area), és a felhasználó a saját sorait olvashatja (RLS).
|
||||
/// A service induláskor és bejelentkezéskor betölti a jelenlegi
|
||||
/// felhasználó jogosultságait egy halmazba, amit a UI reaktívan figyel.
|
||||
///
|
||||
/// Bővíthető: új védett terület = új 'area' string (pl. 'admin'),
|
||||
/// a UI a [can] getterrel kérdez rá — kódmódosítás nélkül. A tényleges
|
||||
/// védelmet a Supabase-oldali RLS adja (a `contacts`/admin táblákon),
|
||||
/// ez a service csak a UI-t vezérli (menüpont elrejtése, üzenet).
|
||||
class PermissionService extends GetxService {
|
||||
static PermissionService get to => Get.find();
|
||||
|
||||
static const areaContacts = 'contacts';
|
||||
static const areaAdmin = 'admin';
|
||||
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
|
||||
/// A jelenlegi felhasználó engedélyezett területei.
|
||||
final _areas = <String>{}.obs;
|
||||
final isLoaded = false.obs;
|
||||
|
||||
bool can(String area) => _areas.contains(area);
|
||||
bool get canContacts => can(areaContacts);
|
||||
bool get canAdmin => can(areaAdmin);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// Induláskor és minden auth-változáskor újratöltjük.
|
||||
reload();
|
||||
_client.auth.onAuthStateChange.listen((_) => reload());
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
_areas.clear();
|
||||
isLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final rows = await _client
|
||||
.from('terepi_seged_app_permissions')
|
||||
.select('area')
|
||||
.eq('user_id', user.id);
|
||||
_areas
|
||||
..clear()
|
||||
..addAll(rows.map((r) => r['area'] as String));
|
||||
} catch (_) {
|
||||
// Hálózati hiba: nem adunk jogot (fail-closed), de nem is dobunk.
|
||||
_areas.clear();
|
||||
} finally {
|
||||
_areas.refresh();
|
||||
isLoaded.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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/contact_service.dart';
|
||||
import 'package:terepi_seged/services/device_identity_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_sync_service.dart';
|
||||
|
||||
@@ -98,6 +99,10 @@ class TsSyncService extends GetxService {
|
||||
await StakeoutSyncService.to.sync();
|
||||
}
|
||||
|
||||
if (Get.isRegistered<ContactService>()) {
|
||||
await ContactService.to.flush();
|
||||
}
|
||||
|
||||
lastSyncedAt.value = DateTime.now();
|
||||
} catch (e) {
|
||||
lastError.value = e.toString();
|
||||
|
||||
Reference in New Issue
Block a user