diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e140772..086b91d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,6 +44,16 @@ android:value="2" /> + + + + + + + + + + diff --git a/lib/main.dart b/lib/main.dart index 96b786e..7d58bb9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/auth_service.dart'; +import 'package:terepi_seged/services/contact_service.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:terepi_seged/services/firebase_logger.dart'; @@ -21,6 +22,7 @@ import 'package:terepi_seged/services/layer_sync_service.dart'; import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; +import 'package:terepi_seged/services/permission_service.dart'; import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/services/stakeout_service.dart'; import 'package:terepi_seged/services/stakeout_sync_service.dart'; @@ -71,6 +73,8 @@ Future main() async { Get.put(StakeoutSyncService()); Get.put(TsSyncService()); Get.put(TiltService()); + Get.put(PermissionService()); + Get.put(ContactService()); runApp(const MyApp()); } diff --git a/lib/models/contact.dart b/lib/models/contact.dart new file mode 100644 index 0000000..18993df --- /dev/null +++ b/lib/models/contact.dart @@ -0,0 +1,70 @@ +/// Kapcsolat (névjegy) — a Supabase `contacts` táblát tükrözi. +/// +/// Ez a menü CSAK online, bejelentkezett és jogosult felhasználóknak +/// érhető el, ezért nincs lokális tükrözés/offline-sync: közvetlenül a +/// Supabase-ből olvassuk/írjuk, a hozzáférést az RLS + a jogosultság- +/// tábla dönti el. Az uuid szerveroldali (gen_random_uuid()). +class Contact { + final String? id; // uuid — új rekordnál null, a szerver adja + final String name; + final String address; + final String phone; + final String email; + final String note; + final String? createdBy; + final DateTime? updatedAt; + + const Contact({ + this.id, + required this.name, + this.address = '', + this.phone = '', + this.email = '', + this.note = '', + this.createdBy, + this.updatedAt, + }); + + Contact copyWith({ + String? name, + String? address, + String? phone, + String? email, + String? note, + }) => + Contact( + id: id, + name: name ?? this.name, + address: address ?? this.address, + phone: phone ?? this.phone, + email: email ?? this.email, + note: note ?? this.note, + createdBy: createdBy, + updatedAt: updatedAt, + ); + + /// Beszúráshoz/frissítéshez — az id-t csak akkor küldjük, ha van + /// (frissítésnél), a szerveroldali mezőket (created_by, updated_at) + /// sosem a kliens állítja. + Map toWriteMap() => { + if (id != null) 'id': id, + 'name': name.trim(), + 'address': address.trim(), + 'phone': phone.trim(), + 'email': email.trim(), + 'note': note.trim(), + }; + + factory Contact.fromMap(Map m) => Contact( + id: m['id'] as String?, + name: (m['name'] as String?) ?? '', + address: (m['address'] as String?) ?? '', + phone: (m['phone'] as String?) ?? '', + email: (m['email'] as String?) ?? '', + note: (m['note'] as String?) ?? '', + createdBy: m['created_by'] as String?, + updatedAt: m['updated_at'] != null + ? DateTime.tryParse(m['updated_at'] as String) + : null, + ); +} diff --git a/lib/pages/contacts/presentation/controllers/contacts_controller.dart b/lib/pages/contacts/presentation/controllers/contacts_controller.dart new file mode 100644 index 0000000..9810ab1 --- /dev/null +++ b/lib/pages/contacts/presentation/controllers/contacts_controller.dart @@ -0,0 +1,81 @@ +import 'package:get/get.dart'; + +import '../../../../services/contact_service.dart'; + +class ContactsController extends GetxController { + final items = [].obs; + final isLoading = false.obs; + final error = ''.obs; + final search = ''.obs; + final pendingCount = 0.obs; + + @override + void onInit() { + super.onInit(); + load(); + } + + List get filtered { + final q = search.value.trim().toLowerCase(); + if (q.isEmpty) return items; + return items.where((it) { + final c = it.contact; + return c.name.toLowerCase().contains(q) || + c.phone.toLowerCase().contains(q) || + c.email.toLowerCase().contains(q) || + c.address.toLowerCase().contains(q); + }).toList(); + } + + Future load() async { + isLoading.value = true; + error.value = ''; + try { + items.value = await ContactService.to.listMerged(); + pendingCount.value = await ContactService.to.pendingCount(); + } catch (e) { + error.value = _friendly(e); + } finally { + isLoading.value = false; + } + } + + /// Feltölti a várólistát, majd újratölt (kézi "szinkron most" gomb). + Future flushNow() async { + try { + final n = await ContactService.to.flush(); + if (n > 0) { + Get.snackbar('Szinkron', '$n kapcsolat feltöltve.', + snackPosition: SnackPosition.BOTTOM); + } + await load(); + } catch (e) { + Get.snackbar('Szinkron', _friendly(e), + snackPosition: SnackPosition.BOTTOM); + } + } + + Future delete(ContactWithState it) async { + try { + await ContactService.to + .delete(id: it.contact.id, localUuid: it.localUuid); + items.remove(it); + pendingCount.value = await ContactService.to.pendingCount(); + } catch (e) { + Get.snackbar('Hiba', _friendly(e), snackPosition: SnackPosition.BOTTOM); + } + } + + String _friendly(Object e) { + final s = e.toString(); + if (s.contains('permission') || + s.contains('policy') || + s.contains('row-level')) { + return 'Nincs jogosultságod a kapcsolatok eléréséhez.'; + } + if (s.contains('SocketException') || s.contains('Failed host')) { + return 'Nincs hálózati kapcsolat.'; + } + return s; + } +} diff --git a/lib/pages/contacts/presentation/views/contact_edit_view.dart b/lib/pages/contacts/presentation/views/contact_edit_view.dart new file mode 100644 index 0000000..84b2ef9 --- /dev/null +++ b/lib/pages/contacts/presentation/views/contact_edit_view.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../../models/contact.dart'; +import '../../../../services/contact_service.dart'; + +/// Kapcsolat szerkesztő — TELJES OLDAL (Scaffold), nem bottom sheet. +/// +/// Miért oldal: a bottom sheet a szoftveres billentyűzettel rosszul +/// viselkedik (a mezőket eltakarja / a lapot feltolja). A Scaffold body +/// automatikusan a billentyűzet fölé görget, a mentés gomb pedig fixen +/// az appbarban van — kesztyűs, terepi használatra is kényelmes. +/// +/// Új rekord: `Get.to(() => const ContactEditView())`. +/// Szerkesztés: `Get.to(() => const ContactEditView(), arguments: contact)`. +class ContactEditView extends StatefulWidget { + const ContactEditView({super.key}); + + @override + State createState() => _ContactEditViewState(); +} + +class _ContactEditViewState extends State { + final _formKey = GlobalKey(); + late final Contact? _original; + + late final TextEditingController _name; + late final TextEditingController _address; + late final TextEditingController _phone; + late final TextEditingController _email; + late final TextEditingController _note; + + bool _saving = false; + + @override + void initState() { + super.initState(); + _original = Get.arguments is Contact ? Get.arguments as Contact : null; + _name = TextEditingController(text: _original?.name ?? ''); + _address = TextEditingController(text: _original?.address ?? ''); + _phone = TextEditingController(text: _original?.phone ?? ''); + _email = TextEditingController(text: _original?.email ?? ''); + _note = TextEditingController(text: _original?.note ?? ''); + } + + @override + void dispose() { + _name.dispose(); + _address.dispose(); + _phone.dispose(); + _email.dispose(); + _note.dispose(); + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _saving = true); + try { + final contact = (_original ?? const Contact(name: '')).copyWith( + name: _name.text, + address: _address.text, + phone: _phone.text, + email: _email.text, + note: _note.text, + ); + final queued = await ContactService.to.save(contact); + Get.back(result: queued); // a lista frissítéshez visszakapja + Get.snackbar(queued ? 'Elmentve (offline)' : 'Mentve', + queued ? '${contact.name} - feltöltés, amint van net' : contact.name, + snackPosition: SnackPosition.BOTTOM); + } catch (e) { + Get.snackbar('Hiba', _friendly(e), + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFFB71C1C), + colorText: const Color(0xFFFFFFFF)); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + String _friendly(Object e) { + final s = e.toString(); + if (s.contains('permission') || + s.contains('policy') || + s.contains('row-level')) { + return 'Nincs jogosultságod a művelethez.'; + } + return s; + } + + @override + Widget build(BuildContext context) { + final isNew = _original?.id == null; + return Scaffold( + appBar: AppBar( + title: Text(isNew ? 'Új kapcsolat' : 'Kapcsolat szerkesztése'), + actions: [ + TextButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check), + label: const Text('Mentés'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _name, + autofocus: isNew, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Név *', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'A név megadása kötelező.' + : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _address, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Cím', + prefixIcon: Icon(Icons.location_on_outlined), + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextFormField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Telefonszám', + prefixIcon: Icon(Icons.phone_outlined), + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextFormField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'E-mail', + prefixIcon: Icon(Icons.email_outlined), + ), + textInputAction: TextInputAction.next, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + final ok = + RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(v.trim()); + return ok ? null : 'Érvénytelen e-mail cím.'; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _note, + maxLines: 4, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Megjegyzés', + prefixIcon: Icon(Icons.notes_outlined), + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: const Icon(Icons.save_outlined), + label: const Text('Mentés'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/contacts/presentation/views/contacts_view.dart b/lib/pages/contacts/presentation/views/contacts_view.dart new file mode 100644 index 0000000..540bf2f --- /dev/null +++ b/lib/pages/contacts/presentation/views/contacts_view.dart @@ -0,0 +1,335 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../models/contact.dart'; +import '../../../../services/contact_service.dart'; +import '../../../../services/permission_service.dart'; +import '../controllers/contacts_controller.dart'; +import 'contact_edit_view.dart'; + +/// Kapcsolatok lista — csak bejelentkezett ÉS jogosult felhasználóknak. +/// +/// A jogosultság-ellenőrzés kétszintű: a UI a PermissionService-szel +/// elrejti/tiltja az oldalt (barátságos üzenet), a tényleges védelmet +/// pedig a Supabase-oldali RLS adja (az RLS-elutasítás is kezelt). +class ContactsView extends StatelessWidget { + const ContactsView({super.key}); + + @override + Widget build(BuildContext context) { + // Jogosultság-kapu — ha nincs joga, be sem töltjük a listát. + if (Get.isRegistered() && + !PermissionService.to.canContacts) { + return Scaffold( + appBar: AppBar(title: const Text('Kapcsolatok')), + body: const _NoAccess(), + ); + } + + final c = Get.put(ContactsController()); + + return Scaffold( + appBar: AppBar( + title: const Text('Kapcsolatok'), + actions: [ + IconButton( + icon: const Icon(Icons.upload_file), + tooltip: 'Import CSV', + onPressed: () => _importCsv(c), + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Frissítés', + onPressed: c.load, + ), + Obx(() => c.pendingCount.value == 0 + ? const SizedBox.shrink() + : IconButton( + icon: Badge( + label: Text('${c.pendingCount.value}'), + child: const Icon(Icons.cloud_upload_outlined), + ), + tooltip: 'Várólista feltöltése', + onPressed: c.flushNow, + )) + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final saved = await Get.to(() => const ContactEditView()); + if (saved is Contact) c.load(); + }, + icon: const Icon(Icons.person_add_alt), + label: const Text('Új'), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: TextField( + decoration: const InputDecoration( + hintText: 'Keresés név, telefon, e-mail…', + prefixIcon: Icon(Icons.search), + isDense: true, + border: OutlineInputBorder(), + ), + onChanged: (v) => c.search.value = v, + ), + ), + Expanded( + child: Obx(() { + if (c.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (c.error.value.isNotEmpty) { + return _ErrorState(message: c.error.value, onRetry: c.load); + } + final rows = c.filtered; + if (rows.isEmpty) { + return const Center( + child: Text('Nincs megjeleníthető kapcsolat.')); + } + return ListView.separated( + itemCount: rows.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _ContactTile( + item: rows[i], + onEdit: () async { + final saved = await Get.to(() => const ContactEditView(), + arguments: rows[i].contact); + if (saved != null) c.load(); + }, + onDelete: () => _confirmDelete(c, rows[i]), + ), + ); + }), + ), + ], + ), + ); + } + + Future _confirmDelete( + ContactsController c, ContactWithState item) async { + final ok = await Get.dialog(AlertDialog( + title: const Text('Törlés'), + content: Text('Biztosan törlöd: ${item.contact.name}?'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Get.back(result: true), + child: const Text('Törlés')), + ], + )); + if (ok == true) await c.delete(item); + } + + Future _importCsv(ContactsController c) async { + try { + final result = await FilePicker.platform.pickFiles(type: FileType.any); + final picked = result?.files.single; + final path = picked?.path; + if (path == null) return; + + final name = picked!.name.toLowerCase(); + if (!(name.endsWith('.csv') || name.endsWith('.txt'))) { + Get.snackbar('Import', 'CSV vagy TXT fájlt válassz.', + snackPosition: SnackPosition.BOTTOM); + return; + } + + final parsed = await ContactService.parseCsv(File(path)); + if (parsed.isEmpty) { + Get.snackbar('Import', 'Nem található érvényes sor a fájlban.', + snackPosition: SnackPosition.BOTTOM); + return; + } + + final ok = await Get.dialog(AlertDialog( + title: const Text('Import megerősítése'), + content: Text('${parsed.length} kapcsolat importálása?\n\n' + 'Első néhány: ' + '${parsed.take(3).map((e) => e.name).join(', ')}…'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Get.back(result: true), + child: const Text('Import')), + ], + )); + if (ok != true) return; + + final inserted = await ContactService.to.importMany(parsed); + await c.load(); + Get.snackbar('Import kész', '$inserted kapcsolat importálva.', + snackPosition: SnackPosition.BOTTOM); + } catch (e) { + Get.snackbar('Import hiba', e.toString(), + snackPosition: SnackPosition.BOTTOM); + } + } +} + +// ── Sor ────────────────────────────────────────────────────────────── + +class _ContactTile extends StatelessWidget { + final ContactWithState item; + final VoidCallback onEdit; + final VoidCallback onDelete; + const _ContactTile( + {required this.item, required this.onEdit, required this.onDelete}); + + Contact get contact => item.contact; + + @override + Widget build(BuildContext context) { + final subtitle = [ + if (contact.phone.isNotEmpty) contact.phone, + if (contact.email.isNotEmpty) contact.email, + ].join(' · '); + + return ListTile( + leading: CircleAvatar( + child: + Text(contact.name.isNotEmpty ? contact.name[0].toUpperCase() : '?'), + ), + title: Text(contact.name), + subtitle: subtitle.isEmpty ? null : Text(subtitle), + onTap: () => _showDetail(context), + trailing: item.isPending + ? const Tooltip( + message: 'Feltöltésre vár', + child: Icon(Icons.cloud_upload_outlined, + size: 20, color: Colors.orange)) + : PopupMenuButton( + onSelected: (a) { + switch (a) { + case 'edit': + onEdit(); + case 'delete': + onDelete(); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('Szerkesztés')), + PopupMenuItem(value: 'delete', child: Text('Törlés')), + ], + ), + ); + } + + void _showDetail(BuildContext context) { + Get.dialog(AlertDialog( + title: Text(contact.name), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (contact.address.isNotEmpty) + _row(Icons.location_on_outlined, contact.address), + if (contact.phone.isNotEmpty) + _row(Icons.phone_outlined, contact.phone, + onTap: () => _launch('tel:${contact.phone}')), + if (contact.email.isNotEmpty) + _row(Icons.email_outlined, contact.email, + onTap: () => _launch('mailto:${contact.email}')), + if (contact.note.isNotEmpty) _row(Icons.notes_outlined, contact.note), + ], + ), + actions: [ + TextButton( + onPressed: () { + Get.back(); + onEdit(); + }, + child: const Text('Szerkesztés')), + TextButton(onPressed: Get.back, child: const Text('Bezár')), + ], + )); + } + + Widget _row(IconData icon, String text, {VoidCallback? onTap}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: InkWell( + onTap: onTap, + child: Row( + children: [ + Icon(icon, size: 18, color: Colors.grey), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: TextStyle( + color: onTap != null ? Colors.blue : null, + decoration: onTap != null ? TextDecoration.underline : null, + )), + ), + ], + ), + ), + ); + } + + Future _launch(String uri) async { + final u = Uri.parse(uri); + if (await canLaunchUrl(u)) await launchUrl(u); + } +} + +// ── Állapot-widgetek ───────────────────────────────────────────────── + +class _NoAccess extends StatelessWidget { + const _NoAccess(); + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + const Text( + 'Nincs jogosultságod a kapcsolatok eléréséhez.\n' + 'Kérj hozzáférést a rendszergazdától.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + final String message; + final VoidCallback onRetry; + const _ErrorState({required this.message, required this.onRetry}); + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, size: 44, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton(onPressed: onRetry, child: const Text('Újra')), + ], + ), + ), + ); + } +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index b5e6178..15abf82 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import 'package:terepi_seged/pages/bleutooth/bindings/bluetooth_bindings.dart'; import 'package:terepi_seged/pages/bleutooth/presentation/views/bluetooth_test_view.dart'; +import 'package:terepi_seged/pages/contacts/presentation/views/contacts_view.dart'; import 'package:terepi_seged/pages/field_trip/bindings/field_trip_bindings.dart'; import 'package:terepi_seged/pages/field_trip/presentations/views/fiels_trip_view.dart'; import 'package:terepi_seged/pages/home/bindings/home_bindings.dart'; @@ -104,6 +105,7 @@ class AppPages { page: () => const TrackingView()), GetPage(name: Routes.SETTINGS, page: () => const SettingsView()), GetPage( - name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()) + name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()), + GetPage(name: Routes.CONTACTS, page: () => const ContactsView()) ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 9c4de33..e7509fd 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -22,6 +22,7 @@ abstract class Routes { static const LOGIN = '/login'; static const SHELL = '/shell'; + static const CONTACTS = '/contacts'; static const SETTINGS = '/settings'; static const STAKEOUT_IMPORT = '/stakeout_import'; diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index dda0a94..809fa18 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -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 _migrateToV4(Database db) async { @@ -1277,4 +1281,48 @@ class AppDatabase { [correctId]); } } + + Future _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 insertPendingContact(Map row) async { + final db = await database; + await db.insert('contacts_outbox', row); + } + + Future>> listPendingContacts() async { + final db = await database; + return db.query('contacts_outbox', orderBy: 'created_at ASC'); + } + + Future updatePendingContact( + String localUuid, Map fields) async { + final db = await database; + await db.update('contacts_outbox', fields, + where: 'local_uuid = ?', whereArgs: [localUuid]); + } + + Future deletePendingContact(String localUuid) async { + final db = await database; + await db.delete('contacts_outbox', + where: 'local_uuid = ?', whereArgs: [localUuid]); + } + + Future countPendingContacts() async { + final db = await database; + return Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM contacts_outbox')) ?? + 0; + } } diff --git a/lib/services/contact_service.dart b/lib/services/contact_service.dart new file mode 100644 index 0000000..27fbf9d --- /dev/null +++ b/lib/services/contact_service.dart @@ -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 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() async { + final pending = (await _db.listPendingContacts()) + .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() + .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) 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 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'], + '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 pendingCount() => _db.countPendingContacts(); + + // ═════════════════════════════════════════════════════════════════ + // CSV import (változatlan a korábbihoz képest) + // ═════════════════════════════════════════════════════════════════ + + static Future> 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 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( + 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(), + '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; +} diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart new file mode 100644 index 0000000..e243e82 --- /dev/null +++ b/lib/services/permission_service.dart @@ -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 = {}.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 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; + } + } +} diff --git a/lib/services/ts_sync_service.dart b/lib/services/ts_sync_service.dart index aade8fd..a621133 100644 --- a/lib/services/ts_sync_service.dart +++ b/lib/services/ts_sync_service.dart @@ -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()) { + await ContactService.to.flush(); + } + lastSyncedAt.value = DateTime.now(); } catch (e) { lastError.value = e.toString(); diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index d18163c..6878cc0 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -6,7 +6,9 @@ import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_setti import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/auth_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; +import 'package:terepi_seged/services/permission_service.dart'; import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/ts_sync_service.dart'; import '../services/gnss/gnss_connection.dart'; import '../services/gnss/gnss_device_service.dart'; @@ -105,14 +107,36 @@ class AppDrawer extends StatelessWidget { // Get.to(() => const NtripSettingsView()); }, ), - ListTile( - leading: const Icon(Icons.phone_outlined), - title: const Text('Kapcsolatok'), - onTap: () { - Get.back(); - // Get.to(() => const NtripSettingsView()); - }, - ), + Obx(() { + final signedIn = AuthService.to.isSignedIn; + final allowed = !Get.isRegistered() || + PermissionService.to.canContacts; + if (!signedIn || !allowed) return const SizedBox.shrink(); + return ListTile( + leading: const Icon(Icons.phone_outlined), + title: const Text('Kapcsolatok'), + onTap: () { + Get.back(); + Get.toNamed(Routes.CONTACTS); + }, + ); + }), + Obx(() => ListTile( + leading: TsSyncService.to.isSyncing.value + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.sync), + title: const Text('Szinkronizálás'), + subtitle: Text( + TsSyncService.to.pendingCount.value > 0 + ? '${TsSyncService.to.pendingCount.value} elem feltöltésre vár' + : 'Minden szinkronban', + style: const TextStyle(fontSize: 12), + ), + onTap: TsSyncService.to.syncNow, + )), ListTile( leading: const Icon(Icons.data_exploration_outlined), title: const Text('Mérés'), diff --git a/pubspec.yaml b/pubspec.yaml index 9249aac..768e39b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,7 @@ dependencies: device_info_plus: ^12.4.0 wakelock_plus: ^1.2.11 sensors_plus: ^7.1.0 + url_launcher: ^6.3.2 flutter: sdk: flutter @@ -97,6 +98,7 @@ dev_dependencies: # rules and activating additional ones. flutter_native_splash: ^2.4.4 flutter_lints: ^5.0.0 + sqflite_common_ffi: ^2.4.2 flutter_test: sdk: flutter