import
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user