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