TKK_E32231533/lib/page/history_page.dart

1931 lines
62 KiB
Dart

// =============================================================
// history_page.dart
//
// Dependensi yang perlu ditambahkan di pubspec.yaml:
// firebase_database: ^11.0.0 (sudah dipakai di home_page.dart)
// fl_chart: ^0.68.0 (grafik di dalam aplikasi)
// pdf: ^3.10.0 (membuat file PDF)
// printing: ^5.12.0 (preview / share / simpan PDF)
// intl: ^0.19.0 (format tanggal & jam)
//
// Struktur data Firebase yang diasumsikan:
// riwayat_pengeringan/
// -sessionId/
// waktu_mulai : "2026-07-08T08:00:00"
// waktu_selesai : "2026-07-08T14:30:00"
// logs/
// -logId: { time: "2026-07-08T08:05:00", suhu: 45.2, kelembapan: 60.1 }
//
// Sesuaikan nama field di DryingSession.fromSnapshot() /
// DryingLog.fromMap() kalau struktur di project kamu berbeda.
//
// FIX LocaleDataException:
// DateFormat(..., 'id_ID') butuh data locale Indonesia di-load
// dulu sebelum dipakai. Supaya file ini tidak bergantung ke
// main.dart (dan tidak error walau kamu lupa setup di sana),
// initializeDateFormatting('id_ID', null) dipanggil sendiri di
// initState() HistoryPage, SEBELUM data di-fetch / halaman detail
// dibuka. Karena ini inisialisasi global sekali untuk seluruh app,
// SessionDetailPage yang hanya bisa diakses lewat HistoryPage juga
// otomatis aman.
//
// CATATAN PERUBAHAN TERAKHIR:
// - Kartu "Suhu Rata-rata" dan "Kelembapan Awal -> Akhir" di
// halaman detail dihapus.
// - Sebagai gantinya, "Suhu Min-Max" dan "Kelembapan Min-Max"
// sekarang ditampilkan sebagai kartu besar (gaya sensorCard),
// bukan lagi baris kecil di bawah.
// =============================================================
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';
/// =========================================================
/// MODEL: satu titik data suhu & kelembapan pada waktu tertentu
/// =========================================================
class DryingLog {
final DateTime time;
final double suhu;
final double kelembapan;
DryingLog({
required this.time,
required this.suhu,
required this.kelembapan,
});
factory DryingLog.fromMap(Map<dynamic, dynamic> map) {
final rawTime = map['time'] ?? map['waktu'];
DateTime parsedTime;
if (rawTime is int) {
parsedTime = DateTime.fromMillisecondsSinceEpoch(rawTime);
} else {
parsedTime = DateTime.tryParse(rawTime.toString()) ?? DateTime.now();
}
return DryingLog(
time: parsedTime,
suhu: (map['suhu'] as num?)?.toDouble() ?? 0,
kelembapan: (map['kelembapan'] as num?)?.toDouble() ?? 0,
);
}
}
/// =========================================================
/// MODEL: satu sesi / rekap pengeringan
/// =========================================================
class DryingSession {
final String id;
final DateTime startTime;
final DateTime? endTime;
final List<DryingLog> logs;
DryingSession({
required this.id,
required this.startTime,
required this.endTime,
required this.logs,
});
Duration get duration => (endTime ?? DateTime.now()).difference(startTime);
double get avgSuhu => logs.isEmpty
? 0
: logs.map((e) => e.suhu).reduce((a, b) => a + b) / logs.length;
double get avgKelembapan => logs.isEmpty
? 0
: logs.map((e) => e.kelembapan).reduce((a, b) => a + b) / logs.length;
double get maxSuhu => logs.isEmpty ? 0 : logs.map((e) => e.suhu).reduce(max);
double get minSuhu => logs.isEmpty ? 0 : logs.map((e) => e.suhu).reduce(min);
double get maxKelembapan =>
logs.isEmpty ? 0 : logs.map((e) => e.kelembapan).reduce(max);
double get minKelembapan =>
logs.isEmpty ? 0 : logs.map((e) => e.kelembapan).reduce(min);
// ===== Kelembapan awal -> akhir =====
// logs sudah terurut ascending berdasarkan waktu (lihat
// fromSnapshot di bawah), jadi first = titik data paling awal,
// last = titik data paling akhir dalam sesi ini.
double get startKelembapan => logs.isEmpty ? 0 : logs.first.kelembapan;
double get endKelembapan => logs.isEmpty ? 0 : logs.last.kelembapan;
factory DryingSession.fromSnapshot(String id, Map<dynamic, dynamic> map) {
DateTime parseDate(dynamic raw) {
if (raw == null) return DateTime.now();
if (raw is int) return DateTime.fromMillisecondsSinceEpoch(raw);
return DateTime.tryParse(raw.toString()) ?? DateTime.now();
}
final start = parseDate(map['waktu_mulai'] ?? map['start_time']);
final endRaw = map['waktu_selesai'] ?? map['end_time'];
final end = endRaw == null ? null : parseDate(endRaw);
final List<DryingLog> logs = [];
final rawLogs = map['logs'];
if (rawLogs is Map) {
rawLogs.forEach((_, v) {
try {
logs.add(DryingLog.fromMap(Map<dynamic, dynamic>.from(v)));
} catch (_) {}
});
} else if (rawLogs is List) {
for (final v in rawLogs) {
if (v == null) continue;
try {
logs.add(DryingLog.fromMap(Map<dynamic, dynamic>.from(v)));
} catch (_) {}
}
}
logs.sort((a, b) => a.time.compareTo(b.time));
return DryingSession(id: id, startTime: start, endTime: end, logs: logs);
}
}
/// =========================================================
/// HALAMAN: daftar riwayat pengeringan
/// =========================================================
class HistoryPage extends StatefulWidget {
const HistoryPage({super.key});
@override
State<HistoryPage> createState() => _HistoryPageState();
}
class _HistoryPageState extends State<HistoryPage> {
final dbRef = FirebaseDatabase.instance.ref("riwayat_pengeringan");
List<DryingSession> sessions = [];
bool loading = true;
bool downloadingId = false;
// Supaya initializeDateFormatting tidak dipanggil berulang kali
// kalau HistoryPage dibuka-tutup berkali-kali dalam 1 sesi app.
static bool _localeReady = false;
@override
void initState() {
super.initState();
_initLocaleAndFetch();
}
/// FIX LocaleDataException: pastikan data locale 'id_ID' sudah
/// ter-load SEBELUM ada DateFormat(..., 'id_ID') yang dipanggil
/// di mana pun pada halaman ini (termasuk SessionDetailPage).
Future<void> _initLocaleAndFetch() async {
if (!_localeReady) {
await initializeDateFormatting('id_ID', null);
_localeReady = true;
}
await fetchHistory();
}
Future<void> fetchHistory() async {
setState(() => loading = true);
try {
final snapshot = await dbRef.get();
final List<DryingSession> result = [];
if (snapshot.exists && snapshot.value != null) {
final map = Map<dynamic, dynamic>.from(snapshot.value as Map);
map.forEach((key, value) {
try {
result.add(
DryingSession.fromSnapshot(
key.toString(),
Map<dynamic, dynamic>.from(value),
),
);
} catch (e) {
debugPrint("ERROR PARSE SESSION $key: $e");
}
});
}
result.sort((a, b) => b.startTime.compareTo(a.startTime));
setState(() {
sessions = result;
loading = false;
});
} catch (e) {
debugPrint("ERROR FETCH HISTORY: $e");
setState(() => loading = false);
}
}
Future<void> _quickDownload(DryingSession session) async {
setState(() => downloadingId = true);
try {
final bytes = await PdfReportGenerator.generate(session);
final fileName =
'laporan_pengeringan_${DateFormat('yyyyMMdd_HHmm').format(session.startTime)}.pdf';
await Printing.sharePdf(bytes: bytes, filename: fileName);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Gagal membuat PDF: $e")),
);
}
} finally {
if (mounted) setState(() => downloadingId = false);
}
}
/// Tampilkan dialog konfirmasi sebelum menghapus 1 sesi riwayat.
Future<void> _confirmDeleteSession(DryingSession session) async {
final dateFmt = DateFormat('dd MMM yyyy', 'id_ID');
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: const Text(
"Hapus Riwayat?",
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Text(
"Riwayat pengeringan tanggal ${dateFmt.format(session.startTime)} "
"beserta seluruh data suhu & kelembapannya akan dihapus permanen "
"dan tidak bisa dikembalikan.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Batal"),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () => Navigator.pop(context, true),
child: const Text(
"Hapus",
style: TextStyle(color: Colors.white),
),
),
],
),
);
if (confirmed == true) {
await _deleteSession(session);
}
}
/// Hapus 1 sesi riwayat (beserta seluruh logs di dalamnya, karena
/// dihapus dari node induknya) dari Firebase, lalu update tampilan.
Future<void> _deleteSession(DryingSession session) async {
try {
await dbRef.child(session.id).remove();
if (mounted) {
setState(() {
sessions.removeWhere((s) => s.id == session.id);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Riwayat berhasil dihapus")),
);
}
} catch (e) {
debugPrint("ERROR DELETE SESSION: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Gagal menghapus riwayat: $e")),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF6F1EE),
body: SafeArea(
child: RefreshIndicator(
onRefresh: fetchHistory,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
children: [
/// HEADER (senada dengan HomePage)
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6F4E37), Color(0xFF8D6E63)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.brown.withOpacity(0.2),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
if (Navigator.of(context).canPop())
Padding(
padding: const EdgeInsets.only(right: 10),
child: InkWell(
borderRadius: BorderRadius.circular(30),
onTap: () => Navigator.of(context).maybePop(),
child: const Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 20,
),
),
),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.history,
color: Colors.white,
size: 30,
),
),
const SizedBox(width: 15),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Riwayat Pengeringan",
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 5),
Text(
"Rekap Suhu & Kelembapan Kopi",
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
],
),
),
const SizedBox(height: 25),
/// RINGKASAN JUMLAH SESI (gaya sama dengan STATUS MODE)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 15,
),
decoration: BoxDecoration(
color: Colors.brown.shade400,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.coffee_maker, color: Colors.white),
const SizedBox(width: 10),
Text(
loading
? "MEMUAT DATA..."
: "${sessions.length} SESI TERCATAT",
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
],
),
),
const SizedBox(height: 25),
/// DAFTAR RIWAYAT
Align(
alignment: Alignment.centerLeft,
child: Text(
"Daftar Sesi",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
if (loading)
const Padding(
padding: EdgeInsets.only(top: 40),
child: CircularProgressIndicator(),
)
else if (sessions.isEmpty)
_emptyState()
else
Column(
children: sessions
.map((session) => _sessionCard(session))
.toList(),
),
const SizedBox(height: 20),
],
),
),
),
),
);
}
Widget _emptyState() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history_toggle_off, size: 50, color: Colors.brown.shade200),
const SizedBox(height: 14),
Text(
"Belum ada riwayat pengeringan",
style: TextStyle(color: Colors.brown.shade400, fontSize: 14),
),
],
),
);
}
/// KARTU SESI - gaya sama dengan sensorCard/buildControlCard di HomePage
Widget _sessionCard(DryingSession session) {
final dateFmt = DateFormat('dd MMM yyyy', 'id_ID');
final duration = session.duration;
final jam = duration.inHours;
final menit = duration.inMinutes % 60;
return Container(
margin: const EdgeInsets.only(bottom: 15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(25),
child: InkWell(
borderRadius: BorderRadius.circular(25),
onTap: () async {
final deleted = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => SessionDetailPage(session: session),
),
);
// Kalau sesi dihapus dari halaman detail, sinkronkan
// daftar di halaman ini juga.
if (deleted == true && mounted) {
setState(() {
sessions.removeWhere((s) => s.id == session.id);
});
}
},
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.brown.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.coffee, color: Colors.brown),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFmt.format(session.startTime),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
"Lama pengeringan: $jam jam $menit menit",
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
IconButton(
tooltip: "Unduh PDF",
onPressed:
downloadingId ? null : () => _quickDownload(session),
icon: const Icon(
Icons.download_rounded,
color: Colors.brown,
),
),
IconButton(
tooltip: "Hapus",
onPressed: () => _confirmDeleteSession(session),
icon: const Icon(
Icons.delete_outline,
color: Colors.red,
),
),
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _miniStat(
icon: Icons.thermostat,
color: Colors.redAccent,
label:
"${session.avgSuhu.toStringAsFixed(1)}°C rata-rata",
),
),
const SizedBox(width: 10),
Expanded(
child: _miniStat(
icon: Icons.water_drop,
color: Colors.blue,
label:
"${session.startKelembapan.toStringAsFixed(1)}% → ${session.endKelembapan.toStringAsFixed(1)}%",
),
),
],
),
],
),
),
),
),
);
}
Widget _miniStat({
required IconData icon,
required Color color,
required String label,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(15),
),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
/// =========================================================
/// HALAMAN: detail satu sesi pengeringan (grafik + tabel + PDF)
/// =========================================================
class SessionDetailPage extends StatefulWidget {
final DryingSession session;
const SessionDetailPage({super.key, required this.session});
@override
State<SessionDetailPage> createState() => _SessionDetailPageState();
}
class _SessionDetailPageState extends State<SessionDetailPage> {
bool generating = false;
Future<void> _previewPdf() async {
await Printing.layoutPdf(
onLayout: (format) => PdfReportGenerator.generate(widget.session),
name: 'laporan_pengeringan_'
'${DateFormat('yyyyMMdd_HHmm').format(widget.session.startTime)}',
);
}
Future<void> _downloadPdf() async {
setState(() => generating = true);
try {
final bytes = await PdfReportGenerator.generate(widget.session);
final fileName = 'laporan_pengeringan_'
'${DateFormat('yyyyMMdd_HHmm').format(widget.session.startTime)}.pdf';
await Printing.sharePdf(bytes: bytes, filename: fileName);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Gagal membuat PDF: $e")),
);
}
} finally {
if (mounted) setState(() => generating = false);
}
}
bool deleting = false;
/// Tampilkan dialog konfirmasi, lalu hapus sesi ini dari Firebase.
/// Kalau berhasil, halaman ditutup dan mengembalikan `true` supaya
/// HistoryPage tahu harus menghapus sesi ini juga dari daftarnya.
Future<void> _confirmAndDelete() async {
final dateFmt = DateFormat('dd MMM yyyy', 'id_ID');
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: const Text(
"Hapus Riwayat?",
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Text(
"Riwayat pengeringan tanggal ${dateFmt.format(widget.session.startTime)} "
"beserta seluruh data suhu & kelembapannya akan dihapus permanen "
"dan tidak bisa dikembalikan.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Batal"),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () => Navigator.pop(context, true),
child: const Text(
"Hapus",
style: TextStyle(color: Colors.white),
),
),
],
),
);
if (confirmed != true) return;
setState(() => deleting = true);
try {
await FirebaseDatabase.instance
.ref("riwayat_pengeringan")
.child(widget.session.id)
.remove();
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
debugPrint("ERROR DELETE SESSION: $e");
if (mounted) {
setState(() => deleting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Gagal menghapus riwayat: $e")),
);
}
}
}
@override
Widget build(BuildContext context) {
final session = widget.session;
final dateFmt = DateFormat('dd MMMM yyyy', 'id_ID');
final timeFmt = DateFormat('HH:mm');
final duration = session.duration;
final jam = duration.inHours;
final menit = duration.inMinutes % 60;
return Scaffold(
backgroundColor: const Color(0xFFF6F1EE),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
/// HEADER (senada dengan HomePage)
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6F4E37), Color(0xFF8D6E63)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.brown.withOpacity(0.2),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
InkWell(
borderRadius: BorderRadius.circular(30),
onTap: () => Navigator.of(context).maybePop(),
child: const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 20,
),
),
),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.receipt_long,
color: Colors.white,
size: 30,
),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFmt.format(session.startTime),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 5),
Text(
"${timeFmt.format(session.startTime)} - "
"${session.endTime != null ? timeFmt.format(session.endTime!) : '-'}"
"$jam jam $menit menit",
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
],
),
),
InkWell(
borderRadius: BorderRadius.circular(30),
onTap: deleting ? null : _confirmAndDelete,
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: deleting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 24,
),
),
),
],
),
),
const SizedBox(height: 25),
/// SUHU MIN-MAX & KELEMBAPAN MIN-MAX (kartu besar, gaya
/// sama dengan sensorCard - menggantikan kartu Suhu
/// Rata-rata & Kelembapan Awal->Akhir yang dihapus)
Row(
children: [
Expanded(
child: sensorCard(
title: "Suhu Min - Max",
value:
"${session.minSuhu.toStringAsFixed(1)}° - ${session.maxSuhu.toStringAsFixed(1)}°",
icon: Icons.thermostat,
color: Colors.redAccent,
),
),
const SizedBox(width: 15),
Expanded(
child: sensorCard(
title: "Kelembapan Min - Max",
value:
"${session.minKelembapan.toStringAsFixed(1)}% - ${session.maxKelembapan.toStringAsFixed(1)}%",
icon: Icons.water_drop,
color: Colors.blue,
),
),
],
),
const SizedBox(height: 30),
/// UNDUH LAPORAN (gaya sama dengan Kontrol Mesin)
Align(
alignment: Alignment.centerLeft,
child: Text(
"Unduh Laporan",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
Row(
children: [
Expanded(
child: controlButton(
title: "Preview PDF",
icon: Icons.picture_as_pdf_outlined,
color: Colors.blueGrey,
onTap: _previewPdf,
),
),
const SizedBox(width: 12),
Expanded(
child: controlButton(
title: generating ? "Membuat..." : "Unduh PDF",
icon: Icons.download_rounded,
color: Colors.brown.shade700,
onTap: generating ? () {} : _downloadPdf,
),
),
],
),
const SizedBox(height: 30),
/// GRAFIK
Align(
alignment: Alignment.centerLeft,
child: Text(
"Grafik Suhu & Kelembapan",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(8, 20, 20, 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
_legend(),
const SizedBox(height: 10),
SizedBox(height: 240, child: _buildChart(session)),
],
),
),
const SizedBox(height: 30),
/// REKAP DATA - ringkasan sesi dalam bentuk tabel
/// label:nilai (gaya sama seperti ringkasan di laporan
/// PDF), menggantikan tabel mentah semua titik data yang
/// sebelumnya ada di sini.
Align(
alignment: Alignment.centerLeft,
child: Text(
"Rekap Data",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
recapDataCard(session, timeFmt),
const SizedBox(height: 30),
/// KESIMPULAN SESI - gaya sama dengan kartu "Apa Arti
/// Status Ini?" di HomePage (kartu putih rounded, ikon
/// lingkaran + judul + deskripsi, dipisah garis tipis)
Align(
alignment: Alignment.centerLeft,
child: Text(
"Kesimpulan Sesi",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
sessionSummaryCard(session),
const SizedBox(height: 20),
],
),
),
),
);
}
/// SENSOR CARD - identik dengan sensorCard() di HomePage
Widget sensorCard({
required String title,
required String value,
required IconData icon,
required Color color,
}) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 35, color: color),
),
const SizedBox(height: 15),
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade700, fontSize: 14),
),
const SizedBox(height: 10),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
color: color,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
/// CONTROL BUTTON - identik dengan controlButton() di HomePage
Widget controlButton({
required String title,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Column(
children: [
Icon(icon, color: Colors.white, size: 30),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
/// =========================================================
/// KARTU KESIMPULAN SESI
///
/// Gaya visualnya SENGAJA disamakan dengan kartu "Apa Arti Status
/// Ini?" (termGuideCard) di HomePage: kartu putih rounded, ikon
/// bulat + judul + deskripsi per baris, dipisah garis tipis.
///
/// Isinya kesimpulan otomatis yang dihitung dari data log sesi ini:
/// - Apakah target kekeringan (<=15%) tercapai
/// - Seberapa stabil suhunya selama proses
/// - Apakah durasinya wajar untuk 1 sesi pengeringan kopi
/// =========================================================
Widget sessionSummaryCard(DryingSession session) {
final points = _buildConclusionPoints(session);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < points.length; i++) ...[
conclusionTile(
icon: points[i].icon,
color: points[i].color,
title: points[i].title,
description: points[i].description,
),
if (i != points.length - 1)
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1),
),
],
],
),
);
}
/// Hitung poin-poin kesimpulan dari data log sesi. Kalau ada
/// aspek lain yang mau ditambahkan (mis. jumlah data terlalu
/// sedikit, dll), cukup tambah 1 blok baru di sini.
/// Hitung berapa kali arah suhu BERBALIK (dari naik jadi turun, atau
/// sebaliknya) sepanjang sesi. Selisih yang lebih kecil dari
/// [ambangNoise] diabaikan supaya noise pembacaan sensor kecil
/// (mis. 0.1°C) tidak dihitung sebagai "pembalikan arah".
///
/// Kurva yang naik/turun mulus (khas fase pemanasan awal) akan
/// menghasilkan angka ini RENDAH walau rentang suhunya lebar -
/// beda dengan suhu yang benar-benar naik-turun berulang (osilasi),
/// yang akan menghasilkan angka TINGGI.
int _countTempDirectionChanges(
List<DryingLog> logs, {
double ambangNoise = 0.3,
}) {
int changes = 0;
int lastSign = 0;
for (int i = 1; i < logs.length; i++) {
final diff = logs[i].suhu - logs[i - 1].suhu;
if (diff.abs() < ambangNoise) continue;
final sign = diff > 0 ? 1 : -1;
if (lastSign != 0 && sign != lastSign) {
changes++;
}
lastSign = sign;
}
return changes;
}
List<_ConclusionPoint> _buildConclusionPoints(DryingSession session) {
final points = <_ConclusionPoint>[];
// ===== 1. Hasil akhir kelembapan =====
final akhir = session.endKelembapan;
if (session.logs.isEmpty) {
points.add(
_ConclusionPoint(
icon: Icons.info_outline,
color: Colors.grey,
title: "Data Belum Cukup",
description:
"Sesi ini belum punya titik data log sama sekali, jadi belum "
"bisa disimpulkan.",
),
);
return points;
}
if (akhir <= 15) {
points.add(
_ConclusionPoint(
icon: Icons.check_circle,
color: Colors.green,
title: "Target Kekeringan Tercapai",
description:
"Kelembapan akhir ${akhir.toStringAsFixed(1)}% sudah di bawah "
"batas 15% - kopi tergolong sudah kering sesuai standar.",
),
);
} else {
points.add(
_ConclusionPoint(
icon: Icons.warning_amber_rounded,
color: Colors.orange,
title: "Belum Mencapai Target Kekeringan",
description:
"Kelembapan akhir masih ${akhir.toStringAsFixed(1)}%, di atas "
"batas 15% - kemungkinan sesi dihentikan manual sebelum kopi "
"benar-benar kering.",
),
);
}
// ===== 2. Konsistensi suhu =====
//
// [FIX] Sebelumnya hanya lihat selisih max-min: rentang lebar
// langsung dicap "berfluktuasi", padahal suhu yang naik BERTAHAP
// dari dingin ke panas (kurva mulus, khas fase pemanasan awal)
// juga menghasilkan rentang lebar tapi itu WAJAR, bukan fluktuasi.
// "Fluktuasi" yang sebenarnya adalah naik-turun BERULANG - itu
// yang dihitung lewat jumlah pembalikan arah (direction changes).
final rentangSuhu = session.maxSuhu - session.minSuhu;
final arahBerubah = _countTempDirectionChanges(session.logs);
final rasioArahBerubah =
session.logs.length > 1 ? arahBerubah / session.logs.length : 0.0;
if (rentangSuhu <= 15) {
points.add(
_ConclusionPoint(
icon: Icons.thermostat,
color: Colors.redAccent,
title: "Suhu Relatif Stabil",
description:
"Suhu bergerak di rentang ${rentangSuhu.toStringAsFixed(1)}°C "
"(${session.minSuhu.toStringAsFixed(1)}°C - "
"${session.maxSuhu.toStringAsFixed(1)}°C) selama proses, "
"menandakan heater & fan bekerja cukup konsisten.",
),
);
} else if (rasioArahBerubah <= 0.15) {
// Rentang lebar tapi arahnya konsisten (jarang berbalik) ->
// ini kurva naik/turun bertahap yang mulus, bukan fluktuasi.
final naik = session.logs.last.suhu >= session.logs.first.suhu;
points.add(
_ConclusionPoint(
icon: naik ? Icons.trending_up : Icons.trending_down,
color: Colors.blue,
title: naik ? "Suhu Naik Bertahap (Wajar)" : "Suhu Turun Bertahap (Wajar)",
description:
"Suhu ${naik ? 'naik' : 'turun'} secara bertahap dari "
"${session.minSuhu.toStringAsFixed(1)}°C ke "
"${session.maxSuhu.toStringAsFixed(1)}°C tanpa naik-turun "
"berulang. Ini wajar terjadi di fase ${naik ? 'pemanasan awal' : 'pendinginan'} "
"mesin, bukan indikasi alat bermasalah.",
),
);
} else {
points.add(
_ConclusionPoint(
icon: Icons.thermostat,
color: Colors.orange,
title: "Suhu Cukup Berfluktuasi",
description:
"Suhu berulang kali naik-turun (bukan sekadar naik/turun "
"bertahap), dari ${session.minSuhu.toStringAsFixed(1)}°C "
"sampai ${session.maxSuhu.toStringAsFixed(1)}°C. Kalau ini "
"tidak diinginkan, cek kondisi heater, fan, atau posisi sensor.",
),
);
}
// ===== 3. Durasi pengeringan =====
final menitTotal = session.duration.inMinutes;
if (menitTotal < 60) {
points.add(
_ConclusionPoint(
icon: Icons.timer_outlined,
color: Colors.orange,
title: "Durasi Sesi Tergolong Singkat",
description:
"Sesi ini cuma berlangsung $menitTotal menit - kemungkinan "
"ini sesi uji coba alat, bukan pengeringan kopi penuh.",
),
);
} else {
final jam = session.duration.inHours;
final sisaMenit = menitTotal % 60;
points.add(
_ConclusionPoint(
icon: Icons.timer_outlined,
color: Colors.blue,
title: "Durasi Pengeringan Normal",
description:
"Proses berlangsung selama $jam jam $sisaMenit menit, sudah "
"dalam rentang wajar untuk 1 sesi pengeringan kopi.",
),
);
}
return points;
}
/// Satu baris kesimpulan: ikon lingkaran + judul + deskripsi.
/// Sengaja dibuat mirip termGuideTile di HomePage, tapi tanpa
/// baris ON/OFF karena isinya pernyataan tunggal, bukan status
/// yang berganti-ganti.
Widget conclusionTile({
required IconData icon,
required Color color,
required String title,
required String description,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(fontSize: 12.5, color: Colors.grey.shade700),
),
],
),
),
],
);
}
/// =========================================================
/// KARTU REKAP DATA
///
/// Tabel ringkas label : nilai (Waktu Mulai, Waktu Selesai, Lama
/// Pengeringan, Suhu Rata-rata, Suhu Min/Max, Kelembapan Min/Max,
/// Jumlah Data) - sama persis dengan ringkasan yang tampil di
/// laporan PDF, menggantikan tabel mentah semua titik data.
/// =========================================================
Widget recapDataCard(DryingSession session, DateFormat timeFmt) {
final duration = session.duration;
final jam = duration.inHours;
final menit = duration.inMinutes % 60;
final rows = <MapEntry<String, String>>[
MapEntry("Waktu Mulai", timeFmt.format(session.startTime)),
MapEntry(
"Waktu Selesai",
session.endTime != null ? timeFmt.format(session.endTime!) : "-",
),
MapEntry("Lama Pengeringan", "$jam jam $menit menit"),
MapEntry("Suhu Rata-rata", "${session.avgSuhu.toStringAsFixed(1)}°C"),
MapEntry(
"Suhu Min / Max",
"${session.minSuhu.toStringAsFixed(1)}°C / ${session.maxSuhu.toStringAsFixed(1)}°C",
),
MapEntry(
"Kelembapan Min / Max",
"${session.minKelembapan.toStringAsFixed(1)}% / ${session.maxKelembapan.toStringAsFixed(1)}%",
),
MapEntry("Jumlah Data", "${session.logs.length} titik"),
];
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
for (int i = 0; i < rows.length; i++) ...[
_recapRow(rows[i].key, rows[i].value),
if (i != rows.length - 1)
const Divider(height: 1, indent: 18, endIndent: 18),
],
],
),
);
}
/// Satu baris "label : nilai" pada kartu Rekap Data.
Widget _recapRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.brown.shade800,
),
),
],
),
);
}
Widget _legend() {
Widget dot(Color c, String label) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 12)),
],
);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
dot(Colors.redAccent, "Suhu (°C)"),
const SizedBox(width: 20),
dot(Colors.blue, "Kelembapan (%)"),
],
);
}
/// Pilih index mana saja di daftar `logs` yang boleh dikasih label
/// waktu di sumbu-X grafik, dipilih merata sejumlah `maxLabels`.
///
/// Ini sengaja TIDAK memakai `interval` bawaan SideTitles fl_chart,
/// karena fl_chart punya kebiasaan memaksa selalu menampilkan label
/// di titik data paling akhir (maxX) SELAIN label-label hasil
/// perhitungan interval - kalau titik akhir itu jaraknya dekat
/// dengan label interval sebelumnya, dua-duanya numpuk (persis
/// masalah "14:51" & "14:56" yang bertabrakan di grafik).
///
/// Dengan memilih sendiri index-nya secara eksplisit dan merata,
/// jaraknya konsisten terjaga berapa pun panjang sesinya.
Set<int> _pickLabelIndices(int length, {int maxLabels = 5}) {
if (length <= maxLabels) {
return List<int>.generate(length, (i) => i).toSet();
}
final step = (length - 1) / (maxLabels - 1);
final indices = <int>{};
for (int i = 0; i < maxLabels; i++) {
indices.add((i * step).round());
}
return indices;
}
Widget _buildChart(DryingSession session) {
final logs = session.logs;
if (logs.isEmpty) {
return Center(
child: Text(
"Belum ada data",
style: TextStyle(color: Colors.grey.shade500),
),
);
}
final labelIndices = _pickLabelIndices(logs.length, maxLabels: 5);
return LineChart(
LineChartData(
minY: 0,
maxY: 100,
gridData: const FlGridData(show: true, drawVerticalLine: false),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 34,
interval: 20,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final idx = value.round();
if (idx < 0 || idx >= logs.length) return const SizedBox();
if (!labelIndices.contains(idx)) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
DateFormat('HH:mm').format(logs[idx].time),
style: const TextStyle(fontSize: 10),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: List.generate(
logs.length,
(i) => FlSpot(i.toDouble(), logs[i].suhu),
),
isCurved: true,
color: Colors.redAccent,
barWidth: 2.5,
dotData: const FlDotData(show: false),
),
LineChartBarData(
spots: List.generate(
logs.length,
(i) => FlSpot(i.toDouble(), logs[i].kelembapan),
),
isCurved: true,
color: Colors.blue,
barWidth: 2.5,
dotData: const FlDotData(show: false),
),
],
),
);
}
Widget _tableHeaderRow() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
"Waktu",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.brown.shade700,
),
),
),
Expanded(
flex: 2,
child: Text(
"Suhu",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.brown.shade700,
),
),
),
Expanded(
flex: 2,
child: Text(
"Kelembapan",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.brown.shade700,
),
),
),
],
),
);
}
Widget _tableDataRow(String time, String suhu, String kelembapan) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(time, style: const TextStyle(fontSize: 12)),
),
Expanded(
flex: 2,
child: Text(
suhu,
style: const TextStyle(fontSize: 12, color: Colors.redAccent),
),
),
Expanded(
flex: 2,
child: Text(
kelembapan,
style: const TextStyle(fontSize: 12, color: Colors.blue),
),
),
],
),
);
}
}
/// =========================================================
/// MODEL: satu poin kesimpulan pada kartu Kesimpulan Sesi
/// =========================================================
class _ConclusionPoint {
final IconData icon;
final Color color;
final String title;
final String description;
_ConclusionPoint({
required this.icon,
required this.color,
required this.title,
required this.description,
});
}
/// =========================================================
/// GENERATOR PDF
/// Ringkasan + grafik (pw.Chart bawaan package pdf,
/// tidak perlu screenshot widget) + tabel data lengkap.
/// (tidak diubah - hanya tampilan aplikasi yang diselaraskan)
/// =========================================================
class PdfReportGenerator {
static Future<Uint8List> generate(DryingSession session) async {
final doc = pw.Document();
final dateFormat = DateFormat('dd MMMM yyyy', 'id_ID');
final timeFormat = DateFormat('HH:mm');
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(28),
header: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'Laporan Pengeringan Kopi',
style:
pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold),
),
pw.SizedBox(height: 4),
pw.Text(
dateFormat.format(session.startTime),
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
),
pw.SizedBox(height: 8),
pw.Divider(color: PdfColors.grey400),
],
),
footer: (context) => pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Halaman ${context.pageNumber} / ${context.pagesCount}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
),
),
build: (context) => [
_buildSummary(session, timeFormat),
pw.SizedBox(height: 18),
pw.Text(
'Grafik Suhu & Kelembapan',
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),
),
pw.SizedBox(height: 8),
_buildChart(session),
pw.SizedBox(height: 20),
pw.Text(
'Data Rinci',
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),
),
pw.SizedBox(height: 8),
_buildTable(session, timeFormat),
],
),
);
return doc.save();
}
static pw.Widget _buildSummary(DryingSession session, DateFormat timeFormat) {
final duration = session.duration;
final jam = duration.inHours;
final menit = duration.inMinutes % 60;
pw.Widget row(String label, String value) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 3),
child: pw.Row(
children: [
pw.SizedBox(
width: 150,
child: pw.Text(
label,
style:
const pw.TextStyle(fontSize: 11, color: PdfColors.grey700),
),
),
pw.Text(
value,
style:
pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
),
],
),
);
return pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(14),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
row('Waktu Mulai', timeFormat.format(session.startTime)),
row(
'Waktu Selesai',
session.endTime != null ? timeFormat.format(session.endTime!) : '-',
),
row('Lama Pengeringan', '$jam jam $menit menit'),
row('Suhu Rata-rata', '${session.avgSuhu.toStringAsFixed(1)} °C'),
row(
'Suhu Min / Max',
'${session.minSuhu.toStringAsFixed(1)}°C / ${session.maxSuhu.toStringAsFixed(1)}°C',
),
row(
'Kelembapan Min / Max',
'${session.minKelembapan.toStringAsFixed(1)}% / ${session.maxKelembapan.toStringAsFixed(1)}%',
),
row('Jumlah Data', '${session.logs.length} titik'),
],
),
);
}
static pw.Widget _buildChart(DryingSession session) {
final logs = session.logs;
if (logs.isEmpty) {
return pw.Text(
'Tidak ada data grafik',
style: const pw.TextStyle(color: PdfColors.grey600),
);
}
final step = (logs.length / 6).ceil().clamp(1, logs.length);
final labels = List<String>.generate(logs.length, (i) {
if (i % step == 0) {
return DateFormat('HH:mm').format(logs[i].time);
}
return '';
});
return pw.Container(
height: 220,
padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
),
child: pw.Chart(
grid: pw.CartesianGrid(
xAxis: pw.FixedAxis.fromStrings(
labels,
marginStart: 30,
marginEnd: 30,
),
yAxis: pw.FixedAxis([0, 20, 40, 60, 80, 100], divisions: true),
),
datasets: [
pw.LineDataSet(
legend: 'Suhu (°C)',
drawPoints: false,
isCurved: true,
color: PdfColors.red,
lineWidth: 1.5,
data: List.generate(
logs.length,
(i) => pw.PointChartValue(i.toDouble(), logs[i].suhu),
),
),
pw.LineDataSet(
legend: 'Kelembapan (%)',
drawPoints: false,
isCurved: true,
color: PdfColors.blue,
lineWidth: 1.5,
data: List.generate(
logs.length,
(i) => pw.PointChartValue(i.toDouble(), logs[i].kelembapan),
),
),
],
),
);
}
static pw.Widget _buildTable(DryingSession session, DateFormat timeFormat) {
final logs = session.logs;
if (logs.isEmpty) {
return pw.Text(
'Tidak ada data',
style: const pw.TextStyle(color: PdfColors.grey600),
);
}
final data = logs
.map(
(log) => [
timeFormat.format(log.time),
'${log.suhu.toStringAsFixed(1)} °C',
'${log.kelembapan.toStringAsFixed(1)} %',
],
)
.toList();
// Catatan: pada versi lama package `pdf`, gunakan
// pw.Table.fromTextArray(...) alih-alih pw.TableHelper.fromTextArray(...)
return pw.TableHelper.fromTextArray(
headers: ['Waktu', 'Suhu', 'Kelembapan'],
data: data,
headerStyle: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
fontSize: 10,
color: PdfColors.white,
),
headerDecoration: const pw.BoxDecoration(color: PdfColors.brown700),
cellStyle: const pw.TextStyle(fontSize: 9),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.center,
2: pw.Alignment.center,
},
border: null,
rowDecoration: const pw.BoxDecoration(
border: pw.Border(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
),
),
);
}
}