diff --git a/lib/features/igel/data/export_service.dart b/lib/features/igel/data/export_service.dart new file mode 100644 index 0000000..c061795 --- /dev/null +++ b/lib/features/igel/data/export_service.dart @@ -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 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 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, + ); + } +} diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index b5e7c33..db6cdf6 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -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 { 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 { 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 { featureC.addListener(_markDirtyBasics); noteC.addListener(_markDirtyBasics); - locationC.addListener(_markDirtyBasics); // NEU + locationC.addListener(_markDirtyBasics); _loadAll(); } @@ -87,7 +97,7 @@ class _IgelDetailState extends ConsumerState { 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 { 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 { 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 { return; } try { - // NEU: rescuedAt + location mitsenden (Repo wurde entsprechend erweitert) await igelRepo.update( widget.igelId, newName, @@ -204,7 +213,6 @@ class _IgelDetailState extends ConsumerState { 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 { } } + // --- 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 _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 _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> _fetchImagesForPdf() async { + final List 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 _exportPdfWithOptions() async { + bool includeMesswerte = true; + bool includeBilder = false; + + final ok = await showDialog( + 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 _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 []; + + 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 { _basicsChanged ? 'Änderungen speichern' : 'Keine Änderungen', icon: const Icon(Icons.save), ), + PopupMenuButton( + 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 { ), const SizedBox(height: 12), - // NEU: Gerettet am (Datum) + // Gerettet am (Datum) ListTile( contentPadding: EdgeInsets.zero, leading: @@ -622,7 +962,7 @@ class _IgelDetailState extends ConsumerState { ), const SizedBox(height: 12), - // NEU: Ort / Fundstelle + // Ort / Fundstelle TextField( controller: locationC, decoration: const InputDecoration( @@ -677,7 +1017,10 @@ class _IgelDetailState extends ConsumerState { const SizedBox(height: 8), SizedBox( height: 220, - child: WeightChart(data: chartData), + child: RepaintBoundary( + key: _chartKey, + child: WeightChart(data: chartData), + ), ), ], ), diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index ee90f9a..864faa1 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -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 { late final IgelRepository repo; + late final MesswerteRepository messRepo; // ⬅️ NEU List items = []; bool busy = true; @@ -45,6 +53,7 @@ class _IgelListState extends ConsumerState { 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 { }).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 _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 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( + 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 { ), const SizedBox(height: 8), - // ⬇️ NEU: „Gerettet am“ + // ⬇️ „Gerettet am“ ListTile( contentPadding: EdgeInsets.zero, leading: @@ -292,7 +431,7 @@ class _IgelListState extends ConsumerState { ), const SizedBox(height: 8), - // ⬇️ NEU: „Ort / Fundstelle“ + // ⬇️ „Ort / Fundstelle“ TextField( controller: locationC, decoration: const InputDecoration( @@ -323,11 +462,11 @@ class _IgelListState extends ConsumerState { 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'), diff --git a/lib/shared/csv.dart b/lib/shared/csv.dart new file mode 100644 index 0000000..739688b --- /dev/null +++ b/lib/shared/csv.dart @@ -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 cols) { + _buf.writeln( + cols.map((c) => _escape(c?.toString(), delimiter)).join(delimiter)); + } + + @override + String toString() => _buf.toString(); +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 85a2413..18a4b5d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,26 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include +#include 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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 62e3ed5..f5f7d43 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -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 diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5d35054..3893df6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index 93a379b..2c1a290 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 610dbe4..e6aee9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b53f20e..e3dc359 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,24 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include +#include +#include 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")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2b9f993..7f4e65e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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