845 lines
22 KiB
Dart
845 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'dart:async';
|
||
import 'package:firebase_database/firebase_database.dart';
|
||
|
||
import 'settings_screen.dart';
|
||
import 'statistic_screen.dart';
|
||
|
||
class DashboardScreen extends StatefulWidget {
|
||
const DashboardScreen({super.key});
|
||
|
||
@override
|
||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||
}
|
||
|
||
class _DashboardScreenState extends State<DashboardScreen> {
|
||
final DatabaseReference _dbRef = FirebaseDatabase.instance.ref();
|
||
|
||
// ================= STATE =================
|
||
|
||
bool isManual = false;
|
||
bool pumpOn = false;
|
||
int selectedIndex = 0;
|
||
double humidity = 0.0;
|
||
DateTime now = DateTime.now();
|
||
|
||
// ================= DATA STORAGE =================
|
||
|
||
List<Map<String, dynamic>> historyData = [];
|
||
List<Map<String, dynamic>> realtimePoints = [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
_listenToFirebase();
|
||
_cleanOldDataFromFirebase();
|
||
|
||
Timer.periodic(
|
||
const Duration(seconds: 1),
|
||
(timer) {
|
||
if (mounted) {
|
||
setState(() {
|
||
now = DateTime.now();
|
||
});
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// FIREBASE
|
||
// =================================================
|
||
|
||
void _listenToFirebase() {
|
||
// ================= REALTIME SENSOR =================
|
||
|
||
_dbRef.child("sensor").onValue.listen((event) {
|
||
if (event.snapshot.value == null) return;
|
||
|
||
final data = Map<String, dynamic>.from(
|
||
event.snapshot.value as Map,
|
||
);
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
humidity = (data["soil_percent"] ?? 0).toDouble();
|
||
pumpOn = (data["pump_status"] ?? 0) == 1;
|
||
isManual = (data["is_manual"] ?? false);
|
||
|
||
_recordRealtimePoint(humidity);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ================= HISTORY =================
|
||
|
||
_dbRef.child("history").orderByChild("timestamp").onValue.listen((event) {
|
||
if (event.snapshot.value == null) {
|
||
setState(() {
|
||
historyData = [];
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
final Map<dynamic, dynamic> rawData =
|
||
event.snapshot.value as Map<dynamic, dynamic>;
|
||
|
||
List<Map<String, dynamic>> fetchedHistory = [];
|
||
|
||
rawData.forEach((key, value) {
|
||
fetchedHistory.add({
|
||
"id": key,
|
||
"timestamp": DateTime.fromMillisecondsSinceEpoch(
|
||
value["timestamp"] ?? 0,
|
||
),
|
||
"value": (value["value"] ?? 0).toDouble(),
|
||
"pump": value["pump"] ?? "-",
|
||
});
|
||
});
|
||
|
||
// terbaru -> lama
|
||
|
||
fetchedHistory.sort(
|
||
(a, b) => b["timestamp"].compareTo(a["timestamp"]),
|
||
);
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
historyData = fetchedHistory;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// =================================================
|
||
// HAPUS DATA > 7 HARI
|
||
// =================================================
|
||
|
||
void _cleanOldDataFromFirebase() async {
|
||
final int sevenDaysAgo =
|
||
DateTime.now().subtract(const Duration(days: 7)).millisecondsSinceEpoch;
|
||
|
||
Query oldLogsQuery =
|
||
_dbRef.child("history").orderByChild("timestamp").endAt(sevenDaysAgo);
|
||
|
||
DataSnapshot snapshot = await oldLogsQuery.get();
|
||
|
||
if (snapshot.exists) {
|
||
Map<String, dynamic> updates = {};
|
||
|
||
Map<dynamic, dynamic> data = snapshot.value as Map;
|
||
|
||
data.forEach((key, value) {
|
||
updates[key.toString()] = null;
|
||
});
|
||
|
||
await _dbRef.child("history").update(updates);
|
||
|
||
debugPrint(
|
||
"Data lama (>7 hari) berhasil dibersihkan.",
|
||
);
|
||
}
|
||
}
|
||
|
||
// =================================================
|
||
// REALTIME GRAPH DATA
|
||
// =================================================
|
||
|
||
void _recordRealtimePoint(double val) {
|
||
realtimePoints.insert(0, {
|
||
"value": val,
|
||
"timeLabel":
|
||
"${now.hour}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}"
|
||
});
|
||
|
||
if (realtimePoints.length > 50) {
|
||
realtimePoints.removeLast();
|
||
}
|
||
}
|
||
|
||
// =================================================
|
||
// FIREBASE CONTROL
|
||
// =================================================
|
||
|
||
void _updateModeFirebase(bool manual) {
|
||
_dbRef.child("sensor").update({
|
||
"is_manual": manual,
|
||
});
|
||
}
|
||
|
||
void _updatePumpFirebase(bool value) {
|
||
if (isManual) {
|
||
_dbRef.child("sensor").update({
|
||
"pump_status": value ? 1 : 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// =================================================
|
||
// UI
|
||
// =================================================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF1F4F8),
|
||
body: IndexedStack(
|
||
index: selectedIndex,
|
||
children: [
|
||
_buildDashboardTab(),
|
||
_buildHistoryTab(),
|
||
],
|
||
),
|
||
bottomNavigationBar: _buildBottomNav(),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// BOTTOM NAVIGATION
|
||
// =================================================
|
||
|
||
Widget _buildBottomNav() {
|
||
return BottomNavigationBar(
|
||
currentIndex: selectedIndex,
|
||
onTap: (i) {
|
||
setState(() {
|
||
selectedIndex = i;
|
||
});
|
||
},
|
||
selectedItemColor: const Color(0xFF2E7D32),
|
||
items: const [
|
||
BottomNavigationBarItem(
|
||
icon: Icon(Icons.dashboard_rounded),
|
||
label: "Beranda",
|
||
),
|
||
BottomNavigationBarItem(
|
||
icon: Icon(Icons.history_rounded),
|
||
label: "Riwayat",
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// DASHBOARD TAB
|
||
// =================================================
|
||
|
||
Widget _buildDashboardTab() {
|
||
return Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.white,
|
||
elevation: 0.5,
|
||
title: const Text(
|
||
"Monitoring Selada Hidroponik 🥬",
|
||
style: TextStyle(
|
||
color: Colors.black,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
children: [
|
||
_buildHumidityCard(),
|
||
const SizedBox(height: 20),
|
||
_buildModeSelector(),
|
||
const SizedBox(height: 20),
|
||
_buildPumpControl(),
|
||
const SizedBox(height: 20),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _menuCard(
|
||
Icons.bar_chart,
|
||
"Statistik",
|
||
),
|
||
),
|
||
const SizedBox(width: 15),
|
||
Expanded(
|
||
child: _menuCard(
|
||
Icons.settings,
|
||
"Pengaturan",
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// HUMIDITY CARD
|
||
// =================================================
|
||
|
||
Widget _buildHumidityCard() {
|
||
double percentage = (humidity.clamp(0, 100) / 100);
|
||
|
||
double alignX = (percentage * 2) - 1;
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 25,
|
||
horizontal: 20,
|
||
),
|
||
decoration: _cardDecoration(),
|
||
child: Column(
|
||
children: [
|
||
const Text(
|
||
"KELEMBAPAN ROCKWOOL",
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w900,
|
||
letterSpacing: 1.1,
|
||
),
|
||
),
|
||
const SizedBox(height: 15),
|
||
Text(
|
||
"${humidity.toStringAsFixed(0)}%",
|
||
style: const TextStyle(
|
||
fontSize: 65,
|
||
fontWeight: FontWeight.bold,
|
||
color: Color(0xFF2E7D32),
|
||
),
|
||
),
|
||
const SizedBox(height: 15),
|
||
_buildIndicatorBar(alignX),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// INDICATOR BAR
|
||
// =================================================
|
||
|
||
Widget _buildIndicatorBar(double alignX) {
|
||
return SizedBox(
|
||
height: 60,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Align(
|
||
alignment: Alignment.bottomCenter,
|
||
child: Container(
|
||
height: 40,
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(20),
|
||
gradient: const LinearGradient(
|
||
colors: [
|
||
Colors.blue,
|
||
Colors.green,
|
||
Colors.orange,
|
||
],
|
||
),
|
||
),
|
||
child: const Center(
|
||
child: Text(
|
||
"Ideal 50% – 56%",
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: -5,
|
||
left: 20,
|
||
right: 20,
|
||
child: Align(
|
||
alignment: Alignment(alignX, 0),
|
||
child: const Icon(
|
||
Icons.arrow_drop_down_sharp,
|
||
size: 50,
|
||
color: Colors.red,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// MODE SELECTOR
|
||
// =================================================
|
||
|
||
Widget _buildModeSelector() {
|
||
return Container(
|
||
padding: const EdgeInsets.all(5),
|
||
decoration: _cardDecoration(),
|
||
child: Row(
|
||
children: [
|
||
_modeButton(
|
||
"Otomatis",
|
||
!isManual,
|
||
),
|
||
_modeButton(
|
||
"Manual",
|
||
isManual,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _modeButton(
|
||
String text,
|
||
bool selected,
|
||
) {
|
||
return Expanded(
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
_updateModeFirebase(
|
||
text == "Manual",
|
||
);
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 12,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: selected ? const Color(0xFF2E7D32) : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(15),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: selected ? Colors.white : Colors.black54,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// PUMP CONTROL
|
||
// =================================================
|
||
|
||
Widget _buildPumpControl() {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 20,
|
||
vertical: 8,
|
||
),
|
||
decoration: _cardDecoration(),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.water_drop,
|
||
color: pumpOn ? Colors.blue : Colors.grey,
|
||
),
|
||
const SizedBox(width: 15),
|
||
const Text(
|
||
"Status Pompa",
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Switch(
|
||
value: pumpOn,
|
||
activeColor: Colors.blue,
|
||
onChanged: isManual ? (v) => _updatePumpFirebase(v) : null,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// HISTORY TAB
|
||
// =================================================
|
||
|
||
Widget _buildHistoryTab() {
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF2F4F7),
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.white,
|
||
elevation: 0.5,
|
||
title: const Text(
|
||
"Riwayat Penyiraman",
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
body: Column(
|
||
children: [
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 15,
|
||
),
|
||
child: const Text(
|
||
"Riwayat penyiraman akan otomatis terhapus setelah 7 hari",
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Color(0xFFD17000),
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: historyData.isEmpty
|
||
? const Center(
|
||
child: Text(
|
||
"Belum ada data riwayat...",
|
||
),
|
||
)
|
||
: ListView.builder(
|
||
padding: const EdgeInsets.all(20),
|
||
itemCount: historyData.length,
|
||
itemBuilder: (context, index) {
|
||
final item = historyData[index];
|
||
|
||
return _buildHistoryItem(item);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// HISTORY ITEM
|
||
// =================================================
|
||
|
||
// =================================================
|
||
// HISTORY ITEM
|
||
// =================================================
|
||
|
||
Widget _buildHistoryItem(
|
||
Map<String, dynamic> item,
|
||
) {
|
||
DateTime ts = item["timestamp"];
|
||
|
||
String time =
|
||
"${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}";
|
||
|
||
const List<String> days = [
|
||
"Minggu",
|
||
"Senin",
|
||
"Selasa",
|
||
"Rabu",
|
||
"Kamis",
|
||
"Jumat",
|
||
"Sabtu"
|
||
];
|
||
|
||
const List<String> months = [
|
||
"Januari",
|
||
"Februari",
|
||
"Maret",
|
||
"April",
|
||
"Mei",
|
||
"Juni",
|
||
"Juli",
|
||
"Agustus",
|
||
"September",
|
||
"Oktober",
|
||
"November",
|
||
"Desember"
|
||
];
|
||
|
||
String date = "${days[ts.weekday % 7]}, "
|
||
"${ts.day.toString().padLeft(2, '0')} "
|
||
"${months[ts.month - 1]} "
|
||
"${ts.year}";
|
||
|
||
// ================= WARNA KELEMBAPAN =================
|
||
|
||
Color humidityColor;
|
||
|
||
if (item["value"] < 50) {
|
||
humidityColor = Colors.orange;
|
||
} else if (item["value"] <= 56) {
|
||
humidityColor = Colors.green;
|
||
} else {
|
||
humidityColor = Colors.blue;
|
||
}
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 15),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: _cardDecoration(),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ================= HEADER =================
|
||
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
// ================= ICON + STATUS =================
|
||
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: item["pump"] == "Penyiraman Aktif"
|
||
? Colors.blue.withOpacity(0.1)
|
||
: Colors.green.withOpacity(0.1),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
item["pump"] == "Penyiraman Aktif"
|
||
? Icons.water_drop
|
||
: Icons.check_circle,
|
||
color: item["pump"] == "Penyiraman Aktif"
|
||
? Colors.blue
|
||
: Colors.green,
|
||
size: 22,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
item["pump"],
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ================= DELETE =================
|
||
|
||
InkWell(
|
||
borderRadius: BorderRadius.circular(50),
|
||
onTap: () async {
|
||
bool? confirm = await showDialog(
|
||
context: context,
|
||
builder: (context) {
|
||
return AlertDialog(
|
||
title: const Text(
|
||
"Hapus Riwayat",
|
||
),
|
||
content: const Text(
|
||
"Yakin ingin menghapus riwayat ini?",
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.pop(
|
||
context,
|
||
false,
|
||
);
|
||
},
|
||
child: const Text(
|
||
"Batal",
|
||
),
|
||
),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.red,
|
||
),
|
||
onPressed: () {
|
||
Navigator.pop(
|
||
context,
|
||
true,
|
||
);
|
||
},
|
||
child: const Text(
|
||
"Hapus",
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
|
||
if (confirm == true) {
|
||
await _dbRef.child("history").child(item["id"]).remove();
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
"Riwayat berhasil dihapus",
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.all(6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.red.withOpacity(0.1),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Icons.delete,
|
||
color: Colors.red,
|
||
size: 18,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
const Divider(height: 25),
|
||
|
||
// ================= JAM =================
|
||
|
||
_infoRow(
|
||
Icons.access_time,
|
||
"Jam",
|
||
time,
|
||
),
|
||
|
||
const SizedBox(height: 12),
|
||
|
||
// ================= TANGGAL =================
|
||
|
||
_infoRow(
|
||
Icons.calendar_month,
|
||
"Tanggal",
|
||
date,
|
||
),
|
||
|
||
const SizedBox(height: 12),
|
||
|
||
// ================= KELEMBAPAN =================
|
||
|
||
_infoRow(
|
||
Icons.water,
|
||
"Kelembapan",
|
||
"${item["value"].toStringAsFixed(0)}%",
|
||
valueColor: humidityColor,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// INFO ROW
|
||
// =================================================
|
||
|
||
// =================================================
|
||
// INFO ROW
|
||
// =================================================
|
||
|
||
Widget _infoRow(
|
||
IconData icon,
|
||
String title,
|
||
String value, {
|
||
Color valueColor = Colors.black,
|
||
}) {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
icon,
|
||
size: 18,
|
||
color: Colors.grey,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(title),
|
||
],
|
||
),
|
||
Text(
|
||
value,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: valueColor,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// MENU CARD
|
||
// =================================================
|
||
|
||
Widget _menuCard(
|
||
IconData icon,
|
||
String title,
|
||
) {
|
||
return GestureDetector(
|
||
onTap: () {
|
||
Widget target = (title == "Pengaturan")
|
||
? const SettingsScreen()
|
||
: StatisticScreen(
|
||
realtimeLogs: realtimePoints,
|
||
);
|
||
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => target,
|
||
),
|
||
);
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: _cardDecoration(),
|
||
child: Column(
|
||
children: [
|
||
Icon(
|
||
icon,
|
||
color: const Color(0xFF2E7D32),
|
||
size: 30,
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// =================================================
|
||
// CARD DECORATION
|
||
// =================================================
|
||
|
||
BoxDecoration _cardDecoration() {
|
||
return BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.05),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|