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 createState() => _HistoriPageState(); } class _HistoriPageState extends State { 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 _potOptions = const [ 'Semua Pot', 'Pot 1', 'Pot 2', 'Pot 3', 'Pot 4', 'Pot 5', ]; final List _jenisOptions = const [ 'Semua', 'Monitoring', 'Penyiraman', ]; @override void initState() { super.initState(); _loadDefaultData(); } Future _loadDefaultData() async { final endDate = DateTime.now(); final startDate = endDate.subtract(const Duration(days: 7)); setState(() { _startDate = startDate; _endDate = endDate; }); await _loadHistoryData(); } Future _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 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.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.from(timeValue); _parseSlotAndAdd(entries, slotMap, dateTime); }); return; // done with this date-keyed entry } // ── Format flat push-key / single entry ────────────────────────────── final map = Map.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 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 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 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 _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( 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( 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; }