// ============================================================= // 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 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 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 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 logs = []; final rawLogs = map['logs']; if (rawLogs is Map) { rawLogs.forEach((_, v) { try { logs.add(DryingLog.fromMap(Map.from(v))); } catch (_) {} }); } else if (rawLogs is List) { for (final v in rawLogs) { if (v == null) continue; try { logs.add(DryingLog.fromMap(Map.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 createState() => _HistoryPageState(); } class _HistoryPageState extends State { final dbRef = FirebaseDatabase.instance.ref("riwayat_pengeringan"); List 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 _initLocaleAndFetch() async { if (!_localeReady) { await initializeDateFormatting('id_ID', null); _localeReady = true; } await fetchHistory(); } Future fetchHistory() async { setState(() => loading = true); try { final snapshot = await dbRef.get(); final List result = []; if (snapshot.exists && snapshot.value != null) { final map = Map.from(snapshot.value as Map); map.forEach((key, value) { try { result.add( DryingSession.fromSnapshot( key.toString(), Map.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 _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 _confirmDeleteSession(DryingSession session) async { final dateFmt = DateFormat('dd MMM yyyy', 'id_ID'); final confirmed = await showDialog( 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 _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( 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 createState() => _SessionDetailPageState(); } class _SessionDetailPageState extends State { bool generating = false; Future _previewPdf() async { await Printing.layoutPdf( onLayout: (format) => PdfReportGenerator.generate(widget.session), name: 'laporan_pengeringan_' '${DateFormat('yyyyMMdd_HHmm').format(widget.session.startTime)}', ); } Future _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 _confirmAndDelete() async { final dateFmt = DateFormat('dd MMM yyyy', 'id_ID'); final confirmed = await showDialog( 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 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("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 _pickLabelIndices(int length, {int maxLabels = 5}) { if (length <= maxLabels) { return List.generate(length, (i) => i).toSet(); } final step = (length - 1) / (maxLabels - 1); final indices = {}; 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 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.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), ), ), ); } }