440 lines
15 KiB
Dart
440 lines
15 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:terepi_seged/models/project.dart';
|
|
import 'package:terepi_seged/services/contact_export_service.dart';
|
|
import 'package:terepi_seged/services/project_service.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(),
|
|
);
|
|
}
|
|
// Projekt-kapu — a kapcsolat mindig egy projekthez tartozik, és csak
|
|
// MEGOSZTOTT (online) projektnél van értelme (a funkció a Supabase-en
|
|
// él). Csak-lokális vagy hiányzó projektnél blokkolunk.
|
|
final activeProject = ProjectService.to.activeProject.value;
|
|
if (activeProject == null || activeProject.isLocalOnly) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Kapcsolatok')),
|
|
body: _ProjectRequired(project: activeProject),
|
|
);
|
|
}
|
|
|
|
final c = Get.put(ContactsController());
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Kapcsolatok'),
|
|
Text(activeProject.name,
|
|
style: const TextStyle(
|
|
fontSize: 12, fontWeight: FontWeight.normal)),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.upload_file),
|
|
tooltip: 'Import CSV',
|
|
onPressed: () => _importCsv(c),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.ios_share),
|
|
tooltip: 'Export CSV',
|
|
onPressed: () => _exportDialog(c, activeProject.name),
|
|
),
|
|
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 activeProject = ProjectService.to.activeProject.value;
|
|
final parsed = await ContactService.parseCsv(File(path),
|
|
projectId: activeProject!.uuid);
|
|
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 imported = await ContactService.to.importMany(parsed);
|
|
await c.load();
|
|
Get.snackbar(
|
|
'Import kész',
|
|
imported.queued > 0
|
|
? '${imported.uploaded} feltöltve, ${imported.queued} offline '
|
|
'várólistán (net jöttével automatikusan felmegy).'
|
|
: '${imported.uploaded} kapcsolat importálva.',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
} catch (e) {
|
|
Get.snackbar('Import hiba', e.toString(),
|
|
snackPosition: SnackPosition.BOTTOM);
|
|
}
|
|
}
|
|
|
|
Future<void> _exportDialog(ContactsController c, String projectName) async {
|
|
final hungarian = true.obs;
|
|
|
|
await Get.dialog(AlertDialog(
|
|
title: const Text('Export CSV'),
|
|
content: Obx(() => SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
title: const Text('Magyar Excel-formátum'),
|
|
subtitle: const Text('pontosvessző elválasztó',
|
|
style: TextStyle(fontSize: 11)),
|
|
value: hungarian.value,
|
|
onChanged: (v) => hungarian.value = v,
|
|
)),
|
|
actions: [
|
|
TextButton(onPressed: Get.back, child: const Text('Mégse')),
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.ios_share, size: 18),
|
|
label: const Text('Export'),
|
|
onPressed: () async {
|
|
Get.back();
|
|
try {
|
|
await ContactExportService.exportAndShare(
|
|
contacts: c.filtered.map((it) => it.contact).toList(),
|
|
// contacts: c.items.map((it) => it.contact).toList(),
|
|
hungarian: hungarian.value,
|
|
projectName: projectName,
|
|
);
|
|
} catch (e) {
|
|
Get.snackbar('Export', 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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProjectRequired extends StatelessWidget {
|
|
final Project? project;
|
|
const _ProjectRequired({required this.project});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isLocalOnly = project?.isLocalOnly ?? false;
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.folder_off_outlined,
|
|
size: 48, color: Colors.grey.shade400),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
project == null
|
|
? 'Nincs aktív projekt.\nVálassz egyet a menüből.'
|
|
: 'A(z) "${project!.name}" projekt csak lokális.\n'
|
|
'A kapcsolatok megosztott (online) projekthez '
|
|
'tartoznak — válts megosztott projektre, vagy tedd '
|
|
'megosztottá ezt.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|