This commit is contained in:
2025-10-24 15:21:05 +02:00
parent 35c2231cbb
commit 5b476b6675
6 changed files with 485 additions and 2 deletions
+431
View File
@@ -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<void> 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<void> 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 = <String, Igel>{
for (final ig in existing) ig.name.toLowerCase().trim(): ig
};
final createdIgels = <String, Igel>{};
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<String> errors;
_ImportResult({
this.rows = 0,
this.igelsCreated = 0,
this.igelsMatched = 0,
this.messwerteCreated = 0,
List<String>? errors,
}) : errors = errors ?? [];
}
@@ -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<IgelDetailScreen> {
_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<String>(
tooltip: 'Export',
onSelected: (v) async {
@@ -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<IgelListScreen> {
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<String>(
onSelected: (v) async {
if (v == 'export_csv_denorm') {
@@ -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"))
+16
View File
@@ -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:
+3 -1
View File
@@ -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