diff --git a/lib/main.dart b/lib/main.dart index a2b518a..a6f8afe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,6 +65,7 @@ Future main() async { Get.put(AuthService()); Get.put(DeviceIdentityService(), permanent: true); await AppDatabase.instance.database; + //await AppDatabase.instance.testOnly(); Get.put(ProjectService(), permanent: true); Get.put(AppLogger(), permanent: true); diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 18993df..7ffa41e 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -6,6 +6,8 @@ /// 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 + projectId; // az AKTUÁLIS projekt uuid-ja (terepi_seged_projects.id) final String name; final String address; final String phone; @@ -16,6 +18,7 @@ class Contact { const Contact({ this.id, + required this.projectId, required this.name, this.address = '', this.phone = '', @@ -34,6 +37,7 @@ class Contact { }) => Contact( id: id, + projectId: projectId, name: name ?? this.name, address: address ?? this.address, phone: phone ?? this.phone, @@ -48,6 +52,7 @@ class Contact { /// sosem a kliens állítja. Map toWriteMap() => { if (id != null) 'id': id, + 'project_id': projectId, 'name': name.trim(), 'address': address.trim(), 'phone': phone.trim(), @@ -57,6 +62,7 @@ class Contact { factory Contact.fromMap(Map m) => Contact( id: m['id'] as String?, + projectId: m['project_id'] as String, name: (m['name'] as String?) ?? '', address: (m['address'] as String?) ?? '', phone: (m['phone'] as String?) ?? '', diff --git a/lib/pages/contacts/presentation/controllers/contacts_controller.dart b/lib/pages/contacts/presentation/controllers/contacts_controller.dart index 9810ab1..60443f9 100644 --- a/lib/pages/contacts/presentation/controllers/contacts_controller.dart +++ b/lib/pages/contacts/presentation/controllers/contacts_controller.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:terepi_seged/services/project_service.dart'; import '../../../../services/contact_service.dart'; @@ -27,16 +28,22 @@ class ContactsController extends GetxController { }).toList(); } - Future load() async { - isLoading.value = true; + Future load({bool silent = false}) async { + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId == null) { + error.value = 'Nincs aktív projekt.'; + isLoading.value = false; + return; + } + if (!silent) isLoading.value = true; error.value = ''; try { - items.value = await ContactService.to.listMerged(); - pendingCount.value = await ContactService.to.pendingCount(); + items.value = await ContactService.to.listMerged(projectId); + pendingCount.value = await ContactService.to.pendingCount(projectId); } catch (e) { error.value = _friendly(e); } finally { - isLoading.value = false; + if (!silent) isLoading.value = false; } } @@ -60,7 +67,10 @@ class ContactsController extends GetxController { await ContactService.to .delete(id: it.contact.id, localUuid: it.localUuid); items.remove(it); - pendingCount.value = await ContactService.to.pendingCount(); + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId != null) { + pendingCount.value = await ContactService.to.pendingCount(projectId); + } } catch (e) { Get.snackbar('Hiba', _friendly(e), snackPosition: SnackPosition.BOTTOM); } diff --git a/lib/pages/contacts/presentation/views/contact_edit_view.dart b/lib/pages/contacts/presentation/views/contact_edit_view.dart index 84b2ef9..2dabc9b 100644 --- a/lib/pages/contacts/presentation/views/contact_edit_view.dart +++ b/lib/pages/contacts/presentation/views/contact_edit_view.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:terepi_seged/pages/contacts/presentation/controllers/contacts_controller.dart'; +import 'package:terepi_seged/services/project_service.dart'; import '../../../../models/contact.dart'; import '../../../../services/contact_service.dart'; @@ -57,7 +59,11 @@ class _ContactEditViewState extends State { if (!(_formKey.currentState?.validate() ?? false)) return; setState(() => _saving = true); try { - final contact = (_original ?? const Contact(name: '')).copyWith( + final contact = (_original ?? + Contact( + name: '', + projectId: ProjectService.to.activeProject.value!.uuid)) + .copyWith( name: _name.text, address: _address.text, phone: _phone.text, @@ -66,6 +72,9 @@ class _ContactEditViewState extends State { ); final queued = await ContactService.to.save(contact); Get.back(result: queued); // a lista frissítéshez visszakapja + if (Get.isRegistered()) { + Get.find().load(silent: true); + } Get.snackbar(queued ? 'Elmentve (offline)' : 'Mentve', queued ? '${contact.name} - feltöltés, amint van net' : contact.name, snackPosition: SnackPosition.BOTTOM); diff --git a/lib/pages/contacts/presentation/views/contacts_view.dart b/lib/pages/contacts/presentation/views/contacts_view.dart index 540bf2f..b18cc62 100644 --- a/lib/pages/contacts/presentation/views/contacts_view.dart +++ b/lib/pages/contacts/presentation/views/contacts_view.dart @@ -3,6 +3,9 @@ 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'; @@ -29,18 +32,42 @@ class ContactsView extends StatelessWidget { 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: const Text('Kapcsolatok'), + 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', @@ -143,8 +170,9 @@ class ContactsView extends StatelessWidget { snackPosition: SnackPosition.BOTTOM); return; } - - final parsed = await ContactService.parseCsv(File(path)); + 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); @@ -167,15 +195,59 @@ class ContactsView extends StatelessWidget { )); if (ok != true) return; - final inserted = await ContactService.to.importMany(parsed); + final imported = await ContactService.to.importMany(parsed); await c.load(); - Get.snackbar('Import kész', '$inserted kapcsolat importálva.', - snackPosition: SnackPosition.BOTTOM); + 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 _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 ────────────────────────────────────────────────────────────── @@ -333,3 +405,35 @@ class _ErrorState extends StatelessWidget { ); } } + +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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart index 2e8400e..b2b239e 100644 --- a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart +++ b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; +//import 'package:flutter/widget_previews.dart'; import 'package:get/get.dart'; import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart'; import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_settings_dialog.dart'; @@ -619,6 +620,7 @@ class _DevRow extends StatelessWidget { required this.posText, required this.negText}); + //@Preview(name: 'Example') @override Widget build(BuildContext context) { final cm = value.abs() * 100; diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 4951ffc..837afcc 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -1294,6 +1294,7 @@ class AppDatabase { await db.execute(''' CREATE TABLE IF NOT EXISTS contacts_outbox ( local_uuid TEXT PRIMARY KEY, + project_id TEXT NOT NULL, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', @@ -1302,6 +1303,8 @@ class AppDatabase { created_at TEXT NOT NULL ) '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project ' + 'ON contacts_outbox(project_id)'); } Future insertPendingContact(Map row) async { @@ -1309,8 +1312,18 @@ class AppDatabase { await db.insert('contacts_outbox', row); } - Future>> listPendingContacts() async { + /// [projectId] NÉLKÜL (a flush-hoz) MINDEN várólistás sort ad vissza — + /// a feltöltés projekttől függetlenül fusson. [projectId]-vel (a UI + /// listához) csak az adott projekt pending sorait. + Future>> listPendingContacts( + {String? projectId}) async { final db = await database; + if (projectId != null) { + return db.query('contacts_outbox', + where: 'project_id = ?', + whereArgs: [projectId], + orderBy: 'created_at ASC'); + } return db.query('contacts_outbox', orderBy: 'created_at ASC'); } @@ -1327,10 +1340,24 @@ class AppDatabase { where: 'local_uuid = ?', whereArgs: [localUuid]); } - Future countPendingContacts() async { + Future countPendingContacts({String? projectId}) async { final db = await database; + if (projectId != null) { + return Sqflite.firstIntValue(await db.rawQuery( + 'SELECT COUNT(*) FROM contacts_outbox WHERE project_id = ?', + [projectId])) ?? + 0; + } return Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM contacts_outbox')) ?? 0; } + + // Future testOnly() async { + // final db = await database; + // await db.execute( + // "ALTER TABLE contacts_outbox ADD COLUMN project_id TEXT NOT NULL DEFAULT ''"); + // await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project ' + // 'ON contacts_outbox(project_id)'); + // } } diff --git a/lib/services/contact_export_service.dart b/lib/services/contact_export_service.dart new file mode 100644 index 0000000..e87c061 --- /dev/null +++ b/lib/services/contact_export_service.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../models/contact.dart'; + +/// Kapcsolatok CSV-export — a kitűzési jegyzőkönyv-exporttal azonos elvek. +/// +/// Két dialektus: +/// * MAGYAR (Excel-barát): pontosvessző elválasztó, UTF-8 BOM-mal +/// (enélkül az Excel elrontja az ékezeteket) +/// * NEMZETKÖZI: vessző elválasztó +/// +/// Mivel a lista már projekthez szűrt (ContactsController.filtered), az +/// export mindig a JELENLEG LÁTOTT (kereséssel/projekttel szűrt) sorokat +/// menti — amit a képernyőn látsz, azt kapod a fájlban is. +class ContactExportService { + ContactExportService._(); + + static Future exportAndShare({ + required List contacts, + required bool hungarian, + String projectName = '', + }) async { + if (contacts.isEmpty) { + throw StateError('Nincs exportálható kapcsolat.'); + } + + final rows = List.from(contacts) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + final sep = hungarian ? ';' : ','; + + String txt(String s) { + final v = s.trim(); + return v.contains(sep) || v.contains('"') || v.contains('\n') + ? '"${v.replaceAll('"', '""')}"' + : v; + } + + final header = ['Nev', 'Cim', 'Telefon', 'Email', 'Megjegyzes'].join(sep); + + final sb = StringBuffer(); + sb.write('\uFEFF'); // UTF-8 BOM — az Excel enélkül elrontja az ékezeteket + sb.writeln(header); + for (final c in rows) { + sb.writeln([ + txt(c.name), + txt(c.address), + txt(c.phone), + txt(c.email), + txt(c.note), + ].join(sep)); + } + + final dir = await getTemporaryDirectory(); + final stamp = DateTime.now() + .toIso8601String() + .substring(0, 16) + .replaceAll(':', '') + .replaceAll('-', '') + .replaceAll('T', '_'); + final safeName = projectName.isEmpty + ? 'kapcsolatok' + : 'kapcsolatok_${projectName.replaceAll(RegExp(r'[^\w\-]'), '_')}'; + final path = p.join(dir.path, '${safeName}_$stamp.csv'); + await File(path).writeAsString(sb.toString(), flush: true); + + await SharePlus.instance.share(ShareParams( + files: [XFile(path, mimeType: 'text/csv')], + subject: 'Kapcsolatok — $projectName', + text: '${rows.length} kapcsolat', + )); + } +} diff --git a/lib/services/contact_service.dart b/lib/services/contact_service.dart index 27fbf9d..c4f9d84 100644 --- a/lib/services/contact_service.dart +++ b/lib/services/contact_service.dart @@ -46,8 +46,8 @@ class ContactService extends GetxService { /// 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> listMerged() async { - final pending = (await _db.listPendingContacts()) + Future> listMerged(String projectId) async { + final pending = (await _db.listPendingContacts(projectId: projectId)) .map((m) => ContactWithState( contact: Contact.fromMap(m), isPending: true, @@ -60,6 +60,7 @@ class ContactService extends GetxService { final rows = await _client .from('terepi_seged_contacts') .select() + .eq('project_id', projectId) .order('name', ascending: true); remote = rows .map((r) => @@ -101,6 +102,7 @@ class ContactService extends GetxService { // Offline (vagy online-hiba) új rekord → outbox. await _db.insertPendingContact({ 'local_uuid': _uuid.v4(), + 'project_id': c.projectId, 'name': c.name.trim(), 'address': c.address.trim(), 'phone': c.phone.trim(), @@ -152,6 +154,7 @@ class ContactService extends GetxService { // local_uuid mint kliens-kulcs → az ismételt próba idempotens. await _client.from('terepi_seged_contacts').upsert({ 'client_uuid': m['local_uuid'], + 'project_id': m['project_id'], 'name': m['name'], 'address': m['address'], 'phone': m['phone'], @@ -169,13 +172,15 @@ class ContactService extends GetxService { return uploaded; } - Future pendingCount() => _db.countPendingContacts(); + Future pendingCount(String projectId) => + _db.countPendingContacts(projectId: projectId); // ═════════════════════════════════════════════════════════════════ // CSV import (változatlan a korábbihoz képest) // ═════════════════════════════════════════════════════════════════ - static Future> parseCsv(File file) async { + static Future> parseCsv(File file, + {required String projectId}) async { final bytes = await file.readAsBytes(); String text; try { @@ -256,6 +261,7 @@ class ContactService extends GetxService { final name = cell(r, iName); if (name.isEmpty) continue; contacts.add(Contact( + projectId: projectId, name: name, address: cell(r, iAddr), phone: cell(r, iPhone), @@ -285,6 +291,7 @@ class ContactService extends GetxService { for (final c in contacts) { await _db.insertPendingContact({ 'local_uuid': _uuid.v4(), + 'project_id': c.projectId, 'name': c.name.trim(), 'address': c.address.trim(), 'phone': c.phone.trim(),