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();
}
@@ -6,14 +6,26 @@
#include "generated_plugin_registrant.h"
#include <file_saver/file_saver_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_saver_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin");
file_saver_plugin_register_with_registrar(file_saver_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
+3
View File
@@ -3,8 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_saver
file_selector_linux
flutter_secure_storage_linux
printing
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -5,12 +5,18 @@
import FlutterMacOS
import Foundation
import file_saver
import file_selector_macos
import flutter_secure_storage_macos
import path_provider_foundation
import printing
import share_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
}
+177 -1
View File
@@ -22,6 +22,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.7.0"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
@@ -38,6 +46,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.11.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
@@ -182,6 +206,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.7"
dio:
dependency: transitive
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.dev"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
exif:
dependency: "direct main"
description:
@@ -206,6 +246,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_saver:
dependency: "direct main"
description:
name: file_saver
sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e"
url: "https://pub.dev"
source: hosted
version: "0.2.14"
file_selector_linux:
dependency: transitive
description:
@@ -384,6 +432,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
image_picker:
dependency: "direct main"
description:
@@ -560,8 +616,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_provider:
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -608,6 +672,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416"
url: "https://pub.dev"
source: hosted
version: "3.11.3"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
platform:
dependency: transitive
description:
@@ -632,6 +720,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
printing:
dependency: "direct main"
description:
name: printing
sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93"
url: "https://pub.dev"
source: hosted
version: "5.14.2"
pub_semver:
dependency: transitive
description:
@@ -648,6 +752,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
riverpod:
dependency: transitive
description:
@@ -656,6 +768,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shelf:
dependency: transitive
description:
@@ -781,6 +909,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
@@ -837,6 +1005,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:
+6 -1
View File
@@ -14,7 +14,12 @@ dependencies:
intl: ^0.20.2
http_parser: ^4.0.2
exif: ^3.3.0
share_plus: ^10.0.0
path_provider: ^2.1.4
file_saver: ^0.2.12
pdf: ^3.11.0
printing: ^5.13.4
dev_dependencies:
build_runner: ^2.4.11
json_serializable: ^6.9.0
@@ -6,12 +6,24 @@
#include "generated_plugin_registrant.h"
#include <file_saver/file_saver_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <printing/printing_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSaverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSaverPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+4
View File
@@ -3,8 +3,12 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_saver
file_selector_windows
flutter_secure_storage_windows
printing
share_plus
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST