Files
MobilApp/lib/pages/contacts/presentation/views/contacts_view.dart
T

440 lines
15 KiB
Dart
Raw Normal View History

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]);
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,
),
],
),
),
);
}
}