1931 lines
62 KiB
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |