948 lines
30 KiB
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;
|
|
}
|