TKK_E32230814/lib/screens/histori_page.dart

948 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import '../services/firebase_database_service.dart';
import '../theme/app_color.dart';
class HistoriPage extends StatefulWidget {
const HistoriPage({super.key});
@override
State<HistoriPage> createState() => _HistoriPageState();
}
class _HistoriPageState extends State<HistoriPage> {
final FirebaseDatabaseService _dbService = FirebaseDatabaseService();
DateTime? _startDate;
DateTime? _endDate;
String _selectedPot = 'Semua Pot';
String _selectedJenis = 'Semua';
bool _isLoading = false;
List<_HistoryEntry> _filteredEntries = [];
int _sampleCount = 0;
int _currentPage = 1;
final int _itemsPerPage = 10;
int _requestSerial = 0;
final List<String> _potOptions = const [
'Semua Pot',
'Pot 1',
'Pot 2',
'Pot 3',
'Pot 4',
'Pot 5',
];
final List<String> _jenisOptions = const [
'Semua',
'Monitoring',
'Penyiraman',
];
@override
void initState() {
super.initState();
_loadDefaultData();
}
Future<void> _loadDefaultData() async {
final endDate = DateTime.now();
final startDate = endDate.subtract(const Duration(days: 7));
setState(() {
_startDate = startDate;
_endDate = endDate;
});
await _loadHistoryData();
}
Future<void> _loadHistoryData() async {
if (_startDate == null || _endDate == null) return;
final requestId = ++_requestSerial;
setState(() {
_isLoading = true;
_currentPage = 1;
});
try {
final data = await _dbService.getHistoryByDateRange(
_startDate!,
_endDate!,
);
debugPrint(
'Date range: ${_startDate!.toIso8601String()} to ${_endDate!.toIso8601String()}',
);
debugPrint('Total entries fetched: ${data.keys.length}');
final rawEntries = _flattenHistoryData(data);
final filteredEntries = _applyFilters(rawEntries);
if (filteredEntries.isEmpty && mounted && requestId == _requestSerial) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Belum ada data histori pada rentang tanggal ini.'),
backgroundColor: Colors.orange,
),
);
}
if (!mounted || requestId != _requestSerial) return;
setState(() {
_filteredEntries = filteredEntries;
_sampleCount = filteredEntries.length;
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading history: $e');
if (!mounted || requestId != _requestSerial) return;
setState(() {
_isLoading = false;
});
if (mounted && requestId == _requestSerial) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
}
}
}
/// Parse format baru: flat list via push() — setiap entri sudah per-pot
List<_HistoryEntry> _flattenHistoryData(Map<String, dynamic> historyData) {
final entries = <_HistoryEntry>[];
historyData.forEach((key, value) {
if (value is! Map) return;
// ── Format date-keyed (ESP32): key = 'date|YYYY-MM-DD' ────────────────
if (key.startsWith('date|')) {
final dateStr = key.substring(5); // e.g. '2026-06-15'
final dateParts = dateStr.split('-');
if (dateParts.length != 3) return;
final year = int.tryParse(dateParts[0]);
final month = int.tryParse(dateParts[1]);
final day = int.tryParse(dateParts[2]);
if (year == null || month == null || day == null) return;
// Setiap child adalah time-slot (HH:mm) yang berisi sensor/log record
final timeSlots = Map<String, dynamic>.from(value);
timeSlots.forEach((timeKey, timeValue) {
if (timeValue is! Map) return;
final timeParts = timeKey.split(':');
int hour = 0, minute = 0;
if (timeParts.length >= 2) {
hour = int.tryParse(timeParts[0]) ?? 0;
minute = int.tryParse(timeParts[1]) ?? 0;
}
final dateTime = DateTime(year, month, day, hour, minute);
final slotMap = Map<String, dynamic>.from(timeValue);
_parseSlotAndAdd(entries, slotMap, dateTime);
});
return; // done with this date-keyed entry
}
// ── Format flat push-key / single entry ──────────────────────────────
final map = Map<String, dynamic>.from(value);
final timestamp = map['timestamp'];
final dateTime = _parseDateTimeFromEntry(map, timestamp);
_parseSlotAndAdd(entries, map, dateTime);
});
entries.sort((a, b) {
final cmp = b.dateTime.compareTo(a.dateTime); // Terbaru dulu
if (cmp != 0) return cmp;
return a.potNumber.compareTo(b.potNumber);
});
return entries;
}
/// Parse satu record/slot sensor dan tambahkan entry ke daftar.
void _parseSlotAndAdd(
List<_HistoryEntry> entries,
Map<String, dynamic> map,
DateTime dateTime,
) {
// 1. Tentukan jenis histori & type
final typeStr = map['type']?.toString() ?? '';
if (typeStr == 'app_log') return; // Skip app_log entries in history
final bool isMonitoring =
(typeStr == 'auto_log' ||
map['jenis_histori']?.toString() == 'monitoring');
final String jenisHistori = isMonitoring ? 'monitoring' : 'penyiraman';
// 2. Tentukan mode
String mode = map['mode']?.toString() ?? '-';
if (mode == '-' && !isMonitoring) {
if (typeStr.contains('waktu')) {
mode = 'waktu';
} else if (typeStr.contains('sensor')) {
mode = 'sensor';
} else if (typeStr == 'manual') {
mode = 'manual';
}
}
final temp = _tryParseDouble(map['suhu']);
final humidity = _tryParseDouble(map['kelembapan']);
// Durasi: support field 'durasi' atau 'duration'
final durasi = map['durasi'] != null
? int.tryParse(map['durasi'].toString()) ?? 0
: (map['duration'] != null
? int.tryParse(map['duration'].toString()) ?? 0
: 0);
// 3a. Format baru (per-pot): ada field `pot` langsung
if (map.containsKey('pot') && map['pot'] != null) {
final potNumber = int.tryParse(map['pot'].toString()) ?? 1;
final soil = _tryParseDouble(map['soil']);
entries.add(_HistoryEntry(
dateTime: dateTime,
jenisHistori: jenisHistori,
mode: mode,
potNumber: potNumber,
temp: temp,
humidity: humidity,
soil: soil,
durasi: durasi,
type: typeStr,
));
return;
}
// 3b. Format lama (multi-pot): ada field soil_1 s.d soil_5
List<int> targetPots = [];
if (isMonitoring) {
// Monitoring: tampilkan semua pot yang datanya ada
for (int p = 1; p <= 5; p++) {
if (map['soil_$p'] != null) targetPots.add(p);
}
if (targetPots.isEmpty) targetPots = [1, 2, 3, 4, 5];
} else {
// Penyiraman: hanya pot yang aktif (field `pots`)
final potsRaw = map['pots'];
if (potsRaw is List) {
targetPots = potsRaw
.map((e) => int.tryParse(e.toString()) ?? 0)
.where((p) => p >= 1 && p <= 5)
.toList();
} else if (potsRaw is Map) {
targetPots = potsRaw.values
.map((e) => int.tryParse(e.toString()) ?? 0)
.where((p) => p >= 1 && p <= 5)
.toList();
}
// Fallback jika field pots kosong atau null
if (targetPots.isEmpty) {
for (int p = 1; p <= 5; p++) {
if (map['soil_$p'] != null) targetPots.add(p);
}
}
if (targetPots.isEmpty) targetPots = [1, 2, 3, 4, 5];
}
// Jika record tidak punya data sensor apapun, skip
if (targetPots.isEmpty &&
map['suhu'] == null &&
map['kelembapan'] == null) {
return;
}
for (final potNumber in targetPots) {
final soil = _tryParseDouble(map['soil_$potNumber'])
?? _tryParseDouble(map['soil']);
entries.add(_HistoryEntry(
dateTime: dateTime,
jenisHistori: jenisHistori,
mode: mode,
potNumber: potNumber,
temp: temp,
humidity: humidity,
soil: soil,
durasi: durasi,
type: typeStr,
));
}
}
DateTime _parseDateTimeFromEntry(Map<String, dynamic> map, dynamic timestamp) {
// Coba dari timestamp (ms)
if (timestamp != null) {
final millis = int.tryParse(timestamp.toString());
if (millis != null && millis > 0) {
return DateTime.fromMillisecondsSinceEpoch(millis);
}
}
// Fallback: dari field tanggal & waktu
try {
final tanggal = map['tanggal']?.toString() ?? '';
final waktu = map['waktu']?.toString() ?? '';
if (tanggal.isNotEmpty && waktu.isNotEmpty) {
final dateParts = tanggal.split('/').map(int.parse).toList();
final timeParts = waktu.split(':').map(int.parse).toList();
if (dateParts.length == 3 && timeParts.length >= 2) {
return DateTime(
dateParts[2], // year
dateParts[1], // month
dateParts[0], // day
timeParts[0],
timeParts[1],
);
}
}
} catch (_) {}
return DateTime.now();
}
double? _tryParseDouble(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
final parsed = double.tryParse(text);
if (parsed == null || parsed < -50 || parsed > 1000) return null;
return parsed;
}
List<_HistoryEntry> _applyFilters(List<_HistoryEntry> entries) {
var result = entries;
// Filter jenis
if (_selectedJenis != 'Semua') {
final targetJenis = _selectedJenis.toLowerCase();
result = result.where((e) => e.jenisHistori == targetJenis).toList();
}
// Filter pot
if (_selectedPot != 'Semua Pot') {
final potNumber = _selectedPotNumber();
if (potNumber != null) {
result = result.where((e) => e.potNumber == potNumber).toList();
}
}
return result;
}
int? _selectedPotNumber() {
if (_selectedPot == 'Semua Pot') return null;
final parts = _selectedPot.split(' ');
if (parts.length < 2) return null;
return int.tryParse(parts[1]);
}
String _getKeterangan(_HistoryEntry entry) {
if (entry.jenisHistori == 'monitoring') {
return 'Monitoring Berkala';
} else {
String typeLabel = '';
final t = entry.type.toLowerCase();
if (t.startsWith('waktu_jadwal_')) {
final num = entry.type.substring('waktu_jadwal_'.length);
typeLabel = 'Jadwal $num';
} else if (t.startsWith('waktu_')) {
typeLabel = entry.type.substring('waktu_'.length).toUpperCase();
} else if (t == 'sensor_threshold' || t.startsWith('sensor')) {
typeLabel = 'Sensor';
} else if (t == 'manual') {
typeLabel = 'Manual';
} else {
typeLabel = entry.type.isNotEmpty ? entry.type : 'Penyiraman';
}
if (entry.durasi > 0) {
return 'Penyiraman $typeLabel (${entry.durasi} detik)';
} else {
return 'Penyiraman $typeLabel';
}
}
}
Future<void> _selectDateRange() async {
final DateTimeRange? picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2024),
lastDate: DateTime.now(),
initialDateRange:
_startDate != null && _endDate != null
? DateTimeRange(start: _startDate!, end: _endDate!)
: null,
builder: (context, child) {
return Theme(
data: Theme.of(
context,
).copyWith(colorScheme: ColorScheme.light(primary: AppColor.primary)),
child: child!,
);
},
);
if (picked != null) {
setState(() {
_startDate = picked.start;
_endDate = picked.end;
});
await _loadHistoryData();
}
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Histori Sistem',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColor.textDark,
),
),
IconButton(
icon: Icon(Icons.refresh, color: AppColor.primary),
onPressed: _isLoading ? null : _loadHistoryData,
tooltip: 'Refresh data',
),
],
),
const SizedBox(height: 8),
// Legend
Row(
children: [
_buildLegendChip('Monitoring', Colors.blue.shade700, Colors.blue.shade50),
const SizedBox(width: 8),
_buildLegendChip('Penyiraman', Colors.green.shade700, Colors.green.shade50),
],
),
const SizedBox(height: 16),
_buildFilterSection(),
const SizedBox(height: 16),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
),
if (!_isLoading && _sampleCount == 0) _buildEmptyState(),
if (!_isLoading && _sampleCount > 0) ...[_buildDataTable()],
],
),
);
}
Widget _buildLegendChip(String label, Color textColor, Color bgColor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: textColor.withValues(alpha: 0.4)),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: textColor,
),
),
);
}
Widget _buildJenisBadge(String jenis) {
final isMonitoring = jenis == 'monitoring';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isMonitoring ? Colors.blue.shade50 : Colors.green.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isMonitoring
? Colors.blue.shade300
: Colors.green.shade300,
),
),
child: Text(
isMonitoring ? 'Monitoring' : 'Penyiraman',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isMonitoring ? Colors.blue.shade800 : Colors.green.shade800,
),
),
);
}
Widget _buildModeBadge(String mode) {
if (mode == '-' || mode.isEmpty) {
return Text('-', style: TextStyle(fontSize: 11, color: Colors.grey[500]));
}
Color color;
switch (mode.toLowerCase()) {
case 'manual':
color = Colors.orange.shade700;
break;
case 'waktu':
color = Colors.purple.shade700;
break;
case 'sensor':
color = Colors.teal.shade700;
break;
default:
color = Colors.grey.shade700;
}
return Text(
mode[0].toUpperCase() + mode.substring(1),
style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600),
);
}
Widget _buildDataTable() {
final displayEntries = _filteredEntries;
final totalPages = (displayEntries.length / _itemsPerPage).ceil();
final startIndex = (_currentPage - 1) * _itemsPerPage;
final endIndex = (startIndex + _itemsPerPage).clamp(
0,
displayEntries.length,
);
final pageEntries = displayEntries.sublist(startIndex, endIndex);
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.table_chart, color: AppColor.primary, size: 22),
const SizedBox(width: 8),
Text(
'Riwayat Data',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColor.textDark,
),
),
],
),
const SizedBox(height: 12),
Text(
'Total: ${displayEntries.length} entri',
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 10,
columns: const [
DataColumn(
label: Text(
'Jenis',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Tanggal & Waktu',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Pot',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Suhu (°C)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Kelembapan (%)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Soil (%)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Keterangan',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
DataColumn(
label: Text(
'Mode',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
],
rows: pageEntries.map((entry) {
return DataRow(
color: WidgetStateProperty.all(
_getRowColor(entry),
),
cells: [
DataCell(_buildJenisBadge(entry.jenisHistori)),
DataCell(
Text(
'${entry.dateTime.day}/${entry.dateTime.month}/${entry.dateTime.year} '
'${entry.dateTime.hour.toString().padLeft(2, '0')}:${entry.dateTime.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 11),
),
),
DataCell(
Text(
'Pot ${entry.potNumber}',
style: const TextStyle(fontSize: 12),
),
),
DataCell(
Text(
entry.temp != null
? entry.temp!.toStringAsFixed(1)
: '-',
style: const TextStyle(fontSize: 12),
),
),
DataCell(
Text(
entry.humidity != null
? entry.humidity!.toStringAsFixed(1)
: '-',
style: const TextStyle(fontSize: 12),
),
),
DataCell(
Text(
entry.soil != null
? entry.soil!.toStringAsFixed(1)
: '-',
style: const TextStyle(fontSize: 12),
),
),
DataCell(
Text(
_getKeterangan(entry),
style: const TextStyle(fontSize: 11),
),
),
DataCell(_buildModeBadge(entry.mode)),
],
);
}).toList(),
),
),
const SizedBox(height: 16),
// Pagination controls
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed:
_currentPage > 1
? () {
setState(() {
_currentPage--;
});
}
: null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Halaman $_currentPage dari $totalPages',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColor.textDark,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed:
_currentPage < totalPages
? () {
setState(() {
_currentPage++;
});
}
: null,
),
],
),
if (totalPages > 1) ...[
const SizedBox(height: 8),
Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 1; i <= totalPages; i++)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: GestureDetector(
onTap: () {
setState(() {
_currentPage = i;
});
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color:
_currentPage == i
? AppColor.primary
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.center,
child: Text(
'$i',
style: TextStyle(
color:
_currentPage == i
? Colors.white
: Colors.black87,
fontWeight:
_currentPage == i
? FontWeight.bold
: FontWeight.normal,
),
),
),
),
),
],
),
),
),
],
],
),
),
);
}
Color _getRowColor(_HistoryEntry entry) {
// Warna berdasarkan jenis histori dulu, kemudian pot
if (entry.jenisHistori == 'penyiraman') {
switch (entry.potNumber) {
case 1: return const Color(0xFFE8F5E9); // Hijau muda
case 2: return const Color(0xFFDCEDC8);
case 3: return const Color(0xFFF1F8E9);
case 4: return const Color(0xFFCCFF90).withValues(alpha: 0.4);
case 5: return const Color(0xFFB9F6CA).withValues(alpha: 0.5);
default: return const Color(0xFFE8F5E9);
}
} else {
switch (entry.potNumber) {
case 1: return const Color(0xFFE3F2FD); // Biru muda
case 2: return const Color(0xFFE8EAF6);
case 3: return const Color(0xFFE1F5FE);
case 4: return const Color(0xFFE0F7FA);
case 5: return const Color(0xFFEDE7F6);
default: return const Color(0xFFE3F2FD);
}
}
}
Widget _buildFilterSection() {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filter & Kontrol',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColor.textDark,
),
),
const SizedBox(height: 12),
InkWell(
onTap: _selectDateRange,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.calendar_today,
color: AppColor.primary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_startDate == null
? 'Pilih Rentang Tanggal'
: '${_startDate!.day}/${_startDate!.month}/${_startDate!.year} - ${_endDate!.day}/${_endDate!.month}/${_endDate!.year}',
style: TextStyle(
fontSize: 14,
color:
_startDate == null
? Colors.grey[600]
: Colors.black87,
),
),
),
Icon(Icons.arrow_drop_down, color: Colors.grey[600]),
],
),
),
),
const SizedBox(height: 12),
// Filter Jenis Histori
DropdownButtonFormField<String>(
value: _selectedJenis,
decoration: InputDecoration(
prefixIcon: Icon(Icons.category, color: AppColor.primary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
labelText: 'Jenis Histori',
),
items: _jenisOptions
.map(
(jenis) =>
DropdownMenuItem(value: jenis, child: Text(jenis)),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedJenis = value;
_currentPage = 1;
});
_loadHistoryData();
},
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _selectedPot,
decoration: InputDecoration(
prefixIcon: Icon(Icons.filter_list, color: AppColor.primary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
labelText: 'Filter Pot',
),
items: _potOptions
.map(
(pot) => DropdownMenuItem(value: pot, child: Text(pot)),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedPot = value;
_currentPage = 1;
});
_loadHistoryData();
},
),
],
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Belum ada data histori untuk filter ini',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Ubah jenis/pot/tanggal untuk melihat data.',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class _HistoryEntry {
const _HistoryEntry({
required this.dateTime,
required this.jenisHistori,
required this.mode,
required this.potNumber,
required this.temp,
required this.humidity,
required this.soil,
required this.durasi,
required this.type,
});
final DateTime dateTime;
final String jenisHistori; // 'monitoring' atau 'penyiraman'
final String mode; // 'manual', 'waktu', 'sensor', '-'
final int potNumber;
final double? temp;
final double? humidity;
final double? soil;
final int durasi;
final String type;
}