Kontaktok listázási hibájának javítása, csv export

This commit is contained in:
2026-07-08 22:13:30 +02:00
parent 7cd682fe59
commit 1ab656585b
9 changed files with 262 additions and 19 deletions
+1
View File
@@ -65,6 +65,7 @@ Future<void> main() async {
Get.put(AuthService()); Get.put(AuthService());
Get.put(DeviceIdentityService(), permanent: true); Get.put(DeviceIdentityService(), permanent: true);
await AppDatabase.instance.database; await AppDatabase.instance.database;
//await AppDatabase.instance.testOnly();
Get.put(ProjectService(), permanent: true); Get.put(ProjectService(), permanent: true);
Get.put(AppLogger(), permanent: true); Get.put(AppLogger(), permanent: true);
+6
View File
@@ -6,6 +6,8 @@
/// tábla dönti el. Az uuid szerveroldali (gen_random_uuid()). /// tábla dönti el. Az uuid szerveroldali (gen_random_uuid()).
class Contact { class Contact {
final String? id; // uuid — új rekordnál null, a szerver adja 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 name;
final String address; final String address;
final String phone; final String phone;
@@ -16,6 +18,7 @@ class Contact {
const Contact({ const Contact({
this.id, this.id,
required this.projectId,
required this.name, required this.name,
this.address = '', this.address = '',
this.phone = '', this.phone = '',
@@ -34,6 +37,7 @@ class Contact {
}) => }) =>
Contact( Contact(
id: id, id: id,
projectId: projectId,
name: name ?? this.name, name: name ?? this.name,
address: address ?? this.address, address: address ?? this.address,
phone: phone ?? this.phone, phone: phone ?? this.phone,
@@ -48,6 +52,7 @@ class Contact {
/// sosem a kliens állítja. /// sosem a kliens állítja.
Map<String, dynamic> toWriteMap() => { Map<String, dynamic> toWriteMap() => {
if (id != null) 'id': id, if (id != null) 'id': id,
'project_id': projectId,
'name': name.trim(), 'name': name.trim(),
'address': address.trim(), 'address': address.trim(),
'phone': phone.trim(), 'phone': phone.trim(),
@@ -57,6 +62,7 @@ class Contact {
factory Contact.fromMap(Map<String, dynamic> m) => Contact( factory Contact.fromMap(Map<String, dynamic> m) => Contact(
id: m['id'] as String?, id: m['id'] as String?,
projectId: m['project_id'] as String,
name: (m['name'] as String?) ?? '', name: (m['name'] as String?) ?? '',
address: (m['address'] as String?) ?? '', address: (m['address'] as String?) ?? '',
phone: (m['phone'] as String?) ?? '', phone: (m['phone'] as String?) ?? '',
@@ -1,4 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:terepi_seged/services/project_service.dart';
import '../../../../services/contact_service.dart'; import '../../../../services/contact_service.dart';
@@ -27,16 +28,22 @@ class ContactsController extends GetxController {
}).toList(); }).toList();
} }
Future<void> load() async { Future<void> load({bool silent = false}) async {
isLoading.value = true; 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 = ''; error.value = '';
try { try {
items.value = await ContactService.to.listMerged(); items.value = await ContactService.to.listMerged(projectId);
pendingCount.value = await ContactService.to.pendingCount(); pendingCount.value = await ContactService.to.pendingCount(projectId);
} catch (e) { } catch (e) {
error.value = _friendly(e); error.value = _friendly(e);
} finally { } finally {
isLoading.value = false; if (!silent) isLoading.value = false;
} }
} }
@@ -60,7 +67,10 @@ class ContactsController extends GetxController {
await ContactService.to await ContactService.to
.delete(id: it.contact.id, localUuid: it.localUuid); .delete(id: it.contact.id, localUuid: it.localUuid);
items.remove(it); 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) { } catch (e) {
Get.snackbar('Hiba', _friendly(e), snackPosition: SnackPosition.BOTTOM); Get.snackbar('Hiba', _friendly(e), snackPosition: SnackPosition.BOTTOM);
} }
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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 '../../../../models/contact.dart';
import '../../../../services/contact_service.dart'; import '../../../../services/contact_service.dart';
@@ -57,7 +59,11 @@ class _ContactEditViewState extends State<ContactEditView> {
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true); setState(() => _saving = true);
try { try {
final contact = (_original ?? const Contact(name: '')).copyWith( final contact = (_original ??
Contact(
name: '',
projectId: ProjectService.to.activeProject.value!.uuid))
.copyWith(
name: _name.text, name: _name.text,
address: _address.text, address: _address.text,
phone: _phone.text, phone: _phone.text,
@@ -66,6 +72,9 @@ class _ContactEditViewState extends State<ContactEditView> {
); );
final queued = await ContactService.to.save(contact); final queued = await ContactService.to.save(contact);
Get.back(result: queued); // a lista frissítéshez visszakapja Get.back(result: queued); // a lista frissítéshez visszakapja
if (Get.isRegistered<ContactsController>()) {
Get.find<ContactsController>().load(silent: true);
}
Get.snackbar(queued ? 'Elmentve (offline)' : 'Mentve', Get.snackbar(queued ? 'Elmentve (offline)' : 'Mentve',
queued ? '${contact.name} - feltöltés, amint van net' : contact.name, queued ? '${contact.name} - feltöltés, amint van net' : contact.name,
snackPosition: SnackPosition.BOTTOM); snackPosition: SnackPosition.BOTTOM);
@@ -3,6 +3,9 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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 'package:url_launcher/url_launcher.dart';
import '../../../../models/contact.dart'; import '../../../../models/contact.dart';
@@ -29,18 +32,42 @@ class ContactsView extends StatelessWidget {
body: const _NoAccess(), 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()); final c = Get.put(ContactsController());
return Scaffold( return Scaffold(
appBar: AppBar( 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: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.upload_file), icon: const Icon(Icons.upload_file),
tooltip: 'Import CSV', tooltip: 'Import CSV',
onPressed: () => _importCsv(c), onPressed: () => _importCsv(c),
), ),
IconButton(
icon: const Icon(Icons.ios_share),
tooltip: 'Export CSV',
onPressed: () => _exportDialog(c, activeProject.name),
),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
tooltip: 'Frissítés', tooltip: 'Frissítés',
@@ -143,8 +170,9 @@ class ContactsView extends StatelessWidget {
snackPosition: SnackPosition.BOTTOM); snackPosition: SnackPosition.BOTTOM);
return; return;
} }
final activeProject = ProjectService.to.activeProject.value;
final parsed = await ContactService.parseCsv(File(path)); final parsed = await ContactService.parseCsv(File(path),
projectId: activeProject!.uuid);
if (parsed.isEmpty) { if (parsed.isEmpty) {
Get.snackbar('Import', 'Nem található érvényes sor a fájlban.', Get.snackbar('Import', 'Nem található érvényes sor a fájlban.',
snackPosition: SnackPosition.BOTTOM); snackPosition: SnackPosition.BOTTOM);
@@ -167,15 +195,59 @@ class ContactsView extends StatelessWidget {
)); ));
if (ok != true) return; if (ok != true) return;
final inserted = await ContactService.to.importMany(parsed); final imported = await ContactService.to.importMany(parsed);
await c.load(); await c.load();
Get.snackbar('Import kész', '$inserted kapcsolat importálva.', Get.snackbar(
snackPosition: SnackPosition.BOTTOM); '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) { } catch (e) {
Get.snackbar('Import hiba', e.toString(), Get.snackbar('Import hiba', e.toString(),
snackPosition: SnackPosition.BOTTOM); 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 ────────────────────────────────────────────────────────────── // ── 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,
),
],
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'dart:ui' show FontFeature; import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
//import 'package:flutter/widget_previews.dart';
import 'package:get/get.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_list_sheet.dart';
import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_settings_dialog.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.posText,
required this.negText}); required this.negText});
//@Preview(name: 'Example')
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cm = value.abs() * 100; final cm = value.abs() * 100;
+29 -2
View File
@@ -1294,6 +1294,7 @@ class AppDatabase {
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS contacts_outbox ( CREATE TABLE IF NOT EXISTS contacts_outbox (
local_uuid TEXT PRIMARY KEY, local_uuid TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '', address TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
@@ -1302,6 +1303,8 @@ class AppDatabase {
created_at TEXT NOT NULL created_at TEXT NOT NULL
) )
'''); ''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project '
'ON contacts_outbox(project_id)');
} }
Future<void> insertPendingContact(Map<String, dynamic> row) async { Future<void> insertPendingContact(Map<String, dynamic> row) async {
@@ -1309,8 +1312,18 @@ class AppDatabase {
await db.insert('contacts_outbox', row); await db.insert('contacts_outbox', row);
} }
Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> listPendingContacts(
{String? projectId}) async {
final db = await database; 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'); return db.query('contacts_outbox', orderBy: 'created_at ASC');
} }
@@ -1327,10 +1340,24 @@ class AppDatabase {
where: 'local_uuid = ?', whereArgs: [localUuid]); where: 'local_uuid = ?', whereArgs: [localUuid]);
} }
Future<int> countPendingContacts() async { Future<int> countPendingContacts({String? projectId}) async {
final db = await database; 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( return Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM contacts_outbox')) ?? await db.rawQuery('SELECT COUNT(*) FROM contacts_outbox')) ??
0; 0;
} }
// Future<void> 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)');
// }
} }
+77
View File
@@ -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<void> exportAndShare({
required List<Contact> contacts,
required bool hungarian,
String projectName = '',
}) async {
if (contacts.isEmpty) {
throw StateError('Nincs exportálható kapcsolat.');
}
final rows = List<Contact>.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',
));
}
}
+11 -4
View File
@@ -46,8 +46,8 @@ class ContactService extends GetxService {
/// A megjelenítendő lista: a Supabase-ből lehúzott kapcsolatok ELÉ /// 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 /// 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. /// az `isPending` jelöli — a UI kis felhő-ikonnal mutatja őket.
Future<List<ContactWithState>> listMerged() async { Future<List<ContactWithState>> listMerged(String projectId) async {
final pending = (await _db.listPendingContacts()) final pending = (await _db.listPendingContacts(projectId: projectId))
.map((m) => ContactWithState( .map((m) => ContactWithState(
contact: Contact.fromMap(m), contact: Contact.fromMap(m),
isPending: true, isPending: true,
@@ -60,6 +60,7 @@ class ContactService extends GetxService {
final rows = await _client final rows = await _client
.from('terepi_seged_contacts') .from('terepi_seged_contacts')
.select() .select()
.eq('project_id', projectId)
.order('name', ascending: true); .order('name', ascending: true);
remote = rows remote = rows
.map((r) => .map((r) =>
@@ -101,6 +102,7 @@ class ContactService extends GetxService {
// Offline (vagy online-hiba) új rekord → outbox. // Offline (vagy online-hiba) új rekord → outbox.
await _db.insertPendingContact({ await _db.insertPendingContact({
'local_uuid': _uuid.v4(), 'local_uuid': _uuid.v4(),
'project_id': c.projectId,
'name': c.name.trim(), 'name': c.name.trim(),
'address': c.address.trim(), 'address': c.address.trim(),
'phone': c.phone.trim(), 'phone': c.phone.trim(),
@@ -152,6 +154,7 @@ class ContactService extends GetxService {
// local_uuid mint kliens-kulcs → az ismételt próba idempotens. // local_uuid mint kliens-kulcs → az ismételt próba idempotens.
await _client.from('terepi_seged_contacts').upsert({ await _client.from('terepi_seged_contacts').upsert({
'client_uuid': m['local_uuid'], 'client_uuid': m['local_uuid'],
'project_id': m['project_id'],
'name': m['name'], 'name': m['name'],
'address': m['address'], 'address': m['address'],
'phone': m['phone'], 'phone': m['phone'],
@@ -169,13 +172,15 @@ class ContactService extends GetxService {
return uploaded; return uploaded;
} }
Future<int> pendingCount() => _db.countPendingContacts(); Future<int> pendingCount(String projectId) =>
_db.countPendingContacts(projectId: projectId);
// ═════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════
// CSV import (változatlan a korábbihoz képest) // CSV import (változatlan a korábbihoz képest)
// ═════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════
static Future<List<Contact>> parseCsv(File file) async { static Future<List<Contact>> parseCsv(File file,
{required String projectId}) async {
final bytes = await file.readAsBytes(); final bytes = await file.readAsBytes();
String text; String text;
try { try {
@@ -256,6 +261,7 @@ class ContactService extends GetxService {
final name = cell(r, iName); final name = cell(r, iName);
if (name.isEmpty) continue; if (name.isEmpty) continue;
contacts.add(Contact( contacts.add(Contact(
projectId: projectId,
name: name, name: name,
address: cell(r, iAddr), address: cell(r, iAddr),
phone: cell(r, iPhone), phone: cell(r, iPhone),
@@ -285,6 +291,7 @@ class ContactService extends GetxService {
for (final c in contacts) { for (final c in contacts) {
await _db.insertPendingContact({ await _db.insertPendingContact({
'local_uuid': _uuid.v4(), 'local_uuid': _uuid.v4(),
'project_id': c.projectId,
'name': c.name.trim(), 'name': c.name.trim(),
'address': c.address.trim(), 'address': c.address.trim(),
'phone': c.phone.trim(), 'phone': c.phone.trim(),