This commit is contained in:
panggilsajarey 2026-07-05 19:02:20 +07:00
parent 930204a82f
commit c1d4aba8c7
14 changed files with 807 additions and 272 deletions

View File

@ -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"

View File

@ -1 +1 @@
6d2081b804991eed21be9bc57d58ff3fb73aae86
11e7c02cde7287ec04bc2749290002346ea5a4c5

163
iot.cpp
View File

@ -1,10 +1,21 @@
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#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";
// ================= WIFI =================
const char* ssid = "Shanum_4G";
const char* password = "12345678";
const char* ssid = "Ozie 1";
const char* password = "followkami";
// ================= TIME (NTP) =================
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 7 * 3600; // WIB (GMT+7)
const int daylightOffset_sec = 0;
// ================= MQTT =================
const char* mqtt_server = "broker.hivemq.com";
@ -36,6 +47,59 @@ 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) {
// Ambil waktu saat ini
struct tm timeinfo;
String timeStr = "";
if (getLocalTime(&timeinfo)) {
char timeStringBuff[50];
strftime(timeStringBuff, sizeof(timeStringBuff), "%H:%M:%S", &timeinfo);
timeStr = String(timeStringBuff);
}
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) +
(timeStr != "" ? ",\"tanggal_upload\":\"" + timeStr + "\"" : "") +
",\"status\":\"" + status + "\"" +
",\"status_lampu\":\"" + lamp + "\"" +
",\"status_pompa\":\"" + pump + "\"}";
Serial.println("[Supabase] Uploading data...");
int httpCode = http.POST(json);
if (httpCode >= 200 && httpCode < 300) {
Serial.printf("[Supabase] Berhasil Terkirim! Status: %d\n", httpCode);
} else if (httpCode > 0) {
String response = http.getString();
Serial.printf("[Supabase] Gagal Mengirim. HTTP Code: %d\n", httpCode);
Serial.printf("[Supabase] Alasan: %s\n", response.c_str());
} else {
Serial.printf("[Supabase] Gagal Koneksi. Error: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
} else {
Serial.println("[Supabase] Gagal: WiFi tidak terhubung!");
}
}
// ================= WIFI =================
void setup_wifi() {
delay(10);
@ -63,15 +127,15 @@ void callback(char* topic, byte* payload, unsigned int length) {
Serial.print("] ");
Serial.println(message);
if (String(topic) == "esp32/control/mode") {
if (String(topic) == "esp32/statusiqbal/mode") {
isAutoMode = (message == "auto");
Serial.println(isAutoMode ? "Mode: AUTO" : "Mode: MANUAL");
}
else if (String(topic) == "esp32/control/pump") {
else if (String(topic) == "esp32/statusiqbal/pump") {
manualPump = (message == "on");
Serial.println(manualPump ? "Manual Pump: ON" : "Manual Pump: OFF");
}
else if (String(topic) == "esp32/control/light") {
else if (String(topic) == "esp32/statusiqbal/light") {
manualLight = (message == "on");
Serial.println(manualLight ? "Manual Light: ON" : "Manual Light: OFF");
}
@ -82,19 +146,22 @@ void reconnect() {
while (!client.connected()) {
Serial.print("Connecting MQTT...");
if (client.connect("ESP32Client", "esp32/status", 0, true, "offline")) {
Serial.println("connected");
client.publish("esp32/status", "online", true);
// Create a unique client ID using MAC address
String clientId = "ESP32MJ-" + WiFi.macAddress();
if (client.connect(clientId.c_str(), "esp32/statusiqbal", 0, true, "offline")) {
Serial.println("CONNECTED to MQTT ✅");
client.publish("esp32/statusiqbal", "online", true);
// Subscribe to control topics
client.subscribe("esp32/control/mode");
client.subscribe("esp32/control/pump");
client.subscribe("esp32/control/light");
client.subscribe("esp32/statusiqbal/mode");
client.subscribe("esp32/statusiqbal/pump");
client.subscribe("esp32/statusiqbal/light");
} else {
Serial.print("failed, rc=");
Serial.print("FAILED, rc=");
Serial.print(client.state());
Serial.println(" coba lagi...");
delay(2000);
Serial.println(" trying again in 5s...");
delay(5000);
}
}
}
@ -111,6 +178,10 @@ void setup() {
dht.begin();
setup_wifi();
// Konfigurasi waktu dari NTP
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
client.setServer(mqtt_server, 1883);
client.setCallback(callback);
}
@ -126,6 +197,7 @@ void loop() {
if (now - lastMsg > 2000) {
lastMsg = now;
float suhu = dht.readTemperature();
float kelembapan = dht.readHumidity();
@ -134,19 +206,25 @@ void loop() {
return;
}
// ================= CONTROL LOGIC =================
if (isAutoMode) {
// Automatic Mode (Sensor Based)
bool kondisiLembab = (kelembapan > batasLembab);
bool kondisiPanas = (suhu > batasPanas);
digitalWrite(RELAY1, kondisiLembab ? RELAY_ON : RELAY_OFF);
digitalWrite(RELAY2, kondisiPanas ? RELAY_ON : RELAY_OFF);
if (kondisiPanas && kondisiLembab) statusRelay = "PANAS+LEMBAB";
else if (kondisiPanas) statusRelay = "PANAS";
else if (kondisiLembab) statusRelay = "LEMBAB";
else statusRelay = "NORMAL";
// 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);
@ -158,17 +236,42 @@ void loop() {
bool currentPump = (digitalRead(RELAY1) == RELAY_ON);
bool currentLight = (digitalRead(RELAY2) == RELAY_ON);
client.publish("esp32/dht/suhu", String(suhu).c_str());
// 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) {
Serial.println("[Reporting] Deteksi Perubahan Status!");
}
if (actionDetected || (statusRelay == "NORMAL" && isPeriodicTime)) {
String statusLampuUpload = currentLight ? (isAutoMode ? "MENYALA - OTOMATIS" : "MENYALA - MANUAL") : "MATI";
String statusPompaUpload = currentPump ? (isAutoMode ? "MENYALA - OTOMATIS" : "MENYALA - MANUAL") : "MATI";
Serial.println("[Reporting] Memulai Upload ke Supabase...");
uploadToSupabase(kelembapan, statusRelay, statusLampuUpload, statusPompaUpload);
lastPeriodicUpload = nowMillis;
}
// MQTT Reporting
client.publish("esp32/dht/kelembapan", String(kelembapan).c_str());
client.publish("esp32/relay/status", statusRelay.c_str());
client.publish("esp32/status/pump", currentPump ? "on" : "off", true);
client.publish("esp32/status/light", currentLight ? "on" : "off", true);
client.publish("esp32/status/mode", isAutoMode ? "auto" : "manual", true);
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 for next comparison
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(" | Light: "); Serial.print(currentLight ? "ON" : "OFF");
Serial.print(" | Status: "); Serial.println(statusRelay);
}
}

240
iotb.cpp Normal file
View File

@ -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);
}
}

View File

@ -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,

View File

@ -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 {
);
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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),

View File

@ -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),

View File

@ -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) {

View File

@ -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;
}

View File

@ -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),
),
],
),
);
}
}