diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 247de97..8e39290 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ +#include +#include #include #include +#include +// ================= 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); } } \ No newline at end of file diff --git a/iotb.cpp b/iotb.cpp new file mode 100644 index 0000000..b7cc7bf --- /dev/null +++ b/iotb.cpp @@ -0,0 +1,240 @@ +#include +#include +#include +#include +#include + +// ================= 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); + } +} \ No newline at end of file diff --git a/lib/core/services/mqtt_service.dart b/lib/core/services/mqtt_service.dart index d39a30d..c4047d7 100644 --- a/lib/core/services/mqtt_service.dart +++ b/lib/core/services/mqtt_service.dart @@ -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, diff --git a/lib/features/account/presentation/pages/account_page.dart b/lib/features/account/presentation/pages/account_page.dart index e0dff02..ac8fac5 100644 --- a/lib/features/account/presentation/pages/account_page.dart +++ b/lib/features/account/presentation/pages/account_page.dart @@ -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 createState() => _AccountPageState(); +} + +class _AccountPageState extends State { @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 { ); } } + diff --git a/lib/features/account/presentation/pages/manage_account_page.dart b/lib/features/account/presentation/pages/manage_account_page.dart new file mode 100644 index 0000000..9bb03d4 --- /dev/null +++ b/lib/features/account/presentation/pages/manage_account_page.dart @@ -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 createState() => _ManageAccountPageState(); +} + +class _ManageAccountPageState extends State { + 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 _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 _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 _deleteAccount() async { + final confirm = await showDialog( + 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(); + } +} diff --git a/lib/features/auth/data/user_repository.dart b/lib/features/auth/data/user_repository.dart index 42f5877..7df9df6 100644 --- a/lib/features/auth/data/user_repository.dart +++ b/lib/features/auth/data/user_repository.dart @@ -35,4 +35,48 @@ class UserRepository { return null; } } + + /// Update username in the custom `users` table. + Future 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 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 deleteUser(String username) async { + try { + await _supabase.from('users').delete().eq('username', username); + return true; + } catch (e) { + print('Delete user error: $e'); + return false; + } + } } + diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index d01c8c2..cb17e88 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -17,6 +17,7 @@ class _LoginPageState extends State { 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 { 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), diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart index 58de9bd..4d328aa 100644 --- a/lib/features/auth/presentation/pages/register_page.dart +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -14,6 +14,7 @@ class _RegisterPageState extends State { 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 { 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), diff --git a/lib/features/history/data/history_repository.dart b/lib/features/history/data/history_repository.dart index e75e5b6..3f22563 100644 --- a/lib/features/history/data/history_repository.dart +++ b/lib/features/history/data/history_repository.dart @@ -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) { diff --git a/lib/features/home/presentation/pages/dashboard_page.dart b/lib/features/home/presentation/pages/dashboard_page.dart index 2f8d64b..0527f28 100644 --- a/lib/features/home/presentation/pages/dashboard_page.dart +++ b/lib/features/home/presentation/pages/dashboard_page.dart @@ -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 { 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 { _mqttService.publishControl( 'pump', val ? 'on' : 'off'); if (val) { - _saveManualAction('Pompa Air', true); + _showActionFeedback('Pompa Air'); } }, ); @@ -252,7 +248,7 @@ class _DashboardPageState extends State { _mqttService.publishControl( 'light', val ? 'on' : 'off'); if (val) { - _saveManualAction('Lampu Pemanas', true); + _showActionFeedback('Lampu Pemanas'); } }, ); @@ -326,45 +322,6 @@ class _DashboardPageState extends State { ); } - 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 { } } - Future _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; } diff --git a/lib/features/home/presentation/pages/statistics_page.dart b/lib/features/home/presentation/pages/statistics_page.dart deleted file mode 100644 index 067c808..0000000 --- a/lib/features/home/presentation/pages/statistics_page.dart +++ /dev/null @@ -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 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), - ), - ], - ), - ); - } -}