Chart im Export

This commit is contained in:
2025-11-17 11:51:30 +01:00
parent 5c4acdb716
commit 0b44d839e2
@@ -711,24 +711,94 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
// --- Export PDF ---
Future<Uint8List?> _captureChartPngBytes() async {
final sortedData = [...messwerte]..sort((a, b) => a.datum.compareTo(b.datum));
Size? renderSize;
final dpr = MediaQuery.maybeOf(context)?.devicePixelRatio ??
(WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
? WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio
: 3.0);
try {
final ctx = _chartKey.currentContext;
if (ctx == null) return null;
final boundary = ctx.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return null;
// Ensure the chart has been painted before capturing, otherwise toImage can return empty.
if (boundary.debugNeedsPaint) {
await Future.delayed(Duration.zero);
await WidgetsBinding.instance.endOfFrame;
final boundary =
_chartKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary != null) {
renderSize = boundary.size;
// Ensure the chart has been painted before capturing, otherwise toImage can return empty.
if (boundary.debugNeedsPaint) {
await Future.delayed(Duration.zero);
await WidgetsBinding.instance.endOfFrame;
}
final ui.Image img = await boundary.toImage(pixelRatio: dpr);
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
return byteData.buffer.asUint8List();
}
}
final ui.Image img = await boundary.toImage(pixelRatio: 3);
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
} catch (e, st) {
if (kDebugMode) {
debugPrint('Chart capture via RenderRepaintBoundary failed: $e\n$st');
}
}
// Fallback for web: render the CustomPainter off-screen to avoid missing charts in PDF.
return _renderChartOffscreen(sortedData,
size: renderSize, pixelRatio: dpr);
}
Future<Uint8List?> _renderChartOffscreen(List<Messwert> data,
{Size? size, double pixelRatio = 3.0}) async {
try {
final targetSize = size ?? const Size(600, 220);
final safeSize = Size(
targetSize.width <= 0 ? 600 : targetSize.width,
targetSize.height <= 0 ? 220 : targetSize.height,
);
final recorder = ui.PictureRecorder();
final canvas = Canvas(
recorder,
Rect.fromLTWH(0, 0, safeSize.width, safeSize.height),
);
canvas.drawColor(Colors.white, BlendMode.src);
_WeightChartPainter(data: data).paint(canvas, safeSize);
final image = await recorder.endRecording().toImage(
math.max((safeSize.width * pixelRatio).round(), 1),
math.max((safeSize.height * pixelRatio).round(), 1),
);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
} catch (_) {
} catch (e, st) {
if (kDebugMode) {
debugPrint('Chart fallback render failed: $e\n$st');
}
return null;
}
}
Future<void> _saveChartPngDebug() async {
final sorted = [...messwerte]..sort((a, b) => a.datum.compareTo(b.datum));
final bytes = await _renderChartOffscreen(
sorted,
size: const Size(1200, 400),
pixelRatio: 2.0,
) ??
await _captureChartPngBytes();
if (bytes == null) {
_snack('Chart PNG fehlgeschlagen');
return;
}
await FileSaver.instance.saveFile(
name:
'chart_debug_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}',
bytes: bytes,
ext: 'png',
mimeType: MimeType.png,
);
_snack('Chart PNG exportiert');
}
Future<List<Uint8List?>> _fetchImagesForPdf() async {
final List<Uint8List?> result = [];
for (final img in images) {
@@ -801,7 +871,24 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
if (igel == null) return;
final mw = await messRepo.list(widget.igelId);
final chartBytes = await _captureChartPngBytes();
final chartDataPdf = [...mw]..sort((a, b) => a.datum.compareTo(b.datum));
const pageFormat = PdfPageFormat.a4;
const pageMargin = pw.EdgeInsets.all(24);
final contentWidth =
pageFormat.width - pageMargin.left - pageMargin.right;
const double chartHeight = 220;
const chartRatio = 1.0;
final Size chartOffscreenSize = Size(contentWidth * 2, chartHeight);
Uint8List? chartBytes;
// Primary: offscreen render matching PDF content width.
chartBytes = await _renderChartOffscreen(
chartDataPdf,
size: chartOffscreenSize,
pixelRatio: chartRatio,
);
// Fallback to boundary capture
chartBytes ??= await _captureChartPngBytes();
final imageBytesList =
includeBilder ? await _fetchImagesForPdf() : const <Uint8List?>[];
@@ -824,119 +911,133 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
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',
pageFormat: pageFormat,
margin: pageMargin,
build: (context) {
return [
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: 4),
pw.Text('Gewichtsverlauf',
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,
pw.SizedBox(height: 2),
if (chartBytes != null) ...[
pw.Image(
pw.MemoryImage(chartBytes),
width: contentWidth,
fit: pw.BoxFit.fitWidth,
),
],
if (includeBilder) ...[
pw.SizedBox(height: 16),
pw.Text('Medien (Fotos)',
style:
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 6),
if (imageBytesList.isEmpty)
pw.Text('Keine Medien 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),
pw.SizedBox(height: 2),
] else
pw.Text('Kein Diagramm verfügbar', style: labelStyle),
if (includeMesswerte) ...[
pw.SizedBox(height: 4),
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('Medien (Fotos)',
style:
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 6),
if (imageBytesList.isEmpty)
pw.Text('Keine Medien 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('Medium nicht verfügbar', style: labelStyle),
),
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('Medium nicht verfügbar', style: labelStyle),
),
],
),
],
],
],
),
],
];
},
),
);
final pdfBytes = await doc.save();
final filename =
'igel_${igel!.id}_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}';
try {
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,
);
await FileSaver.instance.saveFile(
name: filename,
bytes: pdfBytes,
ext: 'pdf',
mimeType: MimeType.pdf,
);
_snack('PDF exportiert');
_snack('PDF exportiert');
} catch (e, st) {
if (kDebugMode) {
debugPrint('PDF export failed: $e\n$st');
}
_snack('PDF export fehlgeschlagen');
}
}
String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim();
@@ -1252,6 +1353,16 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
child: WeightChart(data: chartData),
),
),
if (kDebugMode)
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: _saveChartPngDebug,
icon: const Icon(Icons.download),
label:
const Text('Chart als PNG'),
),
),
],
),
),
@@ -1803,10 +1914,10 @@ class _WeightChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
const paddingLeft = 44.0; // Platz für Y-Labels
const paddingLeft = 64.0; // Platz für Y-Labels, damit nichts abgeschnitten wird
const paddingBottom = 28.0; // Platz für X-Labels
const paddingTop = 8.0;
const paddingRight = 12.0;
const paddingRight = 28.0; // Platz für letzte X-Labels
final area = Rect.fromLTWH(
paddingLeft,
@@ -1883,7 +1994,9 @@ class _WeightChartPainter extends CustomPainter {
final fill = Path();
for (var i = 0; i < data.length; i++) {
final d = data[i];
final tx = (d.datum.millisecondsSinceEpoch - minT) / spanT;
// Spread points evenly across the X-axis to avoid charts that collapse
// when timestamps are clustered or contain outliers.
final tx = data.length == 1 ? 0.5 : i / (data.length - 1);
final ty = (d.gewicht - minG) / spanG;
final x = area.left + tx * area.width;
final y = area.bottom - ty * area.height;