Export CSV/PDF

This commit is contained in:
2025-10-24 14:53:53 +02:00
parent 003196490f
commit 35c2231cbb
11 changed files with 868 additions and 22 deletions
+122
View File
@@ -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'),
+24
View File
@@ -0,0 +1,24 @@
// lib/shared/csv.dart
class CsvBuilder {
final StringBuffer _buf = StringBuffer();
final String delimiter;
final String eol;
CsvBuilder({this.delimiter = ';', this.eol = '\n'});
static String _escape(String? v, String delimiter) {
final s = v ?? '';
final mustQuote =
s.contains(delimiter) || s.contains('\n') || s.contains('"');
var out = s.replaceAll('"', '""');
return mustQuote ? '"$out"' : out;
}
void addRow(Iterable<dynamic> cols) {
_buf.writeln(
cols.map((c) => _escape(c?.toString(), delimiter)).join(delimiter));
}
@override
String toString() => _buf.toString();
}