Kapcsolat menü és a hozzá tartozó funkciók.
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: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())
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: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();
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user