Chart im Export
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user