Export CSV/PDF
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// lib/features/igel/data/export_service.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../igel/data/igel_repository.dart';
|
||||
import '../../messwerte/data/messwerte_repository.dart';
|
||||
import '../../messwerte/domain/messwert.dart';
|
||||
|
||||
class IgelExportService {
|
||||
final IgelRepository igelRepo;
|
||||
final MesswerteRepository messRepo;
|
||||
|
||||
IgelExportService({
|
||||
required this.igelRepo,
|
||||
required this.messRepo,
|
||||
});
|
||||
|
||||
String _fmtDate(DateTime? d) =>
|
||||
d == null ? '' : DateFormat('yyyy-MM-dd').format(d);
|
||||
|
||||
String _fmtDateTime(DateTime? d) =>
|
||||
d == null ? '' : DateFormat('yyyy-MM-dd HH:mm').format(d);
|
||||
|
||||
String _csvEscape(String? v, {String delimiter = ';'}) {
|
||||
final s = v ?? '';
|
||||
final needsQuote =
|
||||
s.contains(delimiter) || s.contains('\n') || s.contains('"');
|
||||
final escaped = s.replaceAll('"', '""');
|
||||
return needsQuote ? '"$escaped"' : escaped;
|
||||
}
|
||||
|
||||
/// Erzeugt eine *denormalisierte* CSV:
|
||||
/// Jede Zeile = 1 Messwert + zugehörige Igel-Stammdaten.
|
||||
/// Hat ein Igel *keine* Messwerte, gibt es *trotzdem eine Zeile* mit leeren Messwert-Spalten.
|
||||
Future<void> exportDenormalizedCsv() async {
|
||||
// Immer frisch laden
|
||||
final igelList = await igelRepo.list();
|
||||
|
||||
final sep = ';';
|
||||
final sb = StringBuffer();
|
||||
|
||||
// Kopfzeile – nur Felder, die deine Models wirklich haben
|
||||
sb.writeln([
|
||||
// Igel
|
||||
'igel_id',
|
||||
'igel_name',
|
||||
'igel_gender',
|
||||
'igel_feature',
|
||||
'igel_note',
|
||||
'igel_rescued_at',
|
||||
'igel_location',
|
||||
// Messwert
|
||||
'messwert_id',
|
||||
'messwert_datum',
|
||||
'messwert_gewicht',
|
||||
'messwert_behandlung',
|
||||
'messwert_bemerkung',
|
||||
].join(sep));
|
||||
|
||||
for (final ig in igelList) {
|
||||
List<Messwert> mw = [];
|
||||
try {
|
||||
mw = await messRepo.list(ig.id);
|
||||
} catch (_) {
|
||||
mw = [];
|
||||
}
|
||||
|
||||
if (mw.isEmpty) {
|
||||
// Igel ohne Messwerte → eine Zeile mit leeren Messwert-Spalten
|
||||
sb.writeln([
|
||||
ig.id,
|
||||
_csvEscape(ig.name, delimiter: sep),
|
||||
_csvEscape(ig.gender, delimiter: sep),
|
||||
_csvEscape(ig.feature, delimiter: sep),
|
||||
_csvEscape(ig.note, delimiter: sep),
|
||||
_fmtDate(ig.rescuedAt),
|
||||
_csvEscape(ig.location, delimiter: sep),
|
||||
|
||||
// Messwert-Spalten leer
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join(sep));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final m in mw) {
|
||||
sb.writeln([
|
||||
ig.id,
|
||||
_csvEscape(ig.name, delimiter: sep),
|
||||
_csvEscape(ig.gender, delimiter: sep),
|
||||
_csvEscape(ig.feature, delimiter: sep),
|
||||
_csvEscape(ig.note, delimiter: sep),
|
||||
_fmtDate(ig.rescuedAt),
|
||||
_csvEscape(ig.location, delimiter: sep),
|
||||
m.id,
|
||||
_fmtDateTime(m.datum),
|
||||
m.gewicht,
|
||||
_csvEscape(m.behandlung, delimiter: sep),
|
||||
_csvEscape(m.bemerkung, delimiter: sep),
|
||||
].join(sep));
|
||||
}
|
||||
}
|
||||
|
||||
final bytes =
|
||||
Uint8List.fromList(const Utf8Encoder().convert(sb.toString()));
|
||||
final filename =
|
||||
'igel_export_denorm_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}';
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: filename,
|
||||
bytes: bytes,
|
||||
ext: 'csv',
|
||||
mimeType: MimeType.csv,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
// lib/features/igel/presentation/igel_detail_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:http_parser/http_parser.dart' show MediaType;
|
||||
import 'dart:ui' as ui show TextDirection;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_images_repository.dart';
|
||||
@@ -18,8 +25,8 @@ import '../domain/igel.dart';
|
||||
import '../domain/igel_image.dart';
|
||||
|
||||
// Messwerte
|
||||
import '../../messwerte/domain/messwert.dart';
|
||||
import '../../messwerte/data/messwerte_repository.dart';
|
||||
import '../../messwerte/domain/messwert.dart';
|
||||
|
||||
class IgelDetailScreen extends ConsumerStatefulWidget {
|
||||
const IgelDetailScreen({super.key, required this.igelId});
|
||||
@@ -44,7 +51,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
final featureC = TextEditingController();
|
||||
final noteC = TextEditingController();
|
||||
|
||||
// NEU: Ort/Fundstelle + Gerettet am
|
||||
// Ort/Fundstelle + Gerettet am
|
||||
final locationC = TextEditingController();
|
||||
DateTime? rescuedAt;
|
||||
DateTime? _initRescuedAt;
|
||||
@@ -68,6 +75,9 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
int _uploadDone = 0;
|
||||
int _uploadTotal = 0;
|
||||
|
||||
// Export: Chart als Bild rendern
|
||||
final GlobalKey _chartKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -77,7 +87,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
|
||||
featureC.addListener(_markDirtyBasics);
|
||||
noteC.addListener(_markDirtyBasics);
|
||||
locationC.addListener(_markDirtyBasics); // NEU
|
||||
locationC.addListener(_markDirtyBasics);
|
||||
|
||||
_loadAll();
|
||||
}
|
||||
@@ -87,7 +97,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
nameC.dispose();
|
||||
featureC.dispose();
|
||||
noteC.dispose();
|
||||
locationC.dispose(); // NEU
|
||||
locationC.dispose();
|
||||
mwGewichtC.dispose();
|
||||
mwBehandlungC.dispose();
|
||||
mwBemerkungC.dispose();
|
||||
@@ -111,7 +121,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
newNote != igel!.note ||
|
||||
gender != igel!.gender;
|
||||
|
||||
// NEU: rescuedAt & location berücksichtigen
|
||||
// rescuedAt & location berücksichtigen
|
||||
String? fmt(DateTime? d) =>
|
||||
d == null ? null : DateFormat('yyyy-MM-dd').format(d);
|
||||
if (fmt(rescuedAt) != fmt(_initRescuedAt)) changed = true;
|
||||
@@ -136,7 +146,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
noteC.text = data.note ?? '';
|
||||
gender = data.gender;
|
||||
|
||||
// NEU: rescuedAt + location aus Domain (falls Backend schon erweitert)
|
||||
// rescuedAt + location (sofern im Model vorhanden)
|
||||
rescuedAt = data.rescuedAt;
|
||||
locationC.text = data.location ?? '';
|
||||
_initRescuedAt = data.rescuedAt;
|
||||
@@ -184,7 +194,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// NEU: rescuedAt + location mitsenden (Repo wurde entsprechend erweitert)
|
||||
await igelRepo.update(
|
||||
widget.igelId,
|
||||
newName,
|
||||
@@ -204,7 +213,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
location: locationC.text.trim().isEmpty ? null : locationC.text.trim(),
|
||||
);
|
||||
|
||||
// NEU: Init-Stand für Change-Detection aktualisieren
|
||||
_initRescuedAt = rescuedAt;
|
||||
_initLocation =
|
||||
locationC.text.trim().isEmpty ? null : locationC.text.trim();
|
||||
@@ -427,12 +435,329 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export: CSV (nur dieser Igel, denormalisiert) -------------------------
|
||||
|
||||
String _fmtDate(DateTime? dt) =>
|
||||
dt == null ? '' : DateFormat('yyyy-MM-dd').format(dt);
|
||||
|
||||
String _fmtDateTime(DateTime dt) {
|
||||
final d = dt.toLocal();
|
||||
String two(int x) => x.toString().padLeft(2, '0');
|
||||
return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}';
|
||||
}
|
||||
|
||||
String _fmtDateTimeIso(DateTime? dt) =>
|
||||
dt == null ? '' : DateFormat('yyyy-MM-dd HH:mm').format(dt);
|
||||
|
||||
String _csvEscape(String? v, {String delimiter = ';'}) {
|
||||
final s = v ?? '';
|
||||
final needsQuote =
|
||||
s.contains(delimiter) || s.contains('\n') || s.contains('"');
|
||||
final escaped = s.replaceAll('"', '""');
|
||||
return needsQuote ? '"$escaped"' : escaped;
|
||||
}
|
||||
|
||||
Future<void> _exportCsvSingleIgel() async {
|
||||
if (igel == null) return;
|
||||
// Sicherheits-Reload der Messwerte, damit CSV aktuell ist
|
||||
final mw = await messRepo.list(widget.igelId);
|
||||
|
||||
final sep = ';';
|
||||
final sb = StringBuffer();
|
||||
|
||||
sb.writeln([
|
||||
'igel_id',
|
||||
'igel_name',
|
||||
'igel_gender',
|
||||
'igel_feature',
|
||||
'igel_note',
|
||||
'igel_rescued_at',
|
||||
'igel_location',
|
||||
'messwert_id',
|
||||
'messwert_datum',
|
||||
'messwert_gewicht',
|
||||
'messwert_behandlung',
|
||||
'messwert_bemerkung',
|
||||
].join(sep));
|
||||
|
||||
if (mw.isEmpty) {
|
||||
sb.writeln([
|
||||
igel!.id,
|
||||
_csvEscape(igel!.name, delimiter: sep),
|
||||
_csvEscape(igel!.gender, delimiter: sep),
|
||||
_csvEscape(igel!.feature, delimiter: sep),
|
||||
_csvEscape(igel!.note, delimiter: sep),
|
||||
_fmtDate(igel!.rescuedAt),
|
||||
_csvEscape(igel!.location, delimiter: sep),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join(sep));
|
||||
} else {
|
||||
for (final m in mw) {
|
||||
sb.writeln([
|
||||
igel!.id,
|
||||
_csvEscape(igel!.name, delimiter: sep),
|
||||
_csvEscape(igel!.gender, delimiter: sep),
|
||||
_csvEscape(igel!.feature, delimiter: sep),
|
||||
_csvEscape(igel!.note, delimiter: sep),
|
||||
_fmtDate(igel!.rescuedAt),
|
||||
_csvEscape(igel!.location, delimiter: sep),
|
||||
m.id,
|
||||
_fmtDateTimeIso(m.datum),
|
||||
m.gewicht,
|
||||
_csvEscape(m.behandlung, delimiter: sep),
|
||||
_csvEscape(m.bemerkung, delimiter: sep),
|
||||
].join(sep));
|
||||
}
|
||||
}
|
||||
|
||||
final bytes = Uint8List.fromList(utf8.encode(sb.toString()));
|
||||
final filename =
|
||||
'igel_${igel!.id}_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}';
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: filename,
|
||||
bytes: bytes,
|
||||
ext: 'csv',
|
||||
mimeType: MimeType.csv,
|
||||
);
|
||||
|
||||
_snack('CSV exportiert');
|
||||
}
|
||||
|
||||
// --- Export: PDF -----------------------------------------------------------
|
||||
|
||||
Future<Uint8List?> _captureChartPngBytes() async {
|
||||
try {
|
||||
final ctx = _chartKey.currentContext;
|
||||
if (ctx == null) return null;
|
||||
final boundary = ctx.findRenderObject() as RenderRepaintBoundary?;
|
||||
if (boundary == null) return null;
|
||||
final ui.Image img = await boundary.toImage(pixelRatio: 3);
|
||||
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
return byteData?.buffer.asUint8List();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Uint8List?>> _fetchImagesForPdf() async {
|
||||
final List<Uint8List?> result = [];
|
||||
for (final img in images) {
|
||||
final url = img.thumbUrl ?? img.url;
|
||||
try {
|
||||
final resp =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
|
||||
if (resp.statusCode == 200) {
|
||||
result.add(resp.bodyBytes);
|
||||
} else {
|
||||
result.add(null);
|
||||
}
|
||||
} catch (_) {
|
||||
result.add(null);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _exportPdfWithOptions() async {
|
||||
bool includeMesswerte = true;
|
||||
bool includeBilder = false;
|
||||
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => StatefulBuilder(
|
||||
builder: (context, setStateDialog) {
|
||||
return AlertDialog(
|
||||
title: const Text('Export als PDF'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
value: includeMesswerte,
|
||||
onChanged: (v) =>
|
||||
setStateDialog(() => includeMesswerte = v ?? true),
|
||||
title: const Text('Messwerte aufnehmen'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: includeBilder,
|
||||
onChanged: (v) =>
|
||||
setStateDialog(() => includeBilder = v ?? false),
|
||||
title: const Text('Bilder aufnehmen'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Exportieren'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
await _exportPdf(
|
||||
includeMesswerte: includeMesswerte, includeBilder: includeBilder);
|
||||
}
|
||||
|
||||
Future<void> _exportPdf(
|
||||
{required bool includeMesswerte, required bool includeBilder}) async {
|
||||
if (igel == null) return;
|
||||
|
||||
// Aktuelle Messwerte/Bilderdaten holen
|
||||
final mw = await messRepo.list(widget.igelId);
|
||||
final chartBytes = await _captureChartPngBytes();
|
||||
final imageBytesList =
|
||||
includeBilder ? await _fetchImagesForPdf() : const <Uint8List?>[];
|
||||
|
||||
final doc = pw.Document();
|
||||
|
||||
final textStyle = pw.TextStyle(fontSize: 12);
|
||||
final headerStyle =
|
||||
pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold);
|
||||
final labelStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600);
|
||||
|
||||
pw.Widget infoRow(String label, String value) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.SizedBox(width: 110, child: pw.Text(label, style: labelStyle)),
|
||||
pw.Expanded(child: pw.Text(value, style: textStyle)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Seite 1: Stammdaten + Chart + optional Messwerte/Bilder (alles synchron)
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
margin: const pw.EdgeInsets.all(24),
|
||||
build: (context) => [
|
||||
pw.Text('Igel-Bericht', style: headerStyle),
|
||||
pw.SizedBox(height: 10),
|
||||
infoRow('Name', igel!.name),
|
||||
infoRow('Geschlecht', igel!.gender ?? '—'),
|
||||
infoRow('Merkmal', igel!.feature ?? '—'),
|
||||
infoRow(
|
||||
'Gerettet am',
|
||||
rescuedAt == null
|
||||
? '—'
|
||||
: DateFormat('dd.MM.yyyy').format(rescuedAt!)),
|
||||
infoRow('Ort / Fundstelle', igel!.location ?? '—'),
|
||||
infoRow('Information', igel!.note ?? '—'),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Gewichtsverlauf',
|
||||
style:
|
||||
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 8),
|
||||
if (chartBytes != null)
|
||||
pw.Image(pw.MemoryImage(chartBytes),
|
||||
height: 200, fit: pw.BoxFit.contain)
|
||||
else
|
||||
pw.Text('Kein Diagramm verfügbar', style: labelStyle),
|
||||
if (includeMesswerte) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Messwerte',
|
||||
style:
|
||||
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 6),
|
||||
if (mw.isEmpty)
|
||||
pw.Text('Keine Messwerte vorhanden.', style: labelStyle)
|
||||
else
|
||||
pw.TableHelper.fromTextArray(
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
headers: const [
|
||||
'Datum/Uhrzeit',
|
||||
'Gewicht (g)',
|
||||
'Behandlung',
|
||||
'Bemerkung'
|
||||
],
|
||||
data: mw
|
||||
.map((m) => [
|
||||
DateFormat('dd.MM.yyyy HH:mm')
|
||||
.format(m.datum.toLocal()),
|
||||
m.gewicht.toString(),
|
||||
m.behandlung ?? '',
|
||||
m.bemerkung ?? '',
|
||||
])
|
||||
.toList(),
|
||||
cellStyle: textStyle,
|
||||
headerDecoration:
|
||||
const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
cellAlignment: pw.Alignment.centerLeft,
|
||||
),
|
||||
],
|
||||
if (includeBilder) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Bilder',
|
||||
style:
|
||||
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 6),
|
||||
if (imageBytesList.isEmpty)
|
||||
pw.Text('Keine Bilder vorhanden.', style: labelStyle)
|
||||
else
|
||||
pw.Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final bytes in imageBytesList)
|
||||
if (bytes != null)
|
||||
pw.Container(
|
||||
width: 160,
|
||||
height: 120,
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey300),
|
||||
borderRadius: pw.BorderRadius.circular(6),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(2),
|
||||
child: pw.Image(pw.MemoryImage(bytes),
|
||||
fit: pw.BoxFit.cover),
|
||||
)
|
||||
else
|
||||
pw.Container(
|
||||
width: 160,
|
||||
height: 120,
|
||||
alignment: pw.Alignment.center,
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey300),
|
||||
borderRadius: pw.BorderRadius.circular(6),
|
||||
),
|
||||
child:
|
||||
pw.Text('Bild nicht verfügbar', style: labelStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final pdfBytes = await doc.save();
|
||||
final filename =
|
||||
'igel_${igel!.id}_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}';
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: filename,
|
||||
bytes: pdfBytes,
|
||||
ext: 'pdf',
|
||||
mimeType: MimeType.pdf,
|
||||
);
|
||||
|
||||
_snack('PDF exportiert');
|
||||
}
|
||||
|
||||
String _fmtGramm(int g) => '$g g';
|
||||
String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim();
|
||||
|
||||
@@ -554,6 +879,21 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
_basicsChanged ? 'Änderungen speichern' : 'Keine Änderungen',
|
||||
icon: const Icon(Icons.save),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Export',
|
||||
onSelected: (v) async {
|
||||
if (v == 'csv') {
|
||||
await _exportCsvSingleIgel();
|
||||
} else if (v == 'pdf') {
|
||||
await _exportPdfWithOptions();
|
||||
}
|
||||
},
|
||||
itemBuilder: (ctx) => const [
|
||||
PopupMenuItem(value: 'csv', child: Text('Als CSV exportieren')),
|
||||
PopupMenuItem(value: 'pdf', child: Text('Als PDF exportieren…')),
|
||||
],
|
||||
icon: const Icon(Icons.ios_share),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _pickAndUpload,
|
||||
tooltip: 'Bilder hinzufügen',
|
||||
@@ -591,7 +931,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// NEU: Gerettet am (Datum)
|
||||
// Gerettet am (Datum)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading:
|
||||
@@ -622,7 +962,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// NEU: Ort / Fundstelle
|
||||
// Ort / Fundstelle
|
||||
TextField(
|
||||
controller: locationC,
|
||||
decoration: const InputDecoration(
|
||||
@@ -677,7 +1017,10 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: WeightChart(data: chartData),
|
||||
child: RepaintBoundary(
|
||||
key: _chartKey,
|
||||
child: WeightChart(data: chartData),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data'; // ⬅️ für Uint8List (FileSaver)
|
||||
import 'package:file_saver/file_saver.dart'; // ⬅️ für Download/Speichern plattformübergreifend
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart'; // ⬅️ NEU: für Datumsausgabe
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_repository.dart';
|
||||
import '../domain/igel.dart';
|
||||
|
||||
// ⬇️ für denormalisierten Export benötigen wir die Messwerte
|
||||
import '../../messwerte/data/messwerte_repository.dart';
|
||||
import '../../messwerte/domain/messwert.dart';
|
||||
|
||||
class IgelListScreen extends ConsumerStatefulWidget {
|
||||
const IgelListScreen({super.key});
|
||||
@override
|
||||
@@ -16,6 +23,7 @@ class IgelListScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
late final IgelRepository repo;
|
||||
late final MesswerteRepository messRepo; // ⬅️ NEU
|
||||
|
||||
List<Igel> items = [];
|
||||
bool busy = true;
|
||||
@@ -45,6 +53,7 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
repo = ref.read(igelRepoProvider);
|
||||
messRepo = ref.read(messwerteRepoProvider); // ⬅️ NEU
|
||||
_load();
|
||||
searchC.addListener(() {
|
||||
_debounce?.cancel();
|
||||
@@ -143,14 +152,144 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// =========================
|
||||
// DENORMALISIERTER CSV-EXPORT
|
||||
// =========================
|
||||
|
||||
String _fmtDate(DateTime? d) =>
|
||||
d == null ? '' : DateFormat('yyyy-MM-dd').format(d);
|
||||
String _fmtDateTime(DateTime? d) =>
|
||||
d == null ? '' : DateFormat('yyyy-MM-dd HH:mm').format(d);
|
||||
|
||||
String _csvEscape(String? v, {String delimiter = ';'}) {
|
||||
final s = v ?? '';
|
||||
final needsQuote =
|
||||
s.contains(delimiter) || s.contains('\n') || s.contains('"');
|
||||
final escaped = s.replaceAll('"', '""');
|
||||
return needsQuote ? '"$escaped"' : escaped;
|
||||
}
|
||||
|
||||
Future<void> _exportCsvDenormalized() async {
|
||||
try {
|
||||
// frisch laden, um sicherzugehen
|
||||
final igelList = await repo.list();
|
||||
|
||||
// Header
|
||||
final sb = StringBuffer();
|
||||
final sep = ';';
|
||||
sb.writeln([
|
||||
// Igel-Stammdaten
|
||||
'igel_id',
|
||||
'igel_name',
|
||||
'igel_gender',
|
||||
'igel_feature',
|
||||
'igel_note',
|
||||
'igel_rescued_at',
|
||||
'igel_location',
|
||||
'igel_created_at',
|
||||
'igel_updated_at',
|
||||
// Messwert
|
||||
'messwert_id',
|
||||
'messwert_datum',
|
||||
'messwert_gewicht',
|
||||
'messwert_behandlung',
|
||||
'messwert_bemerkung',
|
||||
].join(sep));
|
||||
|
||||
for (final ig in igelList) {
|
||||
// Messwerte je Igel holen
|
||||
List<Messwert> mw = [];
|
||||
try {
|
||||
mw = await messRepo.list(ig.id);
|
||||
} catch (_) {
|
||||
mw = [];
|
||||
}
|
||||
|
||||
// wenn keine Messwerte → trotzdem 1 Zeile ausgeben
|
||||
if (mw.isEmpty) {
|
||||
sb.writeln([
|
||||
ig.id,
|
||||
_csvEscape(ig.name, delimiter: sep),
|
||||
_csvEscape(ig.gender, delimiter: sep),
|
||||
_csvEscape(ig.feature, delimiter: sep),
|
||||
_csvEscape(ig.note, delimiter: sep),
|
||||
_fmtDate(ig.rescuedAt),
|
||||
_csvEscape(ig.location, delimiter: sep),
|
||||
_fmtDateTime(ig.createdAt),
|
||||
_fmtDateTime(ig.updatedAt),
|
||||
|
||||
// leere Messwert-Spalten
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join(sep));
|
||||
continue;
|
||||
}
|
||||
|
||||
// pro Messwert eine Zeile
|
||||
for (final m in mw) {
|
||||
sb.writeln([
|
||||
ig.id,
|
||||
_csvEscape(ig.name, delimiter: sep),
|
||||
_csvEscape(ig.gender, delimiter: sep),
|
||||
_csvEscape(ig.feature, delimiter: sep),
|
||||
_csvEscape(ig.note, delimiter: sep),
|
||||
_fmtDate(ig.rescuedAt),
|
||||
_csvEscape(ig.location, delimiter: sep),
|
||||
_fmtDateTime(ig.createdAt),
|
||||
_fmtDateTime(ig.updatedAt),
|
||||
m.id,
|
||||
_fmtDateTime(m.datum),
|
||||
m.gewicht,
|
||||
_csvEscape(m.behandlung, delimiter: sep),
|
||||
_csvEscape(m.bemerkung, delimiter: sep),
|
||||
].join(sep));
|
||||
}
|
||||
}
|
||||
|
||||
final bytes =
|
||||
Uint8List.fromList(const Utf8Encoder().convert(sb.toString()));
|
||||
final name =
|
||||
'igel_export_denorm_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}';
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: name,
|
||||
bytes: bytes,
|
||||
ext: 'csv',
|
||||
mimeType: MimeType.csv,
|
||||
);
|
||||
|
||||
_snack('CSV exportiert: $name');
|
||||
} catch (e) {
|
||||
_snack('Export fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext ctx) {
|
||||
final visible = _visibleItems;
|
||||
final df = DateFormat('dd.MM.yyyy'); // ⬅️ NEU
|
||||
final df = DateFormat('dd.MM.yyyy');
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Meine Igel'),
|
||||
actions: [
|
||||
// ⬇️ Export-Menü
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) async {
|
||||
if (v == 'export_csv_denorm') {
|
||||
await _exportCsvDenormalized();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: 'export_csv_denorm',
|
||||
child: Text('Export CSV (denormalisiert)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Logout',
|
||||
icon: const Icon(Icons.logout),
|
||||
@@ -262,7 +401,7 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ⬇️ NEU: „Gerettet am“
|
||||
// ⬇️ „Gerettet am“
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading:
|
||||
@@ -292,7 +431,7 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ⬇️ NEU: „Ort / Fundstelle“
|
||||
// ⬇️ „Ort / Fundstelle“
|
||||
TextField(
|
||||
controller: locationC,
|
||||
decoration: const InputDecoration(
|
||||
@@ -323,11 +462,11 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
nameC.clear();
|
||||
noteC.clear();
|
||||
featureC.clear();
|
||||
locationC.clear(); // ⬅️ NEU
|
||||
locationC.clear(); // ⬅️
|
||||
setState(() {
|
||||
showForm = false;
|
||||
gender = null;
|
||||
rescuedAt = null; // ⬅️ NEU
|
||||
rescuedAt = null; // ⬅️
|
||||
});
|
||||
},
|
||||
child: const Text('Abbrechen'),
|
||||
|
||||
Reference in New Issue
Block a user