diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index aa8be1e..b87d05d 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -711,24 +711,94 @@ class _IgelDetailState extends ConsumerState { // --- Export PDF --- Future _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 _renderChartOffscreen(List 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 _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> _fetchImagesForPdf() async { final List result = []; for (final img in images) { @@ -801,7 +871,24 @@ class _IgelDetailState extends ConsumerState { 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 []; @@ -824,119 +911,133 @@ class _IgelDetailState extends ConsumerState { 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 { 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;