diff --git a/lib/features/igel/data/import_service.dart b/lib/features/igel/data/import_service.dart new file mode 100644 index 0000000..f8414fd --- /dev/null +++ b/lib/features/igel/data/import_service.dart @@ -0,0 +1,431 @@ +// lib/features/igel/data/import_service.dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:csv/csv.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../igel/data/igel_repository.dart'; +import '../../messwerte/data/messwerte_repository.dart'; +import '../domain/igel.dart'; + +class IgelCsvImportService { + final IgelRepository igelRepo; + final MesswerteRepository messRepo; + + IgelCsvImportService({required this.igelRepo, required this.messRepo}); + + /// Öffnet den Dateiauswahldialog, parst und importiert die denormalisierte CSV. + /// Zeigt am Ende ein SnackBar-Ergebnis. + Future pickAndImport(BuildContext context) async { + try { + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + withData: true, + ); + if (picked == null || picked.files.isEmpty) return; + final file = picked.files.first; + final bytes = file.bytes; + if (bytes == null) throw Exception('Leere Datei'); + + final res = await _importBytes(bytes); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'CSV importiert: ${res.rows} Zeilen – ' + 'Igel neu: ${res.igelsCreated}, zugeordnet: ${res.igelsMatched}, ' + 'Messwerte neu: ${res.messwerteCreated}' + '${res.errors.isEmpty ? '' : ' – Fehler: ${res.errors.length}'}', + ), + duration: const Duration(seconds: 6), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import fehlgeschlagen: $e')), + ); + } + } + } + + /// Öffnet Datei-Dialog und importiert nur Messwerte für *einen* Igel. + /// Erlaubte CSV-Formate: + /// - Denormalisierte Export-CSV: Zeilen mit passendem igel_id *oder* igel_name werden importiert + /// - "schlanke" CSV nur mit Messwert-Spalten: messwert_datum, messwert_gewicht, (optional) messwert_behandlung, messwert_bemerkung + Future pickAndImportForIgel( + BuildContext context, { + required int igelId, + String? igelNameHint, + }) async { + try { + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + withData: true, + ); + if (picked == null || picked.files.isEmpty) return; + final file = picked.files.first; + final bytes = file.bytes; + if (bytes == null) throw Exception('Leere Datei'); + + final res = await _importBytesForIgel( + bytes, + igelId: igelId, + igelNameHint: igelNameHint, + ); + if (context.mounted) { + final msg = res.rows == 0 + ? 'Keine passenden Zeilen gefunden.' + : 'CSV importiert: ${res.rows} passende Zeilen – Messwerte neu: ${res.messwerteCreated}' + '${res.errors.isEmpty ? '' : ' – Fehler: ${res.errors.length}'}'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg), duration: const Duration(seconds: 6)), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import fehlgeschlagen: $e')), + ); + } + } + } + + Future<_ImportResult> _importBytesForIgel( + Uint8List bytes, { + required int igelId, + String? igelNameHint, + }) async { + final content = utf8.decode(bytes, allowMalformed: true); + final firstLine = content.split('\n').first; + final delimiter = _detectDelimiter(firstLine); + + final rows = const CsvToListConverter( + eol: '\n', + shouldParseNumbers: false, + ).convert( + content, + fieldDelimiter: delimiter, + ); + if (rows.isEmpty) throw Exception('CSV ohne Daten'); + + final header = rows.first.map((e) => (e?.toString() ?? '').trim()).toList(); + int idx(String name) { + final lower = name.toLowerCase(); + for (var i = 0; i < header.length; i++) { + if ((header[i].toString()).toLowerCase() == lower) return i; + } + return -1; + } + + // Optional vorhandene Kopfspalten (denormalisierte CSV) + final hIgelId = idx('igel_id'); + final hIgelName = idx('igel_name'); + + final hMwDatum = idx('messwert_datum'); + final hMwGewicht = idx('messwert_gewicht'); + final hMwBehandlung = idx('messwert_behandlung'); + final hMwBemerkung = idx('messwert_bemerkung'); + + // Alternativ: "schlanke" CSV nur mit Messwert-Spalten ohne igel_* → dann importieren wir *alle* Zeilen in diesen Igel. + final hasAnyIgelCol = (hIgelId >= 0) || (hIgelName >= 0); + + final res = _ImportResult(rows: 0); + for (var r = 1; r < rows.length; r++) { + final row = rows[r]; + + String? s(int col) { + if (col < 0 || col >= row.length) return null; + final v = row[col]; + final t = v?.toString().trim(); + return (t == null || t.isEmpty) ? null : t; + } + + bool belongsToThisIgel = true; + if (hasAnyIgelCol) { + belongsToThisIgel = false; + // 1) igel_id Match? + final idStr = s(hIgelId); + if (idStr != null) { + final parsed = int.tryParse(idStr); + if (parsed != null && parsed == igelId) { + belongsToThisIgel = true; + } + } + // 2) igel_name Match (Fallback) + if (!belongsToThisIgel && hIgelName >= 0) { + final csvName = (s(hIgelName) ?? '').toLowerCase(); + final hint = (igelNameHint ?? '').toLowerCase(); + if (csvName.isNotEmpty && hint.isNotEmpty && csvName == hint) { + belongsToThisIgel = true; + } + } + } + + if (!belongsToThisIgel) continue; + + // Ab hier zählt die Zeile als passend + res.rows++; + + final datumStr = s(hMwDatum); + final gewichtStr = s(hMwGewicht); + if (datumStr == null && gewichtStr == null) { + continue; // nur "Stammdatenzeile" → bei Detail-Import ignorieren + } + + final dt = _parseDateTime(datumStr); + final gewicht = gewichtStr != null ? int.tryParse(gewichtStr) : null; + + if (dt == null || gewicht == null) { + res.errors.add( + 'Zeile ${r + 1}: Messwert unvollständig/ungültig (Datum="$datumStr", Gewicht="$gewichtStr").', + ); + continue; + } + + try { + await messRepo.create( + igelId: igelId, + datum: dt, + gewicht: gewicht, + behandlung: s(hMwBehandlung), + bemerkung: s(hMwBemerkung), + ); + res.messwerteCreated++; + } catch (e) { + res.errors.add('Zeile ${r + 1}: Messwert anlegen fehlgeschlagen: $e'); + } + } + return res; + } + + // ---- Kernlogik ------------------------------------------------------------ + + Future<_ImportResult> _importBytes(Uint8List bytes) async { + // CSV einlesen (UTF8 mit BOM erkennen) + final content = utf8.decode(bytes, allowMalformed: true); + + // Delimiter autodetect: ; , \t + final firstLine = content.split('\n').first; + final delimiter = _detectDelimiter(firstLine); + + final rows = const CsvToListConverter( + eol: '\n', + shouldParseNumbers: false, + ).convert( + content, + fieldDelimiter: delimiter, + ); + + if (rows.isEmpty) throw Exception('CSV ohne Daten'); + final header = rows.first.map((e) => (e?.toString() ?? '').trim()).toList(); + + // Header -> Index + int idx(String name) { + final lower = name.toLowerCase(); + for (var i = 0; i < header.length; i++) { + if ((header[i].toString()).toLowerCase() == lower) return i; + } + return -1; + } + + final hIgelName = idx('igel_name'); + final hIgelGender = idx('igel_gender'); + final hIgelFeature = idx('igel_feature'); + final hIgelNote = idx('igel_note'); + final hIgelRescuedAt = idx('igel_rescued_at'); + final hIgelLocation = idx('igel_location'); + + final hMwDatum = idx('messwert_datum'); + final hMwGewicht = idx('messwert_gewicht'); + final hMwBehandlung = idx('messwert_behandlung'); + final hMwBemerkung = idx('messwert_bemerkung'); + + if (hIgelName < 0) { + throw Exception('Spalte "igel_name" nicht gefunden.'); + } + + // vorhandene Igel (Name -> Igel) + final existing = await igelRepo.list(); + final byName = { + for (final ig in existing) ig.name.toLowerCase().trim(): ig + }; + + final createdIgels = {}; + var res = _ImportResult(rows: rows.length - 1); + + // jede Datenzeile + for (var r = 1; r < rows.length; r++) { + final row = rows[r]; + + // Helper: safe get + String? s(int col) { + if (col < 0 || col >= row.length) return null; + final v = row[col]; + final t = v?.toString().trim(); + return (t == null || t.isEmpty) ? null : t; + } + + // ---- Igel bestimmen/erstellen + final name = s(hIgelName); + if (name == null) { + res.errors.add('Zeile ${r + 1}: igel_name fehlt.'); + continue; + } + final key = name.toLowerCase(); + + Igel ig; + if (byName.containsKey(key)) { + ig = byName[key]!; + res.igelsMatched++; + } else if (createdIgels.containsKey(key)) { + ig = createdIgels[key]!; + res.igelsMatched++; + } else { + // neu anlegen + try { + final gender = s(hIgelGender); + final feature = s(hIgelFeature); + final note = s(hIgelNote); + final location = s(hIgelLocation); + final rescuedAt = + _parseDate(s(hIgelRescuedAt)); // yyyy-MM-dd (vom Export) + + final newId = await igelRepo.create( + name, + note: note, + gender: gender, + feature: feature, + rescuedAt: rescuedAt, + location: location, + ); + // Holen (oder minimal zusammensetzen) + ig = Igel( + id: newId.id, + name: name, + gender: gender, + note: note, + feature: feature, + rescuedAt: rescuedAt, + location: location, + createdAt: null, + updatedAt: null, + ); + createdIgels[key] = ig; + byName[key] = ig; + res.igelsCreated++; + } catch (e) { + res.errors + .add('Zeile ${r + 1}: Igel "$name" anlegen fehlgeschlagen: $e'); + continue; + } + } + + // ---- Messwert (optional) + final datumStr = s(hMwDatum); + final gewichtStr = s(hMwGewicht); + + if (datumStr == null && gewichtStr == null) { + // OK – Zeile kann nur Stammdaten repräsentieren (Igel ohne Messwerte) + continue; + } + + final dt = _parseDateTime(datumStr); + final gewicht = gewichtStr != null ? int.tryParse(gewichtStr) : null; + + if (dt == null || gewicht == null) { + res.errors.add( + 'Zeile ${r + 1}: Messwert unvollständig/ungültig (Datum="$datumStr", Gewicht="$gewichtStr").', + ); + continue; + } + + try { + await messRepo.create( + igelId: ig.id, + datum: dt, + gewicht: gewicht, + behandlung: s(hMwBehandlung), + bemerkung: s(hMwBemerkung), + ); + res.messwerteCreated++; + } catch (e) { + res.errors.add('Zeile ${r + 1}: Messwert anlegen fehlgeschlagen: $e'); + } + } + + return res; + } + + String _detectDelimiter(String firstLine) { + final counts = { + ';': ';'.allMatches(firstLine).length, + ',': ','.allMatches(firstLine).length, + '\t': '\t'.allMatches(firstLine).length, + }; + // nimm den häufigsten + counts.removeWhere((k, v) => v == 0); + if (counts.isEmpty) return ';'; // Default wie Export + counts.entries.toList().sort((a, b) => b.value.compareTo(a.value)); + return counts.entries.first.key; + } + + DateTime? _parseDate(String? s) { + if (s == null) return null; + final trims = s.trim(); + if (trims.isEmpty) return null; + // Export nutzt yyyy-MM-dd, akzeptiere aber auch dd.MM.yyyy + final fmts = [ + DateFormat('yyyy-MM-dd'), + DateFormat('dd.MM.yyyy'), + ]; + for (final f in fmts) { + try { + return f.parse(trims, true).toLocal(); + } catch (_) {} + } + return null; + } + + DateTime? _parseDateTime(String? s) { + if (s == null) return null; + final trims = s.trim(); + if (trims.isEmpty) return null; + final fmts = [ + DateFormat('yyyy-MM-dd HH:mm'), + DateFormat('dd.MM.yyyy HH:mm'), + DateFormat('yyyy-MM-ddTHH:mm'), + DateFormat('yyyy-MM-ddTHH:mm:ss'), + DateFormat('yyyy-MM-dd'), + DateFormat('dd.MM.yyyy'), + ]; + for (final f in fmts) { + try { + return f.parse(trims, true).toLocal(); + } catch (_) {} + } + return DateTime.tryParse(trims)?.toLocal(); + } +} + +class _ImportResult { + int rows; // eingelesene (Daten-)Zeilen + int igelsCreated; // neu angelegte Igel + int igelsMatched; // vorhandene/zugeordnete Igel + int messwerteCreated; // neu angelegte Messwerte + final List errors; + + _ImportResult({ + this.rows = 0, + this.igelsCreated = 0, + this.igelsMatched = 0, + this.messwerteCreated = 0, + List? errors, + }) : errors = errors ?? []; +} diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index db6cdf6..f9c06a4 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -23,6 +23,7 @@ import '../../igel/data/igel_images_repository.dart'; import '../../igel/data/igel_repository.dart'; import '../domain/igel.dart'; import '../domain/igel_image.dart'; +import '../../igel/data/import_service.dart'; // Messwerte import '../../messwerte/data/messwerte_repository.dart'; @@ -879,6 +880,24 @@ class _IgelDetailState extends ConsumerState { _basicsChanged ? 'Änderungen speichern' : 'Keine Änderungen', icon: const Icon(Icons.save), ), + IconButton( + tooltip: 'CSV für diesen Igel importieren…', + icon: const Icon(Icons.upload_file), + onPressed: () async { + final service = IgelCsvImportService( + igelRepo: ref.read(igelRepoProvider), + messRepo: ref.read(messwerteRepoProvider), + ); + await service.pickAndImportForIgel( + context, + igelId: widget.igelId, + igelNameHint: igel?.name, // hilft beim Name-Matching + ); + // danach Messwerte neu laden + messwerte = await messRepo.list(widget.igelId); + if (mounted) setState(() {}); + }, + ), PopupMenuButton( tooltip: 'Export', onSelected: (v) async { diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index 864faa1..412c096 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -15,6 +15,8 @@ import '../domain/igel.dart'; import '../../messwerte/data/messwerte_repository.dart'; import '../../messwerte/domain/messwert.dart'; +import '../../igel/data/import_service.dart'; + class IgelListScreen extends ConsumerStatefulWidget { const IgelListScreen({super.key}); @override @@ -276,7 +278,18 @@ class _IgelListState extends ConsumerState { appBar: AppBar( title: const Text('Meine Igel'), actions: [ - // ⬇️ Export-Menü + IconButton( + tooltip: 'CSV importieren…', + icon: const Icon(Icons.upload_file), + onPressed: () async { + final service = IgelCsvImportService( + igelRepo: ref.read(igelRepoProvider), + messRepo: ref.read(messwerteRepoProvider), + ); + await service.pickAndImport(context); + await _load(); // nach Import Liste aktualisieren + }, + ), // ⬇️ Export-Menü PopupMenuButton( onSelected: (v) async { if (v == 'export_csv_denorm') { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3893df6..41c7a0a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import file_picker import file_saver import file_selector_macos import flutter_secure_storage_macos @@ -13,6 +14,7 @@ import printing import share_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 2c1a290..bbfe962 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csv: + dependency: "direct main" + description: + name: csv + sha256: "63ed2871dd6471193dffc52c0e6c76fb86269c00244d244297abbb355c84a86e" + url: "https://pub.dev" + source: hosted + version: "5.1.1" dart_style: dependency: transitive description: @@ -246,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" file_saver: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e6aee9b..da292a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,9 @@ dependencies: file_saver: ^0.2.12 pdf: ^3.11.0 printing: ^5.13.4 - + csv: ^5.0.2 + file_picker: ^8.0.0 + dev_dependencies: build_runner: ^2.4.11 json_serializable: ^6.9.0