Resolve merge conflict
This commit is contained in:
commit
5fbdfa700c
|
|
@ -3,7 +3,7 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<application
|
||||
android:label="monitoring_jamur"
|
||||
android:label="monitoring jamur"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1 +1 @@
|
|||
6d2081b804991eed21be9bc57d58ff3fb73aae86
|
||||
11e7c02cde7287ec04bc2749290002346ea5a4c5
|
||||
14
iot.cpp
14
iot.cpp
|
|
@ -4,6 +4,7 @@
|
|||
#include <PubSubClient.h>
|
||||
#include <DHT.h>
|
||||
#include <time.h>
|
||||
|
||||
// ================= SUPABASE =================
|
||||
const char* supabase_url = "https://hpmicdhjyboyeofphgae.supabase.co";
|
||||
const char* supabase_key = "sb_publishable_OB8e9y1OO0z3Y5xQ828YvA_T3jD16zT";
|
||||
|
|
@ -19,7 +20,6 @@ const int daylightOffset_sec = 0;
|
|||
|
||||
// ================= MQTT =================
|
||||
const char* mqtt_server = "broker.hivemq.com";
|
||||
|
||||
WiFiClient espClient;
|
||||
PubSubClient client(espClient);
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ void uploadToSupabase(float hum, String status, String lamp, String pump) {
|
|||
// Ambil waktu saat ini
|
||||
struct tm timeinfo;
|
||||
String timeStr = "";
|
||||
|
||||
if (getLocalTime(&timeinfo)) {
|
||||
char timeStringBuff[50];
|
||||
strftime(timeStringBuff, sizeof(timeStringBuff), "%H:%M:%S", &timeinfo);
|
||||
|
|
@ -104,14 +105,12 @@ void uploadToSupabase(float hum, String status, String lamp, String pump) {
|
|||
void setup_wifi() {
|
||||
delay(10);
|
||||
Serial.println("Connecting to WiFi...");
|
||||
|
||||
WiFi.begin(ssid, password);
|
||||
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
|
||||
Serial.println("\nWiFi connected!");
|
||||
}
|
||||
|
||||
|
|
@ -145,8 +144,7 @@ void callback(char* topic, byte* payload, unsigned int length) {
|
|||
void reconnect() {
|
||||
while (!client.connected()) {
|
||||
Serial.print("Connecting MQTT...");
|
||||
|
||||
// Create a unique client ID using MAC address
|
||||
// Unique client ID using MAC address
|
||||
String clientId = "ESP32MJ-" + WiFi.macAddress();
|
||||
|
||||
if (client.connect(clientId.c_str(), "esp32/statusiqbal", 0, true, "offline")) {
|
||||
|
|
@ -168,15 +166,12 @@ void reconnect() {
|
|||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
pinMode(RELAY1, OUTPUT);
|
||||
pinMode(RELAY2, OUTPUT);
|
||||
|
||||
digitalWrite(RELAY1, RELAY_OFF);
|
||||
digitalWrite(RELAY2, RELAY_OFF);
|
||||
|
||||
dht.begin();
|
||||
|
||||
setup_wifi();
|
||||
|
||||
// Konfigurasi waktu dari NTP
|
||||
|
|
@ -193,7 +188,6 @@ void loop() {
|
|||
client.loop();
|
||||
|
||||
unsigned long now = millis();
|
||||
|
||||
if (now - lastMsg > 2000) {
|
||||
lastMsg = now;
|
||||
|
||||
|
|
@ -270,7 +264,7 @@ void loop() {
|
|||
Serial.print("Mode: "); Serial.print(isAutoMode ? "AUTO" : "MANUAL");
|
||||
Serial.print(" | Hum: "); Serial.print(kelembapan);
|
||||
Serial.print("% | Pump: "); Serial.print(currentPump ? "ON" : "OFF");
|
||||
Serial.print(" | Light: "); Serial.println(currentLight ? "ON" : "OFF");
|
||||
Serial.print(" | Light: "); Serial.print(currentLight ? "ON" : "OFF");
|
||||
Serial.print(" | Status: "); Serial.println(statusRelay);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
#include <WiFi.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <DHT.h>
|
||||
|
||||
// ================= SUPABASE =================
|
||||
const char* supabase_url = "https://hpmicdhjyboyeofphgae.supabase.co";
|
||||
const char* supabase_key = "sb_publishable_OB8e9y1OO0z3Y5xQ828YvA_T3jD16zT";
|
||||
|
||||
// ================= WIFI =================
|
||||
const char* ssid = "Shanum_4G";
|
||||
const char* password = "12345678";
|
||||
|
||||
// ================= MQTT =================
|
||||
const char* mqtt_server = "broker.emqx.io";
|
||||
|
||||
WiFiClient espClient;
|
||||
PubSubClient client(espClient);
|
||||
|
||||
// ================= DHT =================
|
||||
#define DHTPIN 4
|
||||
#define DHTTYPE DHT22
|
||||
DHT dht(DHTPIN, DHTTYPE);
|
||||
|
||||
// ================= RELAY =================
|
||||
#define RELAY1 27
|
||||
#define RELAY2 26
|
||||
|
||||
// Relay aktif LOW
|
||||
#define RELAY_ON LOW
|
||||
#define RELAY_OFF HIGH
|
||||
|
||||
// ================= THRESHOLD =================
|
||||
float batasPanas = 70.0;
|
||||
float batasLembab = 90.0;
|
||||
|
||||
// ================= GLOBAL STATE =================
|
||||
unsigned long lastMsg = 0;
|
||||
String statusRelay = "INIT";
|
||||
bool isAutoMode = true;
|
||||
bool manualPump = false;
|
||||
bool manualLight = false;
|
||||
|
||||
// State Tracking for Upload
|
||||
bool prevPump = false;
|
||||
bool prevLight = false;
|
||||
unsigned long lastPeriodicUpload = 0;
|
||||
|
||||
// ================= SUPABASE UPLOAD =================
|
||||
void uploadToSupabase(float hum, String status, String lamp, String pump) {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
WiFiClientSecure clientSecure;
|
||||
clientSecure.setInsecure();
|
||||
HTTPClient http;
|
||||
|
||||
String url = String(supabase_url) + "/rest/v1/humidity";
|
||||
http.begin(clientSecure, url);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("apikey", supabase_key);
|
||||
http.addHeader("Authorization", "Bearer " + String(supabase_key));
|
||||
http.addHeader("Prefer", "return=minimal");
|
||||
|
||||
String json = "{\"kelembapan\":" + String(hum) +
|
||||
",\"status\":\"" + status + "\"" +
|
||||
",\"status_lampu\":\"" + lamp + "\"" +
|
||||
",\"status_pompa\":\"" + pump + "\"}";
|
||||
|
||||
Serial.println("[Supabase] Uploading data...");
|
||||
int httpCode = http.POST(json);
|
||||
if (httpCode > 0) {
|
||||
Serial.printf("[Supabase] POST success, code: %d\n", httpCode);
|
||||
} else {
|
||||
Serial.printf("[Supabase] POST failed, error: %s\n", http.errorToString(httpCode).c_str());
|
||||
}
|
||||
http.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ================= WIFI =================
|
||||
void setup_wifi() {
|
||||
delay(10);
|
||||
Serial.println("Connecting to WiFi...");
|
||||
|
||||
WiFi.begin(ssid, password);
|
||||
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
|
||||
Serial.println("\nWiFi connected!");
|
||||
}
|
||||
|
||||
// ================= MQTT CALLBACK =================
|
||||
void callback(char* topic, byte* payload, unsigned int length) {
|
||||
String message = "";
|
||||
for (int i = 0; i < length; i++) {
|
||||
message += (char)payload[i];
|
||||
}
|
||||
|
||||
Serial.print("Message arrived [");
|
||||
Serial.print(topic);
|
||||
Serial.print("] ");
|
||||
Serial.println(message);
|
||||
|
||||
if (String(topic) == "esp32/statusiqbal/mode") {
|
||||
isAutoMode = (message == "auto");
|
||||
Serial.println(isAutoMode ? "Mode: AUTO" : "Mode: MANUAL");
|
||||
}
|
||||
else if (String(topic) == "esp32/statusiqbal/pump") {
|
||||
manualPump = (message == "on");
|
||||
Serial.println(manualPump ? "Manual Pump: ON" : "Manual Pump: OFF");
|
||||
}
|
||||
else if (String(topic) == "esp32/statusiqbal/light") {
|
||||
manualLight = (message == "on");
|
||||
Serial.println(manualLight ? "Manual Light: ON" : "Manual Light: OFF");
|
||||
}
|
||||
}
|
||||
|
||||
// ================= MQTT RECONNECT =================
|
||||
void reconnect() {
|
||||
while (!client.connected()) {
|
||||
Serial.print("Connecting MQTT...");
|
||||
|
||||
if (client.connect("ESP32Client", "esp32/statusiqbal", 0, true, "offline")) {
|
||||
Serial.println("connected");
|
||||
client.publish("esp32/statusiqbal", "online", true);
|
||||
|
||||
// Subscribe to control topics
|
||||
client.subscribe("esp32/statusiqbal/mode");
|
||||
client.subscribe("esp32/statusiqbal/pump");
|
||||
client.subscribe("esp32/statusiqbal/light");
|
||||
} else {
|
||||
Serial.print("failed, rc=");
|
||||
Serial.print(client.state());
|
||||
Serial.println(" coba lagi...");
|
||||
delay(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
pinMode(RELAY1, OUTPUT);
|
||||
pinMode(RELAY2, OUTPUT);
|
||||
|
||||
digitalWrite(RELAY1, RELAY_OFF);
|
||||
digitalWrite(RELAY2, RELAY_OFF);
|
||||
|
||||
dht.begin();
|
||||
|
||||
setup_wifi();
|
||||
client.setServer(mqtt_server, 1883);
|
||||
client.setCallback(callback);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (!client.connected()) {
|
||||
reconnect();
|
||||
}
|
||||
client.loop();
|
||||
|
||||
unsigned long now = millis();
|
||||
|
||||
if (now - lastMsg > 2000) {
|
||||
lastMsg = now;
|
||||
|
||||
float suhu = dht.readTemperature();
|
||||
float kelembapan = dht.readHumidity();
|
||||
|
||||
if (isnan(suhu) || isnan(kelembapan)) {
|
||||
Serial.println("Gagal baca DHT!");
|
||||
return;
|
||||
}
|
||||
|
||||
// ================= CONTROL LOGIC =================
|
||||
if (isAutoMode) {
|
||||
// Logic: <70% Pump ON, Lamp OFF | >90% Lamp ON, Pump OFF | 70-90% NORMAL (OFF/OFF)
|
||||
if (kelembapan < 70.0) {
|
||||
digitalWrite(RELAY1, RELAY_ON); // Pompa Hidup
|
||||
digitalWrite(RELAY2, RELAY_OFF); // Lampu Mati
|
||||
statusRelay = "KERING / PENYIRAMAN";
|
||||
}
|
||||
else if (kelembapan > 90.0) {
|
||||
digitalWrite(RELAY1, RELAY_OFF); // Pompa Mati
|
||||
digitalWrite(RELAY2, RELAY_ON); // Lampu Nyala
|
||||
statusRelay = "TERLALU LEMBAB";
|
||||
}
|
||||
else {
|
||||
digitalWrite(RELAY1, RELAY_OFF); // Pompa Mati
|
||||
digitalWrite(RELAY2, RELAY_OFF); // Lampu Mati
|
||||
statusRelay = "NORMAL";
|
||||
}
|
||||
} else {
|
||||
// Manual Mode (App Based)
|
||||
digitalWrite(RELAY1, manualPump ? RELAY_ON : RELAY_OFF);
|
||||
digitalWrite(RELAY2, manualLight ? RELAY_ON : RELAY_OFF);
|
||||
statusRelay = "MANUAL";
|
||||
}
|
||||
|
||||
// ================= STATUS REPORTING =================
|
||||
bool currentPump = (digitalRead(RELAY1) == RELAY_ON);
|
||||
bool currentLight = (digitalRead(RELAY2) == RELAY_ON);
|
||||
|
||||
// Upload logic: Action transition OR Periodic (30 mins) if Normal
|
||||
unsigned long nowMillis = millis();
|
||||
bool actionDetected = (currentPump != prevPump || currentLight != prevLight);
|
||||
// Interval 30 menit = 1800000 ms
|
||||
bool isPeriodicTime = (nowMillis - lastPeriodicUpload >= 1800000);
|
||||
|
||||
if (actionDetected || (statusRelay == "NORMAL" && isPeriodicTime)) {
|
||||
String statusLampuUpload = currentLight ? (isAutoMode ? "MENYALA - OTOMATIS" : "MENYALA - MANUAL") : "MATI";
|
||||
String statusPompaUpload = currentPump ? (isAutoMode ? "MENYALA - OTOMATIS" : "MENYALA - MANUAL") : "MATI";
|
||||
|
||||
uploadToSupabase(kelembapan, statusRelay, statusLampuUpload, statusPompaUpload);
|
||||
lastPeriodicUpload = nowMillis;
|
||||
}
|
||||
|
||||
// MQTT Reporting
|
||||
client.publish("esp32/dht/suhu", String(suhu).c_str());
|
||||
client.publish("esp32/dht/kelembapan", String(kelembapan).c_str());
|
||||
client.publish("esp32/relay/status", statusRelay.c_str());
|
||||
client.publish("esp32/statusiqbal/pump", currentPump ? "on" : "off", true);
|
||||
client.publish("esp32/statusiqbal/light", currentLight ? "on" : "off", true);
|
||||
client.publish("esp32/statusiqbal/mode", isAutoMode ? "auto" : "manual", true);
|
||||
|
||||
// Save states
|
||||
prevPump = currentPump;
|
||||
prevLight = currentLight;
|
||||
|
||||
// Serial Debug
|
||||
Serial.print("Mode: "); Serial.print(isAutoMode ? "AUTO" : "MANUAL");
|
||||
Serial.print(" | Hum: "); Serial.print(kelembapan);
|
||||
Serial.print("% | Pump: "); Serial.print(currentPump ? "ON" : "OFF");
|
||||
Serial.print(" | Light: "); Serial.println(currentLight ? "ON" : "OFF");
|
||||
Serial.print(" | Status: "); Serial.println(statusRelay);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ class MqttService {
|
|||
final clientId = 'mj_debug_${Random().nextInt(999999)}';
|
||||
|
||||
// FINAL ROBUST CONFIG: Back to EMQX Port 1883
|
||||
client = MqttServerClient('broker.emqx.io', clientId);
|
||||
client = MqttServerClient('broker.hivemq.com', clientId);
|
||||
client!.port = 1883;
|
||||
client!.setProtocolV311();
|
||||
client!.keepAlivePeriod = 60;
|
||||
|
|
@ -101,10 +101,10 @@ class MqttService {
|
|||
const topics = [
|
||||
"esp32/dht/kelembapan",
|
||||
"esp32/relay/status",
|
||||
"esp32/status",
|
||||
"esp32/status/mode",
|
||||
"esp32/status/pump",
|
||||
"esp32/status/light",
|
||||
"esp32/statusiqbal",
|
||||
"esp32/statusiqbal/mode",
|
||||
"esp32/statusiqbal/pump",
|
||||
"esp32/statusiqbal/light",
|
||||
];
|
||||
|
||||
for (final t in topics) {
|
||||
|
|
@ -127,20 +127,20 @@ class MqttService {
|
|||
relayStatus.value = payload.toUpperCase();
|
||||
break;
|
||||
|
||||
case "esp32/status":
|
||||
case "esp32/statusiqbal":
|
||||
isHardwareOnline.value = (pt == "online");
|
||||
debugPrint('MQTT: Hardware -> ${isHardwareOnline.value}');
|
||||
break;
|
||||
|
||||
case "esp32/status/mode":
|
||||
case "esp32/statusiqbal/mode":
|
||||
isAutoMode.value = (pt == "auto");
|
||||
break;
|
||||
|
||||
case "esp32/status/pump":
|
||||
case "esp32/statusiqbal/pump":
|
||||
isPumpOn.value = (pt == "on");
|
||||
break;
|
||||
|
||||
case "esp32/status/light":
|
||||
case "esp32/statusiqbal/light":
|
||||
isLightOn.value = (pt == "on");
|
||||
break;
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ class MqttService {
|
|||
}
|
||||
|
||||
final builder = MqttClientPayloadBuilder()..addString(value);
|
||||
final topic = "esp32/status/$subTopic";
|
||||
final topic = "esp32/statusiqbal/$subTopic";
|
||||
|
||||
client!.publishMessage(
|
||||
topic,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import 'package:flutter/material.dart';
|
|||
import 'package:monitoring_jamur/core/theme/app_theme.dart';
|
||||
import 'package:monitoring_jamur/core/session/user_session.dart';
|
||||
import 'package:monitoring_jamur/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:monitoring_jamur/features/account/presentation/pages/manage_account_page.dart';
|
||||
|
||||
class AccountPage extends StatelessWidget {
|
||||
class AccountPage extends StatefulWidget {
|
||||
const AccountPage({super.key});
|
||||
|
||||
@override
|
||||
State<AccountPage> createState() => _AccountPageState();
|
||||
}
|
||||
|
||||
class _AccountPageState extends State<AccountPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -68,10 +74,12 @@ class AccountPage extends StatelessWidget {
|
|||
_buildMenuTile(
|
||||
icon: Icons.settings_rounded,
|
||||
title: 'Kelola Akun',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Fitur Kelola Akun segera hadir')),
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ManageAccountPage()),
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -81,7 +89,7 @@ class AccountPage extends StatelessWidget {
|
|||
isDestructive: true,
|
||||
onTap: () async {
|
||||
await UserSession.logout();
|
||||
if (context.mounted) {
|
||||
if (mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const LoginPage()),
|
||||
|
|
@ -180,3 +188,4 @@ class AccountPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:monitoring_jamur/core/theme/app_theme.dart';
|
||||
import 'package:monitoring_jamur/core/session/user_session.dart';
|
||||
import 'package:monitoring_jamur/features/auth/data/user_repository.dart';
|
||||
import 'package:monitoring_jamur/features/auth/presentation/pages/login_page.dart';
|
||||
|
||||
class ManageAccountPage extends StatefulWidget {
|
||||
const ManageAccountPage({super.key});
|
||||
|
||||
@override
|
||||
State<ManageAccountPage> createState() => _ManageAccountPageState();
|
||||
}
|
||||
|
||||
class _ManageAccountPageState extends State<ManageAccountPage> {
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final UserRepository _userRepository = UserRepository();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_usernameController.text = UserSession.username ?? '';
|
||||
}
|
||||
|
||||
Future<void> _updateUsername() async {
|
||||
final newUsername = _usernameController.text.trim();
|
||||
if (newUsername.isEmpty) return;
|
||||
if (newUsername == UserSession.username) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Username baru harus berbeda dari yang lama')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
final success = await _userRepository.updateUsername(UserSession.username!, newUsername);
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (success) {
|
||||
await UserSession.saveSession(newUsername);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Username berhasil diperbarui')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal memperbarui username')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updatePassword() async {
|
||||
final newPassword = _passwordController.text.trim();
|
||||
if (newPassword.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Password tidak boleh kosong')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
final success = await _userRepository.updatePassword(UserSession.username!, newPassword);
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (success) {
|
||||
_passwordController.clear();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Password berhasil diperbarui')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal memperbarui password')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAccount() async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Hapus Akun'),
|
||||
content: const Text('Apakah Anda yakin ingin menghapus akun ini? Tindakan ini tidak dapat dibatalkan.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Hapus'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
final success = await _userRepository.deleteUser(UserSession.username!);
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (success) {
|
||||
await UserSession.logout();
|
||||
if (mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const LoginPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal menghapus akun')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.backgroundBeige,
|
||||
appBar: AppBar(
|
||||
title: const Text('Kelola Akun'),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: AppTheme.textDark,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Username Update Section
|
||||
_buildSection(
|
||||
title: 'Ganti Username',
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTextField(
|
||||
controller: _usernameController,
|
||||
label: 'Username baru',
|
||||
icon: Icons.person_outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _updateUsername,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text('Simpan Username'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Password Update Section
|
||||
_buildSection(
|
||||
title: 'Ganti Password',
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTextField(
|
||||
controller: _passwordController,
|
||||
label: 'Password baru',
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _updatePassword,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text('Simpan Password'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// Delete Account Section
|
||||
_buildSection(
|
||||
title: 'Zona Berbahaya',
|
||||
isDestructive: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Menghapus akun akan menghapus semua data Anda secara permanen.',
|
||||
style: TextStyle(color: AppTheme.textLight, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _isLoading ? null : _deleteAccount,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
side: const BorderSide(color: Colors.red),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text('Hapus Akun Saya'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black26,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryGreen),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection({required String title, required Widget child, bool isDestructive = false}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceWhite,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: isDestructive ? Border.all(color: Colors.red.withOpacity(0.3)) : null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.03),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDestructive ? Colors.red : AppTheme.textDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
bool isPassword = false,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
obscureText: isPassword ? _obscurePassword : false,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon, color: AppTheme.primaryGreen),
|
||||
suffixIcon: isPassword
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppTheme.primaryGreen.withOpacity(0.7),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: AppTheme.backgroundBeige.withOpacity(0.5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -35,4 +35,48 @@ class UserRepository {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update username in the custom `users` table.
|
||||
Future<bool> updateUsername(String oldUsername, String newUsername) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('users')
|
||||
.update({'username': newUsername})
|
||||
.eq('username', oldUsername)
|
||||
.select();
|
||||
|
||||
return response.isNotEmpty;
|
||||
} catch (e) {
|
||||
print('Update username error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update password in the custom `users` table.
|
||||
Future<bool> updatePassword(String username, String newPassword) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('users')
|
||||
.update({'password': newPassword})
|
||||
.eq('username', username)
|
||||
.select();
|
||||
|
||||
return response.isNotEmpty;
|
||||
} catch (e) {
|
||||
print('Update password error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a user from the custom `users` table.
|
||||
Future<bool> deleteUser(String username) async {
|
||||
try {
|
||||
await _supabase.from('users').delete().eq('username', username);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Delete user error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||
final _passwordController = TextEditingController();
|
||||
final _userRepo = UserRepository();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
void _login() async {
|
||||
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||
|
|
@ -87,10 +88,21 @@ class _LoginPageState extends State<LoginPage> {
|
|||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Password',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppTheme.primaryGreen.withOpacity(0.7),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||
final _passwordController = TextEditingController();
|
||||
final _userRepo = UserRepository();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
void _register() async {
|
||||
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||
|
|
@ -87,10 +88,21 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Create Password',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: AppTheme.primaryGreen.withOpacity(0.7),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class HistoryRepository {
|
|||
'status': status,
|
||||
'status_lampu': lightStatus,
|
||||
'status_pompa': pumpStatus,
|
||||
'tanggal_upload': DateFormat('HH:mm:ss').format(DateTime.now()),
|
||||
'tanggal_upload': DateTime.now().toIso8601String(),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:monitoring_jamur/core/theme/app_theme.dart';
|
||||
import 'package:monitoring_jamur/features/home/presentation/pages/statistics_page.dart';
|
||||
import 'package:monitoring_jamur/core/services/mqtt_service.dart';
|
||||
import 'package:monitoring_jamur/features/history/data/history_repository.dart';
|
||||
import 'dart:math' as math;
|
||||
|
|
@ -137,9 +136,6 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
const SizedBox(height: 24),
|
||||
// Save to History Button
|
||||
_buildSaveToHistoryButton(),
|
||||
const SizedBox(height: 16),
|
||||
// Statistics Button
|
||||
_buildStatisticsButton(context),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
|
|
@ -233,7 +229,7 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
_mqttService.publishControl(
|
||||
'pump', val ? 'on' : 'off');
|
||||
if (val) {
|
||||
_saveManualAction('Pompa Air', true);
|
||||
_showActionFeedback('Pompa Air');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -252,7 +248,7 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
_mqttService.publishControl(
|
||||
'light', val ? 'on' : 'off');
|
||||
if (val) {
|
||||
_saveManualAction('Lampu Pemanas', true);
|
||||
_showActionFeedback('Lampu Pemanas');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -326,45 +322,6 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildStatisticsButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const StatisticsPage()),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceWhite,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryGreen.withAlpha(20),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bar_chart_rounded, color: AppTheme.primaryGreen, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Lihat Statistik',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryGreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getHumidityStatus(double humidity) {
|
||||
if (humidity < 70) return 'Sangat Kering';
|
||||
|
|
@ -412,31 +369,11 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _saveManualAction(String device, bool isOn) async {
|
||||
final status = _getHumidityStatus(_humidity);
|
||||
|
||||
// Prepare current statuses
|
||||
String pumpStatusText = _mqttService.isPumpOn.value ? 'MENYALA - MANUAL' : 'MATI';
|
||||
String lightStatusText = _mqttService.isLightOn.value ? 'MENYALA - MANUAL' : 'MATI';
|
||||
|
||||
// Override with the new action state
|
||||
if (device.contains('Pompa')) {
|
||||
pumpStatusText = 'MENYALA - MANUAL';
|
||||
} else {
|
||||
lightStatusText = 'MENYALA - MANUAL';
|
||||
}
|
||||
|
||||
await _historyRepository.saveHumidityData(
|
||||
humidity: _humidity,
|
||||
status: status,
|
||||
lightStatus: lightStatusText,
|
||||
pumpStatus: pumpStatusText,
|
||||
);
|
||||
|
||||
void _showActionFeedback(String device) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$device dinyalakan (Data tersimpan!)'),
|
||||
content: Text('$device dinyalakan (Perintah terkirim ke alat!)'),
|
||||
backgroundColor: AppTheme.primaryGreen,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
|
|
@ -754,19 +691,14 @@ class _InstructionPanel extends StatelessWidget {
|
|||
subMessage = 'Tidak optimal untuk pertumbuhan aktif. Pompa menyala (penyiraman aktif)';
|
||||
icon = Icons.warning_amber_rounded;
|
||||
color = Colors.orange.shade800;
|
||||
} else if (humidity >= 70 && humidity < 80) {
|
||||
message = 'Kering - Lembab';
|
||||
subMessage = 'Mulai mendekati kondisi ideal. Pompa menyala (penyiraman terbatas)';
|
||||
icon = Icons.info_outline_rounded;
|
||||
color = Colors.blueGrey;
|
||||
} else if (humidity >= 80 && humidity <= 90) {
|
||||
}else if (humidity >= 70 && humidity <= 90) {
|
||||
message = 'Lembab (Ideal)';
|
||||
subMessage = 'Kondisi optimal pertumbuhan jamur tiram. Pompa mati';
|
||||
subMessage = 'Kondisi optimal pertumbuhan jamur tiram.';
|
||||
icon = Icons.check_circle_outline_rounded;
|
||||
color = AppTheme.primaryGreen;
|
||||
} else {
|
||||
message = 'Sangat Lembab';
|
||||
subMessage = 'Menyebabkan kontaminasi. Pompa mati';
|
||||
subMessage = 'Menyebabkan kontaminasi.Lampu Menyala';
|
||||
icon = Icons.error_outline_rounded;
|
||||
color = Colors.red.shade700;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:monitoring_jamur/core/theme/app_theme.dart';
|
||||
|
||||
class StatisticsPage extends StatelessWidget {
|
||||
const StatisticsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.backgroundBeige,
|
||||
appBar: AppBar(
|
||||
title: const Text('Statistik Monitoring'),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: AppTheme.textDark,
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSummaryRow(),
|
||||
const SizedBox(height: 32),
|
||||
_buildChartSection('Tren Kelembapan', [85, 82, 88, 84, 86, 85, 87], AppTheme.primaryGreen),
|
||||
const SizedBox(height: 32),
|
||||
_buildChartSection('Tren Suhu', [24, 25, 24, 26, 25, 24, 25], Colors.orange),
|
||||
const SizedBox(height: 40),
|
||||
_buildAnalysisCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryRow() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildStatCard('Rata-rata', '85%', Icons.water_drop_rounded, AppTheme.primaryGreen),
|
||||
const SizedBox(width: 16),
|
||||
_buildStatCard('Tertinggi', '89%', Icons.trending_up_rounded, Colors.blue),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceWhite,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(5),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 28),
|
||||
const SizedBox(height: 12),
|
||||
Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppTheme.textLight)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartSection(String title, List<double> values, Color color) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppTheme.textDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
height: 180,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceWhite,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: values.map((v) {
|
||||
double height = (v / 100) * 120;
|
||||
if (title.contains('Suhu')) height = (v / 40) * 120; // Scale for temperature
|
||||
return Container(
|
||||
width: 24,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(50),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 2),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalysisCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryGreen.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppTheme.primaryGreen.withAlpha(50), width: 2),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome_rounded, color: AppTheme.primaryGreen),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Analisis Sistem',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppTheme.primaryGreen),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Berdasarkan data 24 jam terakhir, kelembapan stabil di rentang ideal (80-90%). Pertumbuhan jamur terpantau optimal.',
|
||||
style: TextStyle(fontSize: 14, color: AppTheme.textDark, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue