576 lines
18 KiB
Dart
576 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../theme/app_color.dart';
|
|
import '../services/firebase_database_service.dart';
|
|
import '../services/history_logging_service.dart';
|
|
|
|
class HistoriPage extends StatefulWidget {
|
|
const HistoriPage({super.key});
|
|
|
|
@override
|
|
State<HistoriPage> createState() => _HistoriPageState();
|
|
}
|
|
|
|
class _HistoriPageState extends State<HistoriPage> {
|
|
final FirebaseDatabaseService _dbService = FirebaseDatabaseService();
|
|
final HistoryLoggingService _loggingService = HistoryLoggingService();
|
|
|
|
DateTime? _startDate;
|
|
DateTime? _endDate;
|
|
String _selectedPot = 'Semua Pot';
|
|
bool _isLoading = false;
|
|
|
|
Map<String, double> _averages = {};
|
|
Map<String, Map<String, double>> _potAverages = {};
|
|
|
|
final List<String> _potOptions = [
|
|
'Semua Pot',
|
|
'Pot 1',
|
|
'Pot 2',
|
|
'Pot 3',
|
|
'Pot 4',
|
|
'Pot 5',
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Start logging service if not already running
|
|
if (!_loggingService.isActive) {
|
|
_loggingService.start();
|
|
}
|
|
// Load initial data (last 7 days)
|
|
_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;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final data = await _dbService.getHistoryByDateRange(
|
|
_startDate!,
|
|
_endDate!,
|
|
);
|
|
|
|
if (data.isEmpty) {
|
|
// No history yet, use current data
|
|
final currentData = await _dbService.getSensorData();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Belum ada data histori. Menampilkan data saat ini.',
|
|
),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
}
|
|
_calculateAveragesFromCurrent(currentData);
|
|
} else {
|
|
_calculateAverages(data);
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
debugPrint('Error loading history: $e');
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _calculateAverages(Map<String, dynamic> historyData) {
|
|
double totalTemp = 0;
|
|
double totalHumidity = 0;
|
|
double totalLdr = 0;
|
|
Map<int, double> totalSoil = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
|
|
int count = 0;
|
|
Map<int, int> soilCount = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
|
|
|
|
historyData.forEach((dateKey, dateData) {
|
|
if (dateData is Map) {
|
|
dateData.forEach((timeKey, timeData) {
|
|
if (timeData is Map) {
|
|
count++;
|
|
|
|
totalTemp +=
|
|
double.tryParse(timeData['suhu']?.toString() ?? '0') ?? 0;
|
|
totalHumidity +=
|
|
double.tryParse(timeData['kelembapan']?.toString() ?? '0') ?? 0;
|
|
totalLdr +=
|
|
double.tryParse(timeData['ldr']?.toString() ?? '0') ?? 0;
|
|
|
|
for (int i = 1; i <= 5; i++) {
|
|
final soil =
|
|
double.tryParse(timeData['soil_$i']?.toString() ?? '0') ?? 0;
|
|
if (soil > 0) {
|
|
totalSoil[i] = (totalSoil[i] ?? 0) + soil;
|
|
soilCount[i] = (soilCount[i] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
if (count > 0) {
|
|
_averages = {
|
|
'temp': totalTemp / count,
|
|
'humidity': totalHumidity / count,
|
|
'ldr': totalLdr / count,
|
|
};
|
|
|
|
_potAverages = {};
|
|
for (int i = 1; i <= 5; i++) {
|
|
final potCount = soilCount[i] ?? 0;
|
|
_potAverages['Pot $i'] = {
|
|
'temp': totalTemp / count,
|
|
'humidity': totalHumidity / count,
|
|
'soil': potCount > 0 ? (totalSoil[i] ?? 0) / potCount : 0,
|
|
'light': totalLdr / count,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
void _calculateAveragesFromCurrent(Map<String, dynamic> currentData) {
|
|
_averages = {
|
|
'temp': double.tryParse(currentData['suhu']?.toString() ?? '0') ?? 0,
|
|
'humidity':
|
|
double.tryParse(currentData['kelembapan']?.toString() ?? '0') ?? 0,
|
|
'ldr': double.tryParse(currentData['ldr']?.toString() ?? '0') ?? 0,
|
|
};
|
|
|
|
_potAverages = {};
|
|
for (int i = 1; i <= 5; i++) {
|
|
final soil =
|
|
double.tryParse(currentData['soil_$i']?.toString() ?? '0') ?? 0;
|
|
_potAverages['Pot $i'] = {
|
|
'temp': _averages['temp']!,
|
|
'humidity': _averages['humidity']!,
|
|
'soil': soil,
|
|
'light': _averages['ldr']!,
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
// Reload data with new date range
|
|
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(
|
|
'Hasil Monitoring',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColor.textDark,
|
|
),
|
|
),
|
|
// Manual refresh button
|
|
IconButton(
|
|
icon: Icon(Icons.refresh, color: AppColor.primary),
|
|
onPressed: _isLoading ? null : _loadHistoryData,
|
|
tooltip: 'Refresh data',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Info text
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 16),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Data disimpan setiap ${_loggingService.interval.inMinutes} menit',
|
|
style: TextStyle(fontSize: 12, color: Colors.blue.shade700),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Filter Section
|
|
Card(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Filter Data',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColor.textDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Date Range Filter
|
|
InkWell(
|
|
onTap: _selectDateRange,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
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),
|
|
|
|
// Pot Filter
|
|
DropdownButtonFormField<String>(
|
|
value: _selectedPot,
|
|
decoration: InputDecoration(
|
|
prefixIcon: Icon(
|
|
Icons.filter_list,
|
|
color: AppColor.primary,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
items:
|
|
_potOptions.map((String pot) {
|
|
return DropdownMenuItem<String>(
|
|
value: pot,
|
|
child: Text(pot),
|
|
);
|
|
}).toList(),
|
|
onChanged: (String? newValue) {
|
|
setState(() {
|
|
_selectedPot = newValue!;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Loading indicator
|
|
if (_isLoading)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
),
|
|
|
|
// Data display
|
|
if (!_isLoading && _averages.isEmpty)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32.0),
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Belum ada data histori',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Data akan tersimpan otomatis setiap ${_loggingService.interval.inMinutes} menit',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Overall Averages Section
|
|
if (!_isLoading && _averages.isNotEmpty) ...[
|
|
Text(
|
|
'Rata-Rata Keseluruhan',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColor.textDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildAverageCard(
|
|
title: 'Suhu Rata-Rata',
|
|
value: '${_averages['temp']?.toStringAsFixed(1) ?? '0'} °C',
|
|
icon: Icons.thermostat_outlined,
|
|
color: Colors.orange,
|
|
),
|
|
_buildAverageCard(
|
|
title: 'Kelembaban Udara Rata-Rata',
|
|
value: '${_averages['humidity']?.toStringAsFixed(1) ?? '0'} %',
|
|
icon: Icons.water_drop_outlined,
|
|
color: Colors.blue,
|
|
),
|
|
_buildAverageCard(
|
|
title: 'LDR Rata-Rata',
|
|
value: _averages['ldr']?.toStringAsFixed(1) ?? '0',
|
|
icon: Icons.wb_sunny_outlined,
|
|
color: Colors.amber,
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Per Pot Averages
|
|
if (_selectedPot != 'Semua Pot' &&
|
|
_potAverages.containsKey(_selectedPot)) ...[
|
|
Text(
|
|
'Rata-Rata $_selectedPot',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColor.textDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildPotAverageCard(_selectedPot),
|
|
] else if (_selectedPot == 'Semua Pot' &&
|
|
_potAverages.isNotEmpty) ...[
|
|
Text(
|
|
'Rata-Rata Per Pot',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColor.textDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
..._potAverages.keys.map((pot) => _buildPotAverageCard(pot)),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAverageCard({
|
|
required String title,
|
|
required String value,
|
|
required IconData icon,
|
|
required Color color,
|
|
}) {
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, color: color, size: 28),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPotAverageCard(String potName) {
|
|
final data = _potAverages[potName];
|
|
if (data == null) return const SizedBox.shrink();
|
|
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: ExpansionTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.yard, color: Colors.green, size: 24),
|
|
),
|
|
title: Text(
|
|
potName,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(
|
|
'Klik untuk detail',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
_buildDetailRow(
|
|
'Suhu',
|
|
'${data['temp']?.toStringAsFixed(1) ?? '0'} °C',
|
|
Icons.thermostat,
|
|
Colors.orange,
|
|
),
|
|
const Divider(),
|
|
_buildDetailRow(
|
|
'Kelembaban',
|
|
'${data['humidity']?.toStringAsFixed(1) ?? '0'} %',
|
|
Icons.water_drop,
|
|
Colors.blue,
|
|
),
|
|
const Divider(),
|
|
_buildDetailRow(
|
|
'Soil Moisture',
|
|
'${data['soil']?.toStringAsFixed(1) ?? '0'} %',
|
|
Icons.grass,
|
|
Colors.green,
|
|
),
|
|
const Divider(),
|
|
_buildDetailRow(
|
|
'Light',
|
|
data['light']?.toStringAsFixed(1) ?? '0',
|
|
Icons.wb_sunny,
|
|
Colors.amber,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailRow(
|
|
String label,
|
|
String value,
|
|
IconData icon,
|
|
Color color,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: color, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(label, style: const TextStyle(fontSize: 14))),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|