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

This commit is contained in:
2026-07-08 13:32:04 +02:00
parent 7b35ed2939
commit b1efaff34a
14 changed files with 1158 additions and 10 deletions
@@ -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')),
],
),
),
);
}
}