Kapcsolat menü és a hozzá tartozó funkciók.

This commit is contained in:
2026-07-08 13:32:04 +02:00
parent 7b35ed2939
commit b1efaff34a
14 changed files with 1158 additions and 10 deletions
+10
View File
@@ -44,6 +44,16 @@
android:value="2" /> android:value="2" />
</application> </application>
<queries>
<intent>
<action android:name="android.intent.action.DIAL"/>
</intent>
<intent>
<action android:name="android.intent.action.SENDTO"/>
<data android:scheme="mailto"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+4
View File
@@ -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_database.dart';
import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/app_logger.dart';
import 'package:terepi_seged/services/auth_service.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/coord_converter_service.dart';
import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:terepi_seged/services/firebase_logger.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_audio_service.dart';
import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart';
import 'package:terepi_seged/services/ntrip_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/project_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart'; import 'package:terepi_seged/services/stakeout_service.dart';
import 'package:terepi_seged/services/stakeout_sync_service.dart'; import 'package:terepi_seged/services/stakeout_sync_service.dart';
@@ -71,6 +73,8 @@ Future<void> main() async {
Get.put(StakeoutSyncService()); Get.put(StakeoutSyncService());
Get.put(TsSyncService()); Get.put(TsSyncService());
Get.put(TiltService()); Get.put(TiltService());
Get.put(PermissionService());
Get.put(ContactService());
runApp(const MyApp()); runApp(const MyApp());
} }
+70
View File
@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
@@ -0,0 +1,81 @@
import 'package:get/get.dart';
import '../../../../services/contact_service.dart';
class ContactsController extends GetxController {
final items = <ContactWithState>[].obs;
final isLoading = false.obs;
final error = ''.obs;
final search = ''.obs;
final pendingCount = 0.obs;
@override
void onInit() {
super.onInit();
load();
}
List<ContactWithState> 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<void> 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<void> 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<void> 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;
}
}
@@ -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<ContactEditView> createState() => _ContactEditViewState();
}
class _ContactEditViewState extends State<ContactEditView> {
final _formKey = GlobalKey<FormState>();
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<void> _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'),
),
],
),
),
);
}
}
@@ -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>() &&
!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<void> _confirmDelete(
ContactsController c, ContactWithState item) async {
final ok = await Get.dialog<bool>(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<void> _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<bool>(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<String>(
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<void> _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')),
],
),
),
);
}
}
+3 -1
View File
@@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:terepi_seged/pages/bleutooth/bindings/bluetooth_bindings.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/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/bindings/field_trip_bindings.dart';
import 'package:terepi_seged/pages/field_trip/presentations/views/fiels_trip_view.dart'; import 'package:terepi_seged/pages/field_trip/presentations/views/fiels_trip_view.dart';
import 'package:terepi_seged/pages/home/bindings/home_bindings.dart'; import 'package:terepi_seged/pages/home/bindings/home_bindings.dart';
@@ -104,6 +105,7 @@ class AppPages {
page: () => const TrackingView()), page: () => const TrackingView()),
GetPage(name: Routes.SETTINGS, page: () => const SettingsView()), GetPage(name: Routes.SETTINGS, page: () => const SettingsView()),
GetPage( GetPage(
name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()) name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()),
GetPage(name: Routes.CONTACTS, page: () => const ContactsView())
]; ];
} }
+1
View File
@@ -22,6 +22,7 @@ abstract class Routes {
static const LOGIN = '/login'; static const LOGIN = '/login';
static const SHELL = '/shell'; static const SHELL = '/shell';
static const CONTACTS = '/contacts';
static const SETTINGS = '/settings'; static const SETTINGS = '/settings';
static const STAKEOUT_IMPORT = '/stakeout_import'; static const STAKEOUT_IMPORT = '/stakeout_import';
+49 -1
View File
@@ -43,7 +43,7 @@ class AppDatabase {
final path = p.join(dbDir.path, 'terepi_seged.db'); final path = p.join(dbDir.path, 'terepi_seged.db');
return openDatabase(path, return openDatabase(path,
version: 4, version: 5,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade); onUpgrade: _onUpgrade);
@@ -252,6 +252,7 @@ class AppDatabase {
'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)'); 'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)');
await _createStakeoutTable(db); await _createStakeoutTable(db);
await _createContactsOutbox(db);
// Alap projekt létrehozása az első indításhoz // Alap projekt létrehozása az első indításhoz
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
@@ -284,6 +285,9 @@ class AppDatabase {
if (oldVersion < 4) { if (oldVersion < 4) {
await _migrateToV4(db); await _migrateToV4(db);
} }
if (oldVersion < 5) {
_createContactsOutbox(db);
}
} }
Future<void> _migrateToV4(Database db) async { Future<void> _migrateToV4(Database db) async {
@@ -1277,4 +1281,48 @@ class AppDatabase {
[correctId]); [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;
}
} }
+317
View File
@@ -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;
}
+62
View File
@@ -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;
}
}
}
+5
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.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/device_identity_service.dart';
import 'package:terepi_seged/services/stakeout_sync_service.dart'; import 'package:terepi_seged/services/stakeout_sync_service.dart';
@@ -98,6 +99,10 @@ class TsSyncService extends GetxService {
await StakeoutSyncService.to.sync(); await StakeoutSyncService.to.sync();
} }
if (Get.isRegistered<ContactService>()) {
await ContactService.to.flush();
}
lastSyncedAt.value = DateTime.now(); lastSyncedAt.value = DateTime.now();
} catch (e) { } catch (e) {
lastError.value = e.toString(); lastError.value = e.toString();
+32 -8
View File
@@ -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/routes/app_pages.dart';
import 'package:terepi_seged/services/auth_service.dart'; import 'package:terepi_seged/services/auth_service.dart';
import 'package:terepi_seged/services/ntrip_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/project_service.dart';
import 'package:terepi_seged/services/ts_sync_service.dart';
import '../services/gnss/gnss_connection.dart'; import '../services/gnss/gnss_connection.dart';
import '../services/gnss/gnss_device_service.dart'; import '../services/gnss/gnss_device_service.dart';
@@ -105,14 +107,36 @@ class AppDrawer extends StatelessWidget {
// Get.to(() => const NtripSettingsView()); // Get.to(() => const NtripSettingsView());
}, },
), ),
ListTile( Obx(() {
leading: const Icon(Icons.phone_outlined), final signedIn = AuthService.to.isSignedIn;
title: const Text('Kapcsolatok'), final allowed = !Get.isRegistered<PermissionService>() ||
onTap: () { PermissionService.to.canContacts;
Get.back(); if (!signedIn || !allowed) return const SizedBox.shrink();
// Get.to(() => const NtripSettingsView()); 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( ListTile(
leading: const Icon(Icons.data_exploration_outlined), leading: const Icon(Icons.data_exploration_outlined),
title: const Text('Mérés'), title: const Text('Mérés'),
+2
View File
@@ -83,6 +83,7 @@ dependencies:
device_info_plus: ^12.4.0 device_info_plus: ^12.4.0
wakelock_plus: ^1.2.11 wakelock_plus: ^1.2.11
sensors_plus: ^7.1.0 sensors_plus: ^7.1.0
url_launcher: ^6.3.2
flutter: flutter:
sdk: flutter sdk: flutter
@@ -97,6 +98,7 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_native_splash: ^2.4.4 flutter_native_splash: ^2.4.4
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
sqflite_common_ffi: ^2.4.2
flutter_test: flutter_test:
sdk: flutter sdk: flutter