Kapcsolat menü és a hozzá tartozó funkciók.
This commit is contained in:
@@ -44,6 +44,16 @@
|
||||
android:value="2" />
|
||||
</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.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
@@ -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<void> main() async {
|
||||
Get.put(StakeoutSyncService());
|
||||
Get.put(TsSyncService());
|
||||
Get.put(TiltService());
|
||||
Get.put(PermissionService());
|
||||
Get.put(ContactService());
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -43,7 +43,7 @@ class AppDatabase {
|
||||
final path = p.join(dbDir.path, 'terepi_seged.db');
|
||||
|
||||
return openDatabase(path,
|
||||
version: 4,
|
||||
version: 5,
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade);
|
||||
@@ -252,6 +252,7 @@ class AppDatabase {
|
||||
'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)');
|
||||
|
||||
await _createStakeoutTable(db);
|
||||
await _createContactsOutbox(db);
|
||||
|
||||
// Alap projekt létrehozása az első indításhoz
|
||||
final now = DateTime.now().toIso8601String();
|
||||
@@ -284,6 +285,9 @@ class AppDatabase {
|
||||
if (oldVersion < 4) {
|
||||
await _migrateToV4(db);
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
_createContactsOutbox(db);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateToV4(Database db) async {
|
||||
@@ -1277,4 +1281,48 @@ class AppDatabase {
|
||||
[correctId]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createContactsOutbox(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS contacts_outbox (
|
||||
local_uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> insertPendingContact(Map<String, dynamic> row) async {
|
||||
final db = await database;
|
||||
await db.insert('contacts_outbox', row);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listPendingContacts() async {
|
||||
final db = await database;
|
||||
return db.query('contacts_outbox', orderBy: 'created_at ASC');
|
||||
}
|
||||
|
||||
Future<void> updatePendingContact(
|
||||
String localUuid, Map<String, dynamic> fields) async {
|
||||
final db = await database;
|
||||
await db.update('contacts_outbox', fields,
|
||||
where: 'local_uuid = ?', whereArgs: [localUuid]);
|
||||
}
|
||||
|
||||
Future<void> deletePendingContact(String localUuid) async {
|
||||
final db = await database;
|
||||
await db.delete('contacts_outbox',
|
||||
where: 'local_uuid = ?', whereArgs: [localUuid]);
|
||||
}
|
||||
|
||||
Future<int> countPendingContacts() async {
|
||||
final db = await database;
|
||||
return Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM contacts_outbox')) ??
|
||||
0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import 'app_database.dart';
|
||||
|
||||
/// A `contacts` Supabase-tábla műveletei + OFFLINE OUTBOX.
|
||||
///
|
||||
/// Modell: a kapcsolatok OTTHONA a Supabase (jogosultsághoz kötött, RLS).
|
||||
/// A lokális `contacts_outbox` NEM tükör, hanem átmeneti PUFFER: csak a
|
||||
/// még fel nem töltött, offline rögzített kapcsolatok élnek benne.
|
||||
///
|
||||
/// * Íráskor van net → egyből a Supabase-re (mint eddig).
|
||||
/// * Íráskor nincs net → az outboxba `pending` jelöléssel; a lista a
|
||||
/// szerver-adatok MELLETT ezeket is mutatja (a UI "feltöltésre vár"
|
||||
/// jelzéssel).
|
||||
/// * Net visszatér → flush(): feltölt, és CSAK a Supabase visszaigazolása
|
||||
/// UTÁN törli a lokális példányt — így hálózati flikkernél sem vész el
|
||||
/// adat. A local_uuid kulcs idempotenssé teszi az ismételt próbát.
|
||||
///
|
||||
/// Korlát (szándékos): ez csak LÉTREHOZÁSRA offline-képes. Egy már
|
||||
/// felküldött kapcsolat szerkesztése/törlése online művelet (a teljes
|
||||
/// kétirányú szinkron komplexitását nem hozzuk be). Az outboxban lévő,
|
||||
/// még fel nem töltött sor helyben szerkeszthető/törölhető.
|
||||
class ContactService extends GetxService {
|
||||
static ContactService get to => Get.find();
|
||||
|
||||
static const _uuid = Uuid();
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
AppDatabase get _db => AppDatabase.instance;
|
||||
|
||||
Future<bool> get _isOnline async {
|
||||
final r = await Connectivity().checkConnectivity();
|
||||
return r.any((c) => c != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Olvasás — szerver + lokális pending összefésülve
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A megjelenítendő lista: a Supabase-ből lehúzott kapcsolatok ELÉ
|
||||
/// fűzve a még fel nem töltött (pending) helyi sorok. A pending sorokat
|
||||
/// az `isPending` jelöli — a UI kis felhő-ikonnal mutatja őket.
|
||||
Future<List<ContactWithState>> listMerged() async {
|
||||
final pending = (await _db.listPendingContacts())
|
||||
.map((m) => ContactWithState(
|
||||
contact: Contact.fromMap(m),
|
||||
isPending: true,
|
||||
localUuid: m['local_uuid'] as String,
|
||||
))
|
||||
.toList();
|
||||
|
||||
List<ContactWithState> remote = [];
|
||||
try {
|
||||
final rows = await _client
|
||||
.from('terepi_seged_contacts')
|
||||
.select()
|
||||
.order('name', ascending: true);
|
||||
remote = rows
|
||||
.map((r) =>
|
||||
ContactWithState(contact: Contact.fromMap(r), isPending: false))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
// Offline / RLS: a szerver-lista nem érhető el, de a pendingeket
|
||||
// ilyenkor is meg tudjuk mutatni.
|
||||
if (pending.isEmpty) rethrow;
|
||||
}
|
||||
|
||||
return [...pending, ...remote];
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Írás — online egyből, offline pufferbe
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Új kapcsolat vagy szerver-oldali frissítés.
|
||||
/// Visszaadja, hogy a művelet OFFLINE pufferbe került-e (true = várólistán).
|
||||
Future<bool> save(Contact c) async {
|
||||
// Meglévő (szerver-oldali) rekord frissítése CSAK online — lásd korlát.
|
||||
final isUpdate = c.id != null;
|
||||
|
||||
if (await _isOnline) {
|
||||
try {
|
||||
await _client.from('terepi_seged_contacts').upsert(c.toWriteMap());
|
||||
return false; // felment
|
||||
} catch (e) {
|
||||
if (isUpdate) rethrow; // frissítést nem pufferelünk
|
||||
// Új rekord + online hiba (pl. pillanatnyi kiesés) → pufferbe.
|
||||
}
|
||||
} else if (isUpdate) {
|
||||
throw ContactOfflineException(
|
||||
'Meglévő kapcsolat szerkesztéséhez internet szükséges. '
|
||||
'Új kapcsolat rögzítése offline is működik.');
|
||||
}
|
||||
|
||||
// Offline (vagy online-hiba) új rekord → outbox.
|
||||
await _db.insertPendingContact({
|
||||
'local_uuid': _uuid.v4(),
|
||||
'name': c.name.trim(),
|
||||
'address': c.address.trim(),
|
||||
'phone': c.phone.trim(),
|
||||
'email': c.email.trim(),
|
||||
'note': c.note.trim(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
return true; // várólistán
|
||||
}
|
||||
|
||||
/// Törlés. Pending (helyi) sor: azonnal a lokálisból. Szerver-oldali
|
||||
/// rekord: online művelet.
|
||||
Future<void> delete({String? id, String? localUuid}) async {
|
||||
if (localUuid != null) {
|
||||
await _db.deletePendingContact(localUuid);
|
||||
return;
|
||||
}
|
||||
if (id != null) {
|
||||
await _client.from('terepi_seged_contacts').delete().eq('id', id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Egyetlen pending sor mezőinek frissítése (amíg helyben van).
|
||||
Future<void> updatePending(String localUuid, Contact c) async {
|
||||
await _db.updatePendingContact(localUuid, {
|
||||
'name': c.name.trim(),
|
||||
'address': c.address.trim(),
|
||||
'phone': c.phone.trim(),
|
||||
'email': c.email.trim(),
|
||||
'note': c.note.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Flush — a TsSyncService hívja net-visszatéréskor
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Feltölti a pending kapcsolatokat, és MINDEGYIKET CSAK a Supabase
|
||||
/// visszaigazolása után törli a lokálisból. Visszaadja a feltöltöttek
|
||||
/// számát. Hiba (pl. RLS) esetén a sor a pufferben marad.
|
||||
Future<int> flush() async {
|
||||
if (_client.auth.currentUser == null) return 0;
|
||||
if (!await _isOnline) return 0;
|
||||
|
||||
final rows = await _db.listPendingContacts();
|
||||
var uploaded = 0;
|
||||
for (final m in rows) {
|
||||
try {
|
||||
// local_uuid mint kliens-kulcs → az ismételt próba idempotens.
|
||||
await _client.from('terepi_seged_contacts').upsert({
|
||||
'client_uuid': m['local_uuid'],
|
||||
'name': m['name'],
|
||||
'address': m['address'],
|
||||
'phone': m['phone'],
|
||||
'email': m['email'],
|
||||
'note': m['note'],
|
||||
}, onConflict: 'client_uuid', ignoreDuplicates: true);
|
||||
|
||||
// Sikeres felküldés → a lokális példány törölhető.
|
||||
await _db.deletePendingContact(m['local_uuid'] as String);
|
||||
uploaded++;
|
||||
} catch (_) {
|
||||
// A sor marad a pufferben, a következő flush újrapróbálja.
|
||||
}
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
Future<int> pendingCount() => _db.countPendingContacts();
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// CSV import (változatlan a korábbihoz képest)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
static Future<List<Contact>> parseCsv(File file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
String text;
|
||||
try {
|
||||
text = utf8.decode(bytes);
|
||||
} catch (_) {
|
||||
text = latin1.decode(bytes);
|
||||
}
|
||||
if (text.isNotEmpty && text.codeUnitAt(0) == 0xFEFF) {
|
||||
text = text.substring(1);
|
||||
}
|
||||
|
||||
final lines = text
|
||||
.split(RegExp(r'\r\n|\r|\n'))
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
if (lines.isEmpty) return [];
|
||||
|
||||
String delimiter = ';';
|
||||
var best = -1;
|
||||
for (final d in [';', ',', '\t']) {
|
||||
final n = d.allMatches(lines.first).length;
|
||||
if (n > best) {
|
||||
best = n;
|
||||
delimiter = d;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> split(String line) {
|
||||
final cells = <String>[];
|
||||
final sb = StringBuffer();
|
||||
var inQ = false;
|
||||
for (var i = 0; i < line.length; i++) {
|
||||
final ch = line[i];
|
||||
if (ch == '"') {
|
||||
inQ = !inQ;
|
||||
} else if (ch == delimiter && !inQ) {
|
||||
cells.add(sb.toString().trim());
|
||||
sb.clear();
|
||||
} else {
|
||||
sb.write(ch);
|
||||
}
|
||||
}
|
||||
cells.add(sb.toString().trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
final rows = lines.map(split).toList();
|
||||
final first = rows.first.map((c) => c.toLowerCase()).toList();
|
||||
const nameKeys = ['name', 'nev', 'név', 'kapcsolat'];
|
||||
const addrKeys = ['address', 'cim', 'cím', 'lakcim', 'lakcím'];
|
||||
const phoneKeys = ['phone', 'telefon', 'tel', 'mobil'];
|
||||
const emailKeys = ['email', 'e-mail', 'mail'];
|
||||
const noteKeys = ['note', 'megjegyzes', 'megjegyzés', 'jegyzet'];
|
||||
|
||||
bool hasAny(String c, List<String> keys) => keys.any(c.contains);
|
||||
final hasHeader = first.any((c) =>
|
||||
hasAny(c, nameKeys) ||
|
||||
hasAny(c, addrKeys) ||
|
||||
hasAny(c, phoneKeys) ||
|
||||
hasAny(c, emailKeys));
|
||||
|
||||
int idx(List<String> keys, int fallback) {
|
||||
if (!hasHeader) return fallback;
|
||||
return first.indexWhere((c) => hasAny(c, keys));
|
||||
}
|
||||
|
||||
final iName = idx(nameKeys, 0);
|
||||
final iAddr = idx(addrKeys, 1);
|
||||
final iPhone = idx(phoneKeys, 2);
|
||||
final iEmail = idx(emailKeys, 3);
|
||||
final iNote = idx(noteKeys, 4);
|
||||
|
||||
final dataRows = hasHeader ? rows.skip(1) : rows;
|
||||
String cell(List<String> r, int i) => (i >= 0 && i < r.length) ? r[i] : '';
|
||||
|
||||
final contacts = <Contact>[];
|
||||
for (final r in dataRows) {
|
||||
final name = cell(r, iName);
|
||||
if (name.isEmpty) continue;
|
||||
contacts.add(Contact(
|
||||
name: name,
|
||||
address: cell(r, iAddr),
|
||||
phone: cell(r, iPhone),
|
||||
email: cell(r, iEmail),
|
||||
note: cell(r, iNote),
|
||||
));
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/// Import: online egyben a Supabase-be, offline az outboxba (soronként).
|
||||
/// Visszaadja (felküldött, várólistára tett) párt.
|
||||
Future<({int uploaded, int queued})> importMany(
|
||||
List<Contact> contacts) async {
|
||||
if (contacts.isEmpty) return (uploaded: 0, queued: 0);
|
||||
|
||||
if (await _isOnline) {
|
||||
try {
|
||||
await _client
|
||||
.from('terepi_seged_contacts')
|
||||
.insert(contacts.map((c) => c.toWriteMap()).toList());
|
||||
return (uploaded: contacts.length, queued: 0);
|
||||
} catch (_) {
|
||||
// Online hiba → pufferbe esik vissza az egész batch.
|
||||
}
|
||||
}
|
||||
for (final c in contacts) {
|
||||
await _db.insertPendingContact({
|
||||
'local_uuid': _uuid.v4(),
|
||||
'name': c.name.trim(),
|
||||
'address': c.address.trim(),
|
||||
'phone': c.phone.trim(),
|
||||
'email': c.email.trim(),
|
||||
'note': c.note.trim(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
return (uploaded: 0, queued: contacts.length);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kapcsolat + megjelenítési állapot (pending-e, és ha igen, a helyi kulcs).
|
||||
class ContactWithState {
|
||||
final Contact contact;
|
||||
final bool isPending;
|
||||
final String? localUuid;
|
||||
const ContactWithState({
|
||||
required this.contact,
|
||||
required this.isPending,
|
||||
this.localUuid,
|
||||
});
|
||||
}
|
||||
|
||||
class ContactOfflineException implements Exception {
|
||||
final String message;
|
||||
ContactOfflineException(this.message);
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
/// Oldal-/funkció-szintű jogosultságok kezelése.
|
||||
///
|
||||
/// A jogosultságokat egy Supabase `app_permissions` tábla tárolja
|
||||
/// (user_id + area), és a felhasználó a saját sorait olvashatja (RLS).
|
||||
/// A service induláskor és bejelentkezéskor betölti a jelenlegi
|
||||
/// felhasználó jogosultságait egy halmazba, amit a UI reaktívan figyel.
|
||||
///
|
||||
/// Bővíthető: új védett terület = új 'area' string (pl. 'admin'),
|
||||
/// a UI a [can] getterrel kérdez rá — kódmódosítás nélkül. A tényleges
|
||||
/// védelmet a Supabase-oldali RLS adja (a `contacts`/admin táblákon),
|
||||
/// ez a service csak a UI-t vezérli (menüpont elrejtése, üzenet).
|
||||
class PermissionService extends GetxService {
|
||||
static PermissionService get to => Get.find();
|
||||
|
||||
static const areaContacts = 'contacts';
|
||||
static const areaAdmin = 'admin';
|
||||
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
|
||||
/// A jelenlegi felhasználó engedélyezett területei.
|
||||
final _areas = <String>{}.obs;
|
||||
final isLoaded = false.obs;
|
||||
|
||||
bool can(String area) => _areas.contains(area);
|
||||
bool get canContacts => can(areaContacts);
|
||||
bool get canAdmin => can(areaAdmin);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// Induláskor és minden auth-változáskor újratöltjük.
|
||||
reload();
|
||||
_client.auth.onAuthStateChange.listen((_) => reload());
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
_areas.clear();
|
||||
isLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final rows = await _client
|
||||
.from('terepi_seged_app_permissions')
|
||||
.select('area')
|
||||
.eq('user_id', user.id);
|
||||
_areas
|
||||
..clear()
|
||||
..addAll(rows.map((r) => r['area'] as String));
|
||||
} catch (_) {
|
||||
// Hálózati hiba: nem adunk jogot (fail-closed), de nem is dobunk.
|
||||
_areas.clear();
|
||||
} finally {
|
||||
_areas.refresh();
|
||||
isLoaded.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/services/contact_service.dart';
|
||||
import 'package:terepi_seged/services/device_identity_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_sync_service.dart';
|
||||
|
||||
@@ -98,6 +99,10 @@ class TsSyncService extends GetxService {
|
||||
await StakeoutSyncService.to.sync();
|
||||
}
|
||||
|
||||
if (Get.isRegistered<ContactService>()) {
|
||||
await ContactService.to.flush();
|
||||
}
|
||||
|
||||
lastSyncedAt.value = DateTime.now();
|
||||
} catch (e) {
|
||||
lastError.value = e.toString();
|
||||
|
||||
@@ -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>() ||
|
||||
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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user