wip: simpan perubahan sebelum revisi heatmap dan sensor

This commit is contained in:
bimabuana 2026-07-06 15:43:06 +07:00
parent a06195f75a
commit e73750213e
42 changed files with 2019 additions and 402 deletions

View File

@ -1,10 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="JTA"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
android:icon="@mipmap/launcher_icon"
tools:replace="android:label">
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 9.1 MiB

103
laporan_alur_aplikasi.md Normal file
View File

@ -0,0 +1,103 @@
# Dokumentasi Diagram Alur (Flowchart) Aplikasi JTA
Dokumen ini berisi rancangan **Diagram Alur (Flowchart)** jalannya aplikasi mobile JTA dari pertama kali dibuka hingga penggunaan fitur-fiturnya. Anda dapat menyalin kode Mermaid di bawah untuk diimpor ke **Diagrams.net (Draw.io)**.
---
## 1. Kode Flowchart (Mermaid)
```mermaid
flowchart TD
Start([Mulai]) --> Splash[Splash Screen: Cek Sesi Login]
Splash --> LoggedIn{Apakah Sudah Login?}
LoggedIn -- Tidak --> Onboarding[Landing Page / Onboarding]
Onboarding --> ChooseAuth{Masuk atau Daftar?}
ChooseAuth -- Masuk --> Login[Halaman Login]
ChooseAuth -- Daftar --> Register[Halaman Register]
Login --> SubmitLogin{Login Berhasil?}
SubmitLogin -- Tidak --> Login
SubmitLogin -- Ya --> MainTab
Register --> SubmitRegister{Register Berhasil?}
SubmitRegister -- Tidak --> Register
SubmitRegister -- Ya --> Login
LoggedIn -- Ya --> MainTab[Halaman Utama / Dashboard]
MainTab --> CheckDevice{Apakah Alat Terhubung?}
CheckDevice -- Tidak --> EmptyState[Tampilan Alat Belum Terhubung]
EmptyState --> ClaimDevice[Input Claim Code Alat]
ClaimDevice --> SubmitClaim{Claim Berhasil?}
SubmitClaim -- Tidak --> ClaimDevice
SubmitClaim -- Ya --> ActiveState
CheckDevice -- Ya --> ActiveState[Tampilan Dashboard Aktif]
ActiveState --> Nav[Navigasi Menu Utama]
Nav --> TabMonitoring[Tab 1: Monitoring]
TabMonitoring --> ViewRealtime[Pantau Suhu & Kelembapan Real-Time]
TabMonitoring --> ViewChart[Lihat Grafik Riwayat Sensor]
Nav --> TabControl[Tab 2: Control]
TabControl --> SelectMode{Pilih Mode Kerja}
SelectMode -- Otomatis --> SetThreshold[Atur Batas Threshold Suhu & Kelembapan]
SetThreshold --> AutoWater[ESP32 Siram Otomatis jika Melewati Batas]
SelectMode -- Manual --> PumpManual[Nyalakan Pompa dengan Durasi Waktu]
TabControl --> SetSchedule[Atur Jadwal Penyiraman]
SetSchedule --> ScheduleWater[ESP32 Siram Otomatis Sesuai Jadwal]
Nav --> ActionLogout[Tombol Logout]
ActionLogout --> ConfirmLogout{Konfirmasi Logout?}
ConfirmLogout -- Ya --> ClearSession[Hapus Sesi & Kembali ke Landing]
ClearSession --> Onboarding
ConfirmLogout -- Batal --> ActiveState
```
---
## 2. Penjelasan Alur Aktivitas Aplikasi
Berikut adalah penjelasan narasi untuk setiap tahapan pada diagram alur di atas:
1. **Awal Sesi (Splash Screen & Auth Check)**:
- Ketika aplikasi pertama kali dijalankan, sistem akan menampilkan Splash Screen dan secara otomatis memeriksa status autentikasi lokal pengguna di Supabase.
- Jika sesi login aktif masih ditemukan, pengguna langsung diarahkan ke **Halaman Utama (Dashboard)**. Jika tidak, pengguna diarahkan ke **Halaman Landing / Onboarding**.
2. **Proses Registrasi & Login**:
- Di Halaman Landing, pengguna dapat memilih masuk (Login) atau membuat akun baru (Register).
- Setelah proses registrasi sukses, pengguna harus masuk menggunakan email dan password terdaftar.
3. **Verifikasi Kepemilikan Alat**:
- Di Halaman Utama, sistem mendeteksi apakah akun pengguna sudah terhubung dengan perangkat IoT ESP32 (`device_id != null`).
- **Jika Belum Terhubung**: Aplikasi menampilkan status kosong (Empty State) dan meminta pengguna memasukkan **Claim Code** yang tertera pada alat untuk menghubungkannya.
- **Jika Sudah Terhubung**: Aplikasi langsung menampilkan dashboard aktif yang berisi pembaruan data sensor real-time.
4. **Navigasi Fitur Utama**:
Setelah alat terhubung, pengguna dapat menggunakan 2 tab fitur utama aplikasi:
- **Fitur Monitoring (Tab 1)**:
- Memantau suhu (°C) dan kelembapan (%) kumbung secara real-time yang diperbarui secara otomatis setiap 5 detik (polling).
- Melihat riwayat grafik perkembangan sensor harian, mingguan, atau bulanan.
- **Fitur Kontrol & Penjadwalan (Tab 2)**:
- Memilih mode kerja alat: **Mode Otomatis** (pompa menyala mandiri berdasarkan batas threshold suhu dan kelembapan yang diinput) atau **Mode Manual** (menyalakan pompa secara langsung dengan opsi durasi waktu 15 detik hingga 5 menit).
- Mengatur jadwal penyiraman berkala (menentukan jam dan hari penyiraman otomatis).
5. **Proses Logout**:
- Pengguna dapat keluar dari akun secara aman dengan menekan tombol logout. Sistem akan menampilkan dialog konfirmasi terlebih dahulu sebelum menghapus sesi aktif Supabase dan mengembalikan pengguna ke Halaman Landing.
---
## 3. Cara Mengimpor ke Diagrams.net (Draw.io)
1. Salin seluruh kode di dalam blok ` ```mermaid ` pada Bagian 1 di atas.
2. Buka [app.diagrams.net](https://app.diagrams.net/).
3. Di menu atas editor Diagrams.net, pilih **Arrange** -> **Insert** -> **Advanced** -> **Mermaid...** (atau klik tombol **`+`** pada toolbar -> **Advanced** -> **Mermaid**).
4. Tempel (*paste*) kode Mermaid tersebut.
5. Klik **Insert** (Sisipkan). Diagrams.net akan langsung menggambar bagan alir lengkap di atas secara instan.

View File

@ -0,0 +1,18 @@
# Laporan Alur Teknis Autentikasi dan Manajemen Sesi Aplikasi JTA
Alur teknis autentikasi dan manajemen sesi setelah pembaruan sistem dijelaskan sebagai berikut:
1. **Registrasi Akun dan Pemisahan Klaim Perangkat (Device Claiming)**:
Proses pendaftaran akun kini terpisah dari klaim perangkat. Pengguna terlebih dahulu melakukan registrasi akun menggunakan nama, email, dan kata sandi yang disimpan langsung di layanan autentikasi **Supabase Auth**. Setelah akun terdaftar dan pengguna masuk ke aplikasi, pengguna dapat menghubungkan perangkat IoT (ESP32) secara terpisah di halaman utama dengan memasukkan **Claim Code** unik. Backend REST API kemudian memetakan `device_id` ke `user_id` di database. Hal ini memastikan hanya pemilik sah yang memiliki otorisasi kontrol dan mencegah akses ilegal dari pengguna lain.
2. **Autentikasi Mandiri dan Cache Sesi Lokal**:
Pengguna melakukan login menggunakan email dan kata sandi yang diverifikasi secara aman oleh Supabase. Setelah autentikasi berhasil, token akses (JWT) dikelola secara otomatis oleh SDK **Supabase Flutter**. Bersamaan dengan itu, sistem mengambil informasi perangkat yang terikat (`device_id`) dari database backend via REST API dan menyimpannya ke penyimpanan lokal perangkat menggunakan **SharedPreferences** sebagai cache (`cached_device_id`). Saat aplikasi dibuka kembali, sistem memeriksa sesi aktif melalui Supabase dan memulihkan `device_id` dari cache secara instan (offline-safe) tanpa memicu delay request API berulang.
3. **Integrasi Identitas, REST API, dan Notifikasi (OneSignal)**:
Setelah berhasil masuk, aplikasi tidak mengontrol perangkat secara langsung menggunakan protokol MQTT, melainkan melalui **REST API (Dio)** dengan menyertakan token autentikasi dan `device_id`. Sementara itu, protokol MQTT tetap digunakan oleh modul perangkat keras (ESP32) untuk mengirim data ke backend. Dari sisi notifikasi, `User ID` dari Supabase didaftarkan ke layanan **OneSignal** (`OneSignal.login(user.id)`). Integrasi ini menjamin notifikasi push (seperti peringatan suhu/kelembapan kritis dari kumbung) hanya dikirimkan ke perangkat mobile pengguna yang terikat secara sah dengan alat tersebut.
4. **Manajemen Navigasi Reaktif dan Terminasi Sesi (Logout)**:
Navigasi antarhalaman tidak lagi menggunakan route guarding manual pada setiap transisi rute, melainkan dikendalikan secara reaktif menggunakan manajemen state **Riverpod** (`AuthNotifier`). Sistem akan otomatis mengarahkan rute halaman (Splash Screen, Landing Page, atau Dashboard Utama) berdasarkan status autentikasi aktif. Akhir dari siklus sesi terjadi saat pengguna menekan tombol *Logout*, di mana sistem secara berurutan akan:
- Melakukan pemutusan sesi pada server Supabase (`signOut`).
- Menghapus cache `device_id` dari penyimpanan lokal (SharedPreferences).
- Melakukan logout/de-registrasi akun dari layanan **OneSignal** (`OneSignal.logout()`) guna memastikan tidak ada kebocoran notifikasi di masa mendatang.

125
laporan_usecase_diagram.md Normal file
View File

@ -0,0 +1,125 @@
# Dokumentasi Diagram Use Case Sistem JTA (Jamur Tiram Antirogo)
Dokumen ini berisi rancangan **Diagram Use Case** beserta penjelasannya untuk digunakan dalam laporan akhir proyek/tugas akhir Anda. Anda dapat menyalin teks dan diagram di bawah ini langsung ke laporan Anda.
---
## 1. Diagram Use Case (Mermaid)
Berikut adalah visualisasi diagram use case untuk aplikasi mobile JTA. Jika laporan Anda mendukung rendering Mermaid (seperti Markdown/GitHub), diagram akan ter-render otomatis. Anda juga dapat mengekspor diagram ini ke format PNG/SVG.
```mermaid
graph TD
%% Define Actors
Petani["Petani (Pengguna)"]
ESP32["Perangkat IoT (ESP32)"]
subgraph Batasan_Sistem_JTA ["Batasan Sistem JTA"]
UC1(("UC-01: Registrasi Akun"))
UC2(("UC-02: Login Akun"))
UC3(("UC-03: Hubungkan Perangkat via Claim Code"))
UC4(("UC-04: Pantau Suhu & Kelembapan Real-Time"))
UC5(("UC-05: Lihat Riwayat Grafik"))
UC6(("UC-06: Pilih Mode Operasi: Manual / Otomatis"))
UC7(("UC-07: Atur Threshold Limit"))
UC8(("UC-08: Kendalikan Pompa Air"))
UC9(("UC-09: Atur Jadwal Penyiraman"))
UC10(("UC-10: Terima Notifikasi"))
UC11(("UC-11: Logout Akun"))
end
%% Actor Relationships
Petani --> UC1
Petani --> UC2
Petani --> UC3
Petani --> UC4
Petani --> UC5
Petani --> UC6
Petani --> UC7
Petani --> UC8
Petani --> UC9
Petani --> UC11
ESP32 --> UC4
ESP32 --> UC8
ESP32 --> UC10
%% Use Case Relationships
UC3 -.->|include| UC2
UC4 -.->|include| UC3
UC5 -.->|include| UC3
UC6 -.->|include| UC3
UC9 -.->|include| UC3
UC7 -.->|extend| UC6
UC8 -.->|extend| UC6
UC10 -.->|extend| UC4
```
### 💡 Cara Cepat Mengimpor ke Diagrams.net (Draw.io)
Draw.io mendukung pembuatan diagram otomatis dari kode Mermaid. Ikuti langkah mudah berikut:
1. Salin seluruh kode blok di dalam tanda ```mermaid diatas.
2. Buka [app.diagrams.net](https://app.diagrams.net/).
3. Di dalam editor Diagrams.net, klik menu **Arrange** di bagian atas -> **Insert** -> **Advanced** -> **Mermaid...** (atau klik ikon **+** (Plus) pada toolbar atas -> **Advanced** -> **Mermaid**).
4. Tempel (*paste*) kode Mermaid yang sudah disalin ke dalam kotak teks yang muncul.
5. Klik tombol **Insert** (Sisipkan).
6. Diagrams.net akan otomatis menggambarkan aktor, elips usecase, garis panah, dan teks penjelas. Anda tinggal merapikan posisi atau mengganti gaya warna agar sesuai selera.
---
## 2. Deskripsi Aktor (Actors)
Berikut adalah penjelasan mengenai aktor-aktor yang terlibat di dalam sistem JTA:
| No | Nama Aktor | Jenis | Deskripsi |
|---|---|---|---|
| 1 | **Petani (Pengguna)** | Utama (*Primary*) | Pengguna akhir aplikasi yang melakukan monitoring suhu/kelembapan kumbung dan mengendalikan pompa penyiraman secara manual maupun otomatis melalui aplikasi mobile. |
| 2 | **Perangkat IoT (ESP32)** | Pendukung (*Supporting*) | Perangkat keras yang mengirimkan data sensor DHT22 ke sistem dan menerima perintah kontrol (on/off relay pompa) serta mengirimkan data status aktual pompa. |
---
## 3. Definisi Use Case
Berikut adalah tabel fungsionalitas sistem berdasarkan Diagram Use Case di atas:
| Kode UC | Nama Use Case | Aktor Terlibat | Deskripsi |
|---|---|---|---|
| **UC-01** | Registrasi Akun | Petani | Pengguna membuat akun baru dengan memasukkan nama, email, dan password agar terdaftar di sistem. |
| **UC-02** | Login Akun | Petani | Pengguna masuk ke sistem menggunakan email dan password terdaftar untuk memvalidasi akses sesi. |
| **UC-03** | Hubungkan Perangkat | Petani | Pengguna memasukkan *Claim Code* alat ESP32 untuk menghubungkan perangkat fisik dengan akun di aplikasi. |
| **UC-04** | Pantau Suhu & Kelembapan | Petani, ESP32 | Sistem menampilkan data suhu (°C) dan kelembapan (%) secara real-time yang dikirimkan oleh sensor dari alat ESP32. |
| **UC-05** | Lihat Riwayat Grafik | Petani | Pengguna melihat visualisasi data sensor historis (grafik harian, mingguan, bulanan) untuk analisis kondisi kumbung. |
| **UC-06** | Pilih Mode Operasi | Petani | Pengguna mengganti mode kerja alat antara Mode Manual (kontrol pengguna) atau Mode Otomatis (kontrol threshold). |
| **UC-07** | Atur Threshold Limit | Petani | Pengguna menentukan batas maksimum suhu dan batas minimum kelembapan sebagai acuan penyiraman otomatis. |
| **UC-08** | Kendalikan Pompa Air | Petani, ESP32 | Pengguna menyalakan/mematikan pompa air secara instan berdasarkan durasi waktu pilihan pada Mode Manual. |
| **UC-09** | Atur Jadwal Penyiraman | Petani | Pengguna menjadwalkan jam dan hari penyiraman otomatis untuk menjaga kelembapan kumbung secara berkala. |
| **UC-10** | Terima Notifikasi | Petani, ESP32 | Sistem mengirim notifikasi peringatan push melalui OneSignal ke smartphone ketika suhu/kelembapan kumbung melewati batas aman. |
| **UC-11** | Logout Akun | Petani | Pengguna keluar dari sesi akun aplikasi secara aman. |
---
## 4. Spesifikasi Use Case Utama (Skenario Utama & Alternatif)
### UC-03: Hubungkan Perangkat (Claim Perangkat)
* **Aktor Utama**: Petani
* **Kondisi Awal (Pre-condition)**: Pengguna sudah berhasil login dan belum menghubungkan perangkat apapun (terdeteksi kartu "Belum Ada Alat Terhubung").
* **Alur Skenario Utama**:
1. Pengguna mengetuk tombol tambah (+) pada kartu kosong halaman Monitoring.
2. Sistem menampilkan dialog "Hubungkan Alat" dan meminta input *Claim Code*.
3. Pengguna memasukkan *Claim Code* yang tertera pada alat.
4. Pengguna mengetuk tombol "Hubungkan".
5. Sistem memvalidasi kode di database Supabase dan menautkan *device_id* ke profil akun pengguna.
6. Sistem menampilkan pesan sukses dan halaman dimuat ulang menampilkan indikator monitoring aktif.
* **Kondisi Akhir (Post-condition)**: Perangkat IoT berhasil terhubung ke akun pengguna secara permanen.
### UC-06: Pilih Mode Operasi & UC-07: Atur Threshold Limit
* **Aktor Utama**: Petani
* **Kondisi Awal (Pre-condition)**: Pengguna sudah login dan status alat dalam keadaan terhubung.
* **Alur Skenario Utama (Mode Otomatis)**:
1. Pengguna membuka tab **Control**.
2. Pengguna mengaktifkan Switch "Mode Otomatis".
3. Sistem mengunci opsi tombol penyiraman manual dan menampilkan slider pengaturan batas threshold.
4. Pengguna menentukan batas maksimal suhu (misal: 30°C) dan batas minimal kelembapan (misal: 70%).
5. Pengguna mengetuk tombol "Simpan Batas".
6. Sistem mengirimkan nilai threshold ke database backend. Perangkat ESP32 membaca batas ini dan akan menyalakan pompa secara mandiri jika kondisi dilewati.
* **Kondisi Akhir (Post-condition)**: Nilai threshold terupdate di backend dan mode otomatis aktif pada perangkat.

View File

@ -1,14 +1,14 @@
class Env {
// class Env {
// --- BASE API URL ---
// Pilih salah satu URL di bawah sesuai kebutuhan testing Anda
static const String baseUrl = 'https://api.jta.web.id/api'; // Production
// static const String baseUrl = '---------'; // Production
// static const String baseUrl = 'http://192.168.X.X:3000/api'; // Local HP Asli
// static const String baseUrl = 'http://10.0.2.2:3000/api'; // Android Emulator
// --- SUPABASE CONFIGURATION ---
static const String supabaseUrl = 'YOUR_SUPABASE_URL';
static const String supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY';
// static const String supabaseUrl = 'YOUR_SUPABASE_URL';
// static const String supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY';
// --- ONESIGNAL CONFIGURATION ---
static const String oneSignalAppId = 'YOUR_ONESIGNAL_APP_ID';
}
// static const String oneSignalAppId = 'YOUR_ONESIGNAL_APP_ID';
// }

View File

@ -6,28 +6,34 @@ import '../../services/supabase_auth_service.dart'; // Dibutuhkan untuk DI di au
import 'auth_state.dart';
// Provider
// Provider untuk AuthRepository yang disuntik dengan SupabaseAuthService
/// Provider global untuk [AuthRepository].
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(SupabaseAuthService()),
);
/// Provider utama untuk mengelola status autentikasi di seluruh aplikasi.
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(ref.read(authRepositoryProvider)),
);
// Notifier
/// Notifier yang mengelola logika bisnis autentikasi pengguna.
/// Berfungsi sebagai penghubung antara UI dan [AuthRepository], serta mengontrol pendaftaran OneSignal Push Notification.
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repo;
AuthNotifier(this._repo) : super(const AuthIdle());
/// Memeriksa status sesi login yang tersimpan di perangkat lokal.
/// Dipanggil pertama kali saat aplikasi dibuka untuk fitur Auto-Login.
Future<void> checkAuthStatus() async {
state = const AuthLoading();
try {
// Gunakan _repo.checkSession() lewat DI tidak perlu buat instance baru
// Mengambil sesi aktif dari repository
final user = await _repo.checkSession();
if (user != null) {
// Jika sesi valid, login ke OneSignal menggunakan ID user untuk pengiriman notifikasi personal
OneSignal.login(user.id);
state = AuthSuccess(user);
} else {
@ -38,10 +44,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
/// Memproses login pengguna menggunakan email dan password.
Future<void> login(String email, String password) async {
state = const AuthLoading();
try {
final user = await _repo.login(email, password);
// Daftarkan ke OneSignal saat login berhasil
OneSignal.login(user.id);
state = AuthSuccess(user);
} catch (e) {
@ -49,11 +57,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
/// Memproses registrasi akun baru (Nama, Email, Password).
Future<void> register(
String name, String email, String password, String claimCode) async {
String name, String email, String password) async {
state = const AuthLoading();
try {
final user = await _repo.register(name, email, password, claimCode);
final user = await _repo.register(name, email, password);
// Daftarkan ke OneSignal saat registrasi berhasil
OneSignal.login(user.id);
state = AuthSuccess(user);
} catch (e) {
@ -61,12 +71,34 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
/// Menghubungkan akun pengguna dengan perangkat IoT ESP32 berdasarkan [claimCode] dari alat.
/// Setelah berhasil diklaim, state [AuthState] akan diperbarui dengan data `deviceId` yang baru.
Future<void> claimDevice(String claimCode) async {
final currentState = state;
if (currentState is! AuthSuccess) return;
state = const AuthLoading();
try {
final deviceId = await _repo.claimDevice(currentState.user.id, claimCode);
// Update UserModel dengan deviceId baru secara immutable
final updatedUser = currentState.user.copyWith(deviceId: deviceId);
state = AuthSuccess(updatedUser);
} catch (e) {
// Revert ke state sebelumnya jika terjadi error klaim
state = currentState;
rethrow;
}
}
/// Keluar dari sesi login pengguna.
/// Menghapus kredensial dari Supabase, memutus sesi OneSignal, dan mereset state ke [AuthIdle].
Future<void> logout() async {
OneSignal.logout();
await _repo.logout();
state = const AuthIdle();
}
/// Mereset kondisi error saat ini kembali ke [AuthIdle].
void resetError() {
if (state is AuthError) state = const AuthIdle();
}

View File

@ -1,28 +1,34 @@
import '../../models/user_model.dart';
/// State untuk auth provider semua kemungkinan kondisi auth.
/// State untuk provider autentikasi ([authProvider]).
/// Merepresentasikan seluruh kemungkinan kondisi autentikasi yang dapat terjadi di aplikasi.
sealed class AuthState {
const AuthState();
}
/// Kondisi awal / idle
/// Kondisi awal atau idle, di mana tidak ada aksi autentikasi yang sedang berjalan.
class AuthIdle extends AuthState {
const AuthIdle();
}
/// Sedang memproses (login/register)
/// Kondisi loading, aktif ketika sistem sedang memproses login, pendaftaran akun (register),
/// atau pengecekan sesi aktif di latar belakang.
class AuthLoading extends AuthState {
const AuthLoading();
}
/// Berhasil membawa data user
/// Kondisi sukses, dipicu ketika pengguna berhasil login atau mendaftar akun.
/// Membawa data profil pengguna berupa objek [UserModel].
class AuthSuccess extends AuthState {
/// Objek data pengguna yang sedang aktif login.
final UserModel user;
const AuthSuccess(this.user);
}
/// Gagal membawa pesan error
/// Kondisi gagal, dipicu ketika terjadi kesalahan selama proses autentikasi (misal: password salah).
/// Membawa pesan kesalahan ([message]) untuk ditampilkan ke antarmuka pengguna (UI).
class AuthError extends AuthState {
/// Pesan kegagalan/error yang ramah bagi pengguna.
final String message;
const AuthError(this.message);
}

View File

@ -23,7 +23,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _confirmCtrl = TextEditingController();
final _claimCodeCtrl = TextEditingController();
AutovalidateMode _autoValidate = AutovalidateMode.disabled;
bool _isButtonEnabled = false;
@ -31,7 +30,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
@override
void initState() {
super.initState();
for (final c in [_nameCtrl, _emailCtrl, _passwordCtrl, _confirmCtrl, _claimCodeCtrl]) {
for (final c in [_nameCtrl, _emailCtrl, _passwordCtrl, _confirmCtrl]) {
c.addListener(_checkButton);
}
}
@ -40,8 +39,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final filled = _nameCtrl.text.isNotEmpty &&
_emailCtrl.text.isNotEmpty &&
_passwordCtrl.text.isNotEmpty &&
_confirmCtrl.text.isNotEmpty &&
_claimCodeCtrl.text.isNotEmpty;
_confirmCtrl.text.isNotEmpty;
if (filled != _isButtonEnabled) {
setState(() => _isButtonEnabled = filled);
@ -54,7 +52,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
_emailCtrl.dispose();
_passwordCtrl.dispose();
_confirmCtrl.dispose();
_claimCodeCtrl.dispose();
super.dispose();
}
@ -67,7 +64,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
_nameCtrl.text,
_emailCtrl.text,
_passwordCtrl.text,
_claimCodeCtrl.text,
);
if (!mounted) return;
@ -141,16 +137,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
v != _passwordCtrl.text ? 'Password tidak sama' : null,
),
const SizedBox(height: 20),
AuthTextField(
controller: _claimCodeCtrl,
label: 'Kode Perangkat (Claim Code)',
prefixIcon: Icons.qr_code_scanner_outlined,
validator: (v) =>
(v == null || v.isEmpty) ? 'Kode perangkat wajib diisi' : null,
),
const SizedBox(height: 40),
AuthPrimaryButton(

View File

@ -198,6 +198,8 @@ class ControlPage extends ConsumerWidget {
final userName = _getUserName(ref);
final deviceCode = _getDeviceCode(ref);
final isOnline = _checkIsOnline(ref);
final authState = ref.watch(authProvider);
final hasDevice = authState is AuthSuccess && authState.user.deviceId != null;
return Scaffold(
backgroundColor: AppTheme.bgPage,
@ -209,10 +211,57 @@ class ControlPage extends ConsumerWidget {
children: [
GreetingSection(
userName: userName,
subtitle: 'Selamat Datang di Kontrol Panel',
subtitle: hasDevice ? 'Selamat Datang di Kontrol Panel' : 'Kontrol Panel Nonaktif',
),
const SizedBox(height: 20),
if (!hasDevice) ...[
const SizedBox(height: 40),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.sensors_off_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 24),
Text(
'Kontrol Perangkat Dinonaktifkan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 12),
Text(
'Silakan hubungkan perangkat kumbung jamur Anda terlebih dahulu pada halaman Monitoring (tab pertama) dengan menekan tombol + dan memasukkan Claim Code Anda.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.5,
),
),
],
),
),
] else ...[
DeviceStatusCard(
isOnline: isOnline,
deviceCode: deviceCode,
@ -260,6 +309,7 @@ class ControlPage extends ConsumerWidget {
],
const SizedBox(height: 20),
],
],
),
),
),

View File

@ -5,6 +5,7 @@ import '../../../core/app_theme.dart';
import '../../auth/auth_notifier.dart';
import '../../auth/auth_state.dart';
import '../providers/monitoring_provider.dart';
import '../providers/control_notifier.dart';
import '../widgets/chart_range_selector.dart';
import '../widgets/chart_type_selector.dart';
import '../widgets/greeting_section.dart';
@ -27,17 +28,125 @@ class MonitoringPage extends ConsumerWidget {
return '-';
}
void _showClaimDeviceDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
bool dialogLoading = false;
String? dialogError;
showDialog(
context: context,
barrierDismissible: true,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text(
'Hubungkan Alat',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Masukkan Kode Perangkat (Claim Code) yang tertera pada alat Anda.',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'Claim Code',
prefixIcon: const Icon(Icons.qr_code_scanner_outlined, color: AppTheme.primary),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppTheme.primary, width: 2),
),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Claim Code tidak boleh kosong' : null,
),
if (dialogError != null) ...[
const SizedBox(height: 12),
Text(
dialogError!,
style: const TextStyle(color: AppTheme.statusDanger, fontSize: 13),
),
],
],
),
),
actions: [
TextButton(
onPressed: dialogLoading ? null : () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: dialogLoading
? null
: () async {
if (!formKey.currentState!.validate()) return;
setState(() {
dialogLoading = true;
dialogError = null;
});
try {
await ref.read(authProvider.notifier).claimDevice(controller.text);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Perangkat berhasil dihubungkan!'),
backgroundColor: AppTheme.statusNormal,
),
);
}
} catch (e) {
setState(() {
dialogLoading = false;
dialogError = e.toString().replaceFirst('Exception: ', '');
});
}
},
child: dialogLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Text('Hubungkan', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final monitoring = ref.watch(monitoringProvider);
final control = ref.watch(controlProvider);
final userName = _getUserName(ref);
final authState = ref.watch(authProvider);
final hasDevice = authState is AuthSuccess && authState.user.deviceId != null;
return Scaffold(
backgroundColor: AppTheme.bgPage,
body: SafeArea(
child: RefreshIndicator(
onRefresh: () =>
ref.read(monitoringProvider.notifier).fetchData(showLoading: true),
onRefresh: () => hasDevice
? ref.read(monitoringProvider.notifier).fetchData(showLoading: true)
: Future.value(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
@ -46,11 +155,17 @@ class MonitoringPage extends ConsumerWidget {
children: [
GreetingSection(
userName: userName,
subtitle: 'Monitoring Real-Time Kumbung',
subtitle: hasDevice ? 'Monitoring Real-Time Kumbung' : 'Selamat datang kembali!',
),
const SizedBox(height: 20),
if (!hasDevice) ...[
const SizedBox(height: 40),
_EmptyDeviceState(
onAddPressed: () => _showClaimDeviceDialog(context, ref),
),
] else ...[
DeviceStatusCard(
isOnline: monitoring.latest != null && DateTime.now().difference(monitoring.latest!.timestamp).inMinutes < 5,
deviceCode: _getDeviceCode(ref),
@ -78,7 +193,7 @@ class MonitoringPage extends ConsumerWidget {
value: monitoring.temperature.toStringAsFixed(1),
unit: '°C',
subtitle: 'Suhu Real Time',
status: monitoring.tempStatus,
status: monitoring.tempStatus(control.suhuLimit),
gradient: AppTheme.suhuGradient,
),
),
@ -88,7 +203,7 @@ class MonitoringPage extends ConsumerWidget {
value: monitoring.humidity.toStringAsFixed(1),
unit: '%',
subtitle: 'Kelembapan Real Time',
status: monitoring.humidStatus,
status: monitoring.humidStatus(control.kelembapanLimit),
gradient: AppTheme.humidGradient,
),
),
@ -106,6 +221,7 @@ class MonitoringPage extends ConsumerWidget {
// Chart
const MonitoringChart(),
],
],
const SizedBox(height: 20),
],
@ -117,6 +233,76 @@ class MonitoringPage extends ConsumerWidget {
}
}
// Empty Device State
class _EmptyDeviceState extends StatelessWidget {
final VoidCallback onAddPressed;
const _EmptyDeviceState({required this.onAddPressed});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: onAddPressed,
child: Container(
width: 84,
height: 84,
decoration: BoxDecoration(
color: AppTheme.accentLight,
shape: BoxShape.circle,
border: Border.all(
color: AppTheme.accent.withOpacity(0.2),
width: 2,
),
),
child: const Icon(
Icons.add,
size: 44,
color: AppTheme.accent,
),
),
),
const SizedBox(height: 24),
const Text(
'Belum Ada Alat Terhubung',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
const Text(
'Tekan tombol + di atas untuk menambahkan perangkat baru Anda menggunakan Claim Code agar dapat memulai monitoring real-time kumbung jamur Anda.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.5,
),
),
],
),
);
}
}
// Error banner
class _ErrorBanner extends StatelessWidget {
final String message;

View File

@ -8,31 +8,36 @@ import '../../auth/auth_state.dart';
import 'control_state.dart';
// Provider
/// Provider global untuk [ControlNotifier] yang mengelola kendali pompa dan penjadwalan.
final controlProvider = NotifierProvider<ControlNotifier, ControlState>(ControlNotifier.new);
// Notifier
/// Notifier yang menangani logika kontrol hardware (ESP32) seperti menyalakan pompa,
/// mengubah mode kerja (Auto/Manual), batas sensor (threshold), serta tambah/hapus jadwal penyiraman.
class ControlNotifier extends Notifier<ControlState> {
late ApiControlService _api;
String _deviceId = '';
// Sync lock: cegah syncRelayFromSensor menimpa optimistic update
// selama 10 detik setelah user melakukan aksi manual (togglePump)
/// Kunci sinkronisasi pompa (Sync Lock): mencegah status relay yang dilaporkan oleh polling berkala
/// menimpa status tombol di UI (Optimistic Update) selama 10 detik pertama setelah user menekan tombol.
DateTime? _syncLockedUntil;
// Sync lock: cegah syncModeFromSensor menimpa optimistic update
// selama 10 detik setelah user melakukan ganti mode (setDeviceMode)
/// Kunci sinkronisasi mode (Sync Lock): mencegah status mode operasi yang dilaporkan oleh polling berkala
/// menimpa pilihan mode di UI selama 10 detik pertama setelah user mengubah mode.
DateTime? _modeSyncLockedUntil;
@override
ControlState build() {
// Memantau perubahan status login pengguna untuk mengambil `deviceId`
final authState = ref.watch(authProvider);
if (authState is AuthSuccess && authState.user.deviceId != null) {
_deviceId = authState.user.deviceId!;
}
// Inisialisasi ApiControlService dengan deviceId milik user
_api = ApiControlService(_deviceId);
// Fetch initial data secara asinkron tanpa memblokir build
// Memicu pemuatan data awal dari API secara asinkron setelah build widget selesai
if (_deviceId.isNotEmpty) {
Future.microtask(() => _initData());
}
@ -40,23 +45,25 @@ class ControlNotifier extends Notifier<ControlState> {
return ControlState.initial();
}
/// Mengambil seluruh data konfigurasi awal (threshold, jadwal, mode operasi) dari API.
Future<void> _initData() async {
state = state.copyWith(isLoading: true);
try {
final thresholdTask = _api.getThreshold();
final schedulesTask = _api.getSchedules();
// Menjalankan request threshold dan jadwal secara paralel untuk efisiensi
final results = await Future.wait([thresholdTask, schedulesTask]);
final threshold = results[0] as Map<String, dynamic>;
final schedulesData = results[1] as List<dynamic>;
// Ambil mode operasi dari API
// Mengambil status mode operasi saat ini dari backend
final currentMode = await _api.getMode();
// Parsing schedules dari backend ke WateringSchedule
// Melakukan parsing data jadwal mentah dari backend ke objek WateringSchedule
final parsedSchedules = schedulesData.map((s) {
// Backend cron format: "minute hour day month day-of-week" (DISIMPAN DALAM UTC!)
// Misal: "30 10 * * 1,3,5" (10:30 UTC = 17:30 WIB)
// Backend menyimpan format cron di zona waktu UTC: "minute hour day month day-of-week"
// Misal: "30 10 * * 1,3,5" (Jam 10:30 UTC = Jam 17:30 WIB)
final cronString = s['cron'].toString();
final cronParts = cronString.split(' ');
int utcMinute = 0;
@ -66,16 +73,17 @@ class ControlNotifier extends Notifier<ControlState> {
utcHour = int.tryParse(cronParts[1]) ?? 0;
}
// Konversi UTC -> WIB (+7 jam) agar tampilan jam di UI sesuai input user
// Konversi UTC ke zona waktu lokal WIB (+7 jam) agar jam yang tampil di UI cocok dengan input pengguna
final utcTotalMinutes = utcHour * 60 + utcMinute;
final wibTotalMinutes = (utcTotalMinutes + 7 * 60) % (24 * 60);
final wibHour = wibTotalMinutes ~/ 60;
final wibMinute = wibTotalMinutes % 60;
// Gunakan utils untuk ekstrak string repeat
// Mengekstrak daftar hari aktif dari string cron
var daysArray = CronUtils.parseDaysFromCron(cronString);
// BUG FIX: Jika penambahan 7 jam menyebabkan hari berganti ke besoknya, geser hari ke depan
// JIKA konversi WIB (+7 jam) menyebabkan pergantian hari (ke esok hari),
// maka daftar hari aktif harus digeser maju (+1 hari).
if (utcTotalMinutes + 7 * 60 >= 24 * 60) {
daysArray = daysArray.map((d) => (d + 1) % 7).toList();
}
@ -92,7 +100,6 @@ class ControlNotifier extends Notifier<ControlState> {
);
}).toList();
state = state.copyWith(
isLoading: false,
suhuLimit: (threshold['temp_max'] as num?)?.toDouble() ?? 30.0,
@ -110,14 +117,18 @@ class ControlNotifier extends Notifier<ControlState> {
}
// Pesan Error & Success
/// Membersihkan pesan error dan sukses dari state saat ini.
void clearMessage() {
state = state.copyWith(clearError: true, clearSuccess: true);
}
// Pompa
/// Mengaktifkan atau menonaktifkan status pompa secara langsung (Manual Control).
/// Menerapkan Optimistic Update di mana UI langsung merespon sebelum request ke server selesai.
Future<void> togglePump([int? durationSeconds]) async {
final willTurnOn = !state.isPumpOn;
// Optimistic update
// 1. Optimistic Update (UI berganti status secara instan)
state = state.copyWith(
isPumpOn: willTurnOn,
activePumpDuration: willTurnOn ? (durationSeconds ?? 30) : null,
@ -125,13 +136,12 @@ class ControlNotifier extends Notifier<ControlState> {
clearTimer: !willTurnOn,
);
// Kunci sync selama 10 detik agar polling dari monitoringProvider tidak
// menimpa optimistic update sebelum ESP32 sempat melaporkan state baru
// 2. Kunci sync lock selama 10 detik agar polling tidak langsung menimpa status tombol
_syncLockedUntil = DateTime.now().add(const Duration(seconds: 10));
try {
if (willTurnOn) {
final duration = durationSeconds ?? 30; // Default 30 detik
final duration = durationSeconds ?? 30; // Default penyiraman 30 detik
await _api.siramManual(duration);
state = state.copyWith(successMessage: 'Menyalakan pompa (${duration}s)');
} else {
@ -139,23 +149,20 @@ class ControlNotifier extends Notifier<ControlState> {
state = state.copyWith(successMessage: 'Pompa dimatikan');
}
} catch (e) {
// Revert jika gagal, dan lepas lock agar sync bisa jalan kembali
// 3. Batalkan/Revert status tombol jika request API mengalami kegagalan
_syncLockedUntil = null;
state = state.copyWith(isPumpOn: !willTurnOn, errorMessage: 'Gagal mengontrol pompa');
print('Gagal toggle pompa: $e');
}
}
/// Sinkronisasi status pompa dari data sensor real-time.
/// Dipanggil oleh monitoring_provider setiap kali dapat data sensor baru dari ESP32.
/// CATATAN: Sync dikunci selama 10 detik setelah user aksi manual (togglePump)
/// untuk mencegah race condition antara optimistic update dan polling.
/// Sinkronisasi status pompa dari data sensor real-time yang didapat dari ESP32.
/// Dipanggil oleh `monitoringProvider` setiap 5 detik.
/// CATATAN: Update akan diabaikan jika sync lock manual (10 detik) masih berjalan.
void syncRelayFromSensor(bool relayOn) {
// Jika sync sedang dikunci (user baru tekan tombol), abaikan update dari polling
if (_syncLockedUntil != null && DateTime.now().isBefore(_syncLockedUntil!)) {
return;
return; // Abaikan update sensor karena user baru saja memicu aksi manual
}
// Hanya update jika berbeda, untuk menghindari rebuild yang tidak perlu
if (state.isPumpOn != relayOn) {
state = state.copyWith(
isPumpOn: relayOn,
@ -164,36 +171,39 @@ class ControlNotifier extends Notifier<ControlState> {
}
}
/// Sinkronisasi status mode operasi dari data sensor real-time yang didapat dari ESP32.
/// CATATAN: Update akan diabaikan jika sync lock mode (10 detik) masih aktif atau mode sedang loading.
void syncModeFromSensor(String modeStr) {
// Jika sync sedang dikunci (user baru tekan tombol), abaikan update dari polling
if (_modeSyncLockedUntil != null && DateTime.now().isBefore(_modeSyncLockedUntil!)) {
return;
return; // Abaikan update sensor karena user baru saja mengganti mode secara manual
}
final realMode = DeviceModeX.fromString(modeStr);
// Jangan overwrite jika UI sedang loading ganti mode (optimistic update)
if (state.deviceMode != realMode && !state.isModeLoading) {
state = state.copyWith(deviceMode: realMode);
}
}
/// Memperbarui status menyala pompa di state secara langsung.
void setPump(bool value) => state = state.copyWith(isPumpOn: value);
// Batasan Sensor
/// Simpan kedua nilai threshold sekaligus dalam satu API call.
/// Dipanggil dari SensorLimitCard saat user menekan tombol Simpan.
/// Menyimpan batasan suhu maks ([suhu]) dan kelembapan min ([kelembapan]) ke server.
Future<void> saveThreshold(double suhu, double kelembapan) async {
// Optimistic update UI langsung mencerminkan nilai baru
final oldSuhu = state.suhuLimit;
final oldKelembapan = state.kelembapanLimit;
// Optimistic Update
state = state.copyWith(suhuLimit: suhu, kelembapanLimit: kelembapan);
try {
await _api.updateThreshold(suhu, kelembapan);
state = state.copyWith(successMessage: 'Batasan sensor diperbarui');
} catch (e) {
// Revert ke nilai lama jika gagal
// Revert ke nilai lama jika gagal menyimpan
state = state.copyWith(
suhuLimit: state.suhuLimit,
kelembapanLimit: state.kelembapanLimit,
suhuLimit: oldSuhu,
kelembapanLimit: oldKelembapan,
errorMessage: 'Gagal menyimpan batasan sensor',
);
print('Gagal save threshold: $e');
@ -201,20 +211,20 @@ class ControlNotifier extends Notifier<ControlState> {
}
// Jadwal
/// Menambahkan jadwal penyiraman otomatis baru.
/// Melakukan konversi waktu lokal (WIB) ke format Cron UTC sebelum dikirim ke API.
Future<void> addSchedule(TimeOfDay time, List<int> days, int durationS) async {
state = state.copyWith(isLoading: true);
try {
// BullMQ menjalankan cron berdasarkan UTC, sedangkan user input dalam WIB (UTC+7).
// Konversikan ke UTC dengan mengurangi 7 jam agar jadwal tepat waktu.
// Konversi WIB (UTC+7) ke UTC dengan mengurangi waktu sebanyak 7 jam
final localMinutes = time.hour * 60 + time.minute;
final utcMinutes = (localMinutes - 7 * 60) % (24 * 60);
// Pastikan tidak negatif (modulo di Dart bisa negatif jika dividennya negatif)
final utcTotalMinutes = utcMinutes < 0 ? utcMinutes + 24 * 60 : utcMinutes;
final utcHour = utcTotalMinutes ~/ 60;
final utcMinute = utcTotalMinutes % 60;
// BUG FIX: Geser hari mundur jika pengurangan 7 jam menyebabkan waktu mundur ke hari kemarin
// JIKA pengurangan waktu WIB menyebabkan hari bergeser mundur (misal jam 05 WIB menjadi jam 22 UTC hari kemarin),
// maka daftar hari aktif harus digeser mundur (-1 hari).
List<int> adjustedDays = List.from(days);
if (localMinutes - 7 * 60 < 0) {
adjustedDays = days.map((d) => (d - 1) < 0 ? 6 : (d - 1)).toList();
@ -245,9 +255,11 @@ class ControlNotifier extends Notifier<ControlState> {
}
}
/// Mengaktifkan atau menonaktifkan jadwal penyiraman tertentu berdasarkan [id].
Future<void> toggleSchedule(String id) async {
// Optimistic update
final oldSchedules = state.schedules;
// Optimistic Update
state = state.copyWith(
schedules: state.schedules.map((s) {
return s.id == id ? s.copyWith(isEnabled: !s.isEnabled) : s;
@ -257,12 +269,13 @@ class ControlNotifier extends Notifier<ControlState> {
try {
await _api.toggleSchedule(id);
} catch (e) {
// Revert
// Revert ke status jadwal semula jika request API gagal
state = state.copyWith(schedules: oldSchedules, errorMessage: 'Gagal mengubah status jadwal');
print('Gagal toggle schedule: $e');
}
}
/// Menghapus jadwal penyiraman otomatis berdasarkan [id].
Future<void> removeSchedule(String id) async {
final oldSchedules = state.schedules;
state = state.copyWith(
@ -274,22 +287,24 @@ class ControlNotifier extends Notifier<ControlState> {
await _api.deleteSchedule(id);
state = state.copyWith(isLoading: false, successMessage: 'Jadwal berhasil dihapus');
} catch (e) {
// Revert jika gagal menghapus
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
print('Gagal hapus schedule: $e');
}
}
// Mode Operasi
/// Mengganti mode kerja alat (Otomatis ke Manual, atau sebaliknya).
Future<void> setDeviceMode(DeviceMode mode) async {
if (state.isModeLoading) return;
if (state.deviceMode == mode) return; // Mencegah request berulang jika menekan mode yang sama
if (state.deviceMode == mode) return; // Mencegah request ganda untuk mode yang sama
final prevMode = state.deviceMode;
// Optimistic update
// Optimistic Update
state = state.copyWith(deviceMode: mode, isModeLoading: true);
// Kunci sync selama 10 detik agar polling tidak mengembalikan state lama
// sebelum ESP32 sempat mengubah dan mengirim state barunya
// Kunci sync lock mode selama 10 detik agar status mode dari sensor tidak langsung menimpa UI sebelum hardware beres merespon
_modeSyncLockedUntil = DateTime.now().add(const Duration(seconds: 10));
try {
@ -299,7 +314,7 @@ class ControlNotifier extends Notifier<ControlState> {
successMessage: 'Mode diubah ke ${mode.label}',
);
} catch (e) {
// Revert
// Revert mode jika gagal menyimpan ke server
_modeSyncLockedUntil = null;
state = state.copyWith(
deviceMode: prevMode,

View File

@ -1,12 +1,24 @@
import 'package:flutter/material.dart';
// Model: WateringSchedule
/// Model data untuk jadwal penyiraman otomatis yang tersimpan di sistem.
class WateringSchedule {
/// ID unik jadwal yang digenerate oleh database.
final String id;
/// Jam penyiraman (WIB).
final TimeOfDay time;
/// Teks deskripsi pengulangan jadwal (misal: "Setiap Hari" atau "Sen, Rab, Jum").
final String repeat;
/// Menandakan apakah jadwal ini sedang aktif atau dinonaktifkan oleh pengguna.
final bool isEnabled;
/// Format string Cron asli yang disimpan di database (dalam zona waktu UTC).
final String? cron;
/// Durasi penyiraman dalam satuan detik. Default: 30 detik.
final int durationS;
const WateringSchedule({
@ -18,12 +30,14 @@ class WateringSchedule {
this.durationS = 30,
});
/// Mengembalikan format jam dalam string 'HH.MM' untuk kebutuhan UI.
String get formattedTime {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
return '$h.$m';
}
/// Menggandakan objek [WateringSchedule] dengan nilai baru secara immutable.
WateringSchedule copyWith({
String? id,
TimeOfDay? time,
@ -54,9 +68,15 @@ class WateringSchedule {
}
// Enum: DeviceMode
/// Mode operasional dari modul IoT penyiram jamur:
/// - [auto_] : Pompa menyala otomatis jika sensor mendeteksi nilai melewati threshold atau sesuai jadwal.
/// - [manual] : Pompa hanya menyala saat dikendalikan langsung oleh pengguna dari aplikasi.
/// - [offline] : Status perangkat sedang tidak terhubung ke jaringan internet.
enum DeviceMode { auto_, manual, offline }
/// Ekstensi pendukung untuk mempermudah manipulasi nilai enum [DeviceMode].
extension DeviceModeX on DeviceMode {
/// Nilai string yang digunakan dalam pertukaran data API dengan backend.
String get apiValue {
switch (this) {
case DeviceMode.auto_: return 'auto';
@ -65,6 +85,7 @@ extension DeviceModeX on DeviceMode {
}
}
/// Teks berformat rapi untuk ditampilkan di UI label status.
String get label {
switch (this) {
case DeviceMode.auto_: return 'AUTO';
@ -73,6 +94,7 @@ extension DeviceModeX on DeviceMode {
}
}
/// Memparsing string dari backend menjadi tipe [DeviceMode].
static DeviceMode fromString(String s) {
final str = s.toLowerCase();
if (str.startsWith('manual')) {
@ -86,17 +108,39 @@ extension DeviceModeX on DeviceMode {
}
// State: ControlState
/// State yang menampung seluruh konfigurasi kendali modul IoT (Pompa/Watering).
class ControlState {
/// Status loading pemuatan data konfigurasi dari API.
final bool isLoading;
/// Status relay pompa saat ini (true = menyala, false = mati).
final bool isPumpOn;
/// Nilai threshold suhu maksimal (°C).
final double suhuLimit;
/// Nilai threshold kelembapan minimal (%).
final double kelembapanLimit;
/// Daftar jadwal penyiraman otomatis yang telah diatur pengguna.
final List<WateringSchedule> schedules;
/// Pesan error dari server (jika ada).
final String? errorMessage;
/// Pesan sukses aksi (jika ada).
final String? successMessage;
/// Berapa lama pompa manual diset menyala (detik).
final int? activePumpDuration;
/// Timestamp waktu ketika pompa dinyalakan (untuk kalkulasi countdown sisa durasi).
final DateTime? pumpTurnedOnAt;
/// Mode alat saat ini (Auto / Manual / Offline).
final DeviceMode deviceMode;
/// Status loading khusus saat mengubah mode alat.
final bool isModeLoading;
const ControlState({
@ -113,11 +157,14 @@ class ControlState {
this.isModeLoading = false,
});
/// State awal saat pertama kali dimuat di memori.
factory ControlState.initial() => const ControlState(
isLoading: true, // Saat mulai aplikasi, data sedang di-fetch
isLoading: true, // Data sedang dimuat dari API pada awal jalannya screen
schedules: [],
);
/// Menggandakan [ControlState] secara immutable.
/// Menyediakan fitur pembersihan pesan sukses/error atau countdown timer.
ControlState copyWith({
bool? isLoading,
bool? isPumpOn,

View File

@ -12,13 +12,19 @@ import '../../auth/auth_state.dart';
import 'control_notifier.dart';
// Enums
/// Rentang waktu grafik: [daily] untuk harian, [monthly] untuk bulanan.
enum ChartRange { daily, monthly }
/// Tipe data yang ditampilkan di grafik: [suhu] (temperatur) atau [kelembapan] (humidity).
enum ChartType { suhu, kelembapan }
// Supporting providers
/// Provider untuk menyediakan instance [IotRepository].
/// Secara dinamis mengambil `deviceId` dari pengguna yang sedang login ([authProvider]).
/// Jika pengguna belum login atau tidak memiliki `deviceId`, akan menggunakan fallback default 'esp32-01'.
final iotRepositoryProvider = Provider<IotRepository>((ref) {
final authState = ref.watch(authProvider);
String deviceId = 'esp32-01'; // Default fallback
String deviceId = 'esp32-01'; // Default fallback jika deviceId tidak ditemukan
if (authState is AuthSuccess && authState.user.deviceId != null) {
deviceId = authState.user.deviceId!;
@ -27,25 +33,40 @@ final iotRepositoryProvider = Provider<IotRepository>((ref) {
return IotRepository(ApiIotService(deviceId));
});
/// Provider untuk menyimpan status pilihan rentang waktu grafik (Harian / Bulanan).
final chartRangeProvider = StateProvider<ChartRange>(
(_) => ChartRange.daily,
);
/// Provider untuk menyimpan status pilihan tipe grafik yang aktif (Suhu / Kelembapan).
final chartTypeProvider = StateProvider<ChartType>(
(_) => ChartType.suhu,
);
/// Provider untuk menyimpan status offset grafik (untuk navigasi hari/bulan sebelumnya atau setelahnya).
final chartOffsetProvider = StateProvider<int>(
(_) => 0,
);
// State
/// State yang merepresentasikan semua data pemantauan IoT di UI.
class MonitoringState {
/// Data sensor terbaru yang diterima dari ESP32.
final SensorModel? latest;
/// Status loading saat aplikasi pertama kali memuat data atau memperbarui secara manual.
final bool isLoading;
/// Pesan error jika terjadi kegagalan pemanggilan API.
final String? errorMessage;
/// Riwayat data sensor (real-time) untuk grafik harian berjalan (maksimal 200 poin).
final List<ChartData> history;
/// Agregasi riwayat harian rata-rata untuk grafik bulanan.
final List<DailyAverage> dailyHistory;
/// Agregasi riwayat per jam rata-rata untuk grafik harian.
final List<DailyAverage> hourlyHistory;
const MonitoringState({
@ -57,30 +78,45 @@ class MonitoringState {
this.hourlyHistory = const [],
});
/// State awal saat provider pertama kali diinisialisasi (status loading aktif).
factory MonitoringState.initial() => const MonitoringState(isLoading: true);
/// Helper getter untuk mendapatkan nilai suhu saat ini. Default: 0 jika null.
double get temperature => latest?.temperature ?? 0;
/// Helper getter untuk mendapatkan nilai kelembapan saat ini. Default: 0 jika null.
double get humidity => latest?.humidity ?? 0;
MonitoringStatus get tempStatus {
if (temperature >= 33) return MonitoringStatus.danger;
if (temperature > 30) return MonitoringStatus.warning;
/// Menentukan status suhu berdasarkan batas maksimum suhu ([maxSuhu]).
/// - Danger: jika suhu melebihi batas maks + 3°C.
/// - Warning: jika suhu melebihi batas maks.
/// - Normal: jika suhu berada di bawah batas maks.
MonitoringStatus tempStatus(double maxSuhu) {
if (temperature >= maxSuhu + 3) return MonitoringStatus.danger;
if (temperature > maxSuhu) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
MonitoringStatus get humidStatus {
if (humidity < 65) return MonitoringStatus.danger;
if (humidity < 70) return MonitoringStatus.warning;
/// Menentukan status kelembapan berdasarkan batas minimum kelembapan ([minHum]).
/// - Danger: jika kelembapan berada di bawah batas min - 5%.
/// - Warning: jika kelembapan berada di bawah batas min.
/// - Normal: jika kelembapan berada di atas batas min.
MonitoringStatus humidStatus(double minHum) {
if (humidity < minHum - 5) return MonitoringStatus.danger;
if (humidity < minHum) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
String get overallStatus {
final statuses = [tempStatus, humidStatus];
/// Menentukan status keseluruhan kumbung jamur berdasarkan status suhu dan kelembapan.
/// Menghasilkan string 'Danger', 'Warning', atau 'Normal'.
String overallStatus(double maxSuhu, double minHum) {
final statuses = [tempStatus(maxSuhu), humidStatus(minHum)];
if (statuses.contains(MonitoringStatus.danger)) return 'Danger';
if (statuses.contains(MonitoringStatus.warning)) return 'Warning';
return 'Normal';
}
/// Menggandakan [MonitoringState] dengan memperbarui beberapa parameter saja (Immutable State).
MonitoringState copyWith({
Object? latest = _sentinel,
bool? isLoading,
@ -92,7 +128,7 @@ class MonitoringState {
return MonitoringState(
latest: latest == _sentinel ? this.latest : latest as SensorModel?,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage, // null = clear error
errorMessage: errorMessage, // null = menghapus pesan error
history: history ?? this.history,
dailyHistory: dailyHistory ?? this.dailyHistory,
hourlyHistory: hourlyHistory ?? this.hourlyHistory,
@ -104,6 +140,8 @@ class MonitoringState {
const _sentinel = Object();
// Notifier
/// Notifier yang mengelola logika bisnis pemantauan sensor real-time.
/// Menghubungi repository secara berkala untuk mengambil data sensor dan riwayatnya.
class MonitoringNotifier extends StateNotifier<MonitoringState> {
final IotRepository _repo;
final Ref _ref;
@ -113,17 +151,29 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
_startAutoRefresh();
}
/// Memulai auto-refresh berkala (polling) setiap 5 detik setelah memuat data awal.
Future<void> _startAutoRefresh() async {
await _initData();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchData());
}
/// Memuat data sensor awal beserta data riwayat grafik dari API.
Future<void> _initData() async {
final authState = _ref.read(authProvider);
final hasDevice = authState is AuthSuccess && authState.user.deviceId != null;
if (!hasDevice) {
// Jika pengguna belum terhubung dengan alat, loading dimatikan
state = state.copyWith(isLoading: false);
return;
}
state = state.copyWith(isLoading: true);
try {
// 1. Ambil data riwayat sensor mentah dari backend
final historyData = await _repo.getHistoryData();
final latest = historyData.isNotEmpty ? historyData.last : null;
// Konversi riwayat sensor mentah ke format ChartData untuk grafik
final historyCharts = historyData.map((s) => ChartData(
timestamp: s.timestamp,
suhu: s.temperature,
@ -133,6 +183,7 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
List<DailyAverage> dailyParsed = [];
List<DailyAverage> hourlyParsed = [];
try {
// 2. Ambil riwayat agregasi harian (30 hari terakhir) untuk grafik bulanan
final dailyRaw = await _repo.getDailyHistory(days: 30);
dailyParsed = dailyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
@ -142,6 +193,7 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
// 3. Ambil riwayat agregasi per jam (7 hari terakhir) untuk grafik harian
final hourlyRaw = await _repo.getHourlyHistory(days: 7);
hourlyParsed = hourlyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
@ -151,7 +203,7 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
} catch (e) {
// Abaikan jika endpoint belum tersedia atau error (fallback chart akan kosong)
// Abaikan jika endpoint agregasi belum tersedia atau terjadi error (chart akan kosong)
}
state = state.copyWith(
@ -167,7 +219,13 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
}
}
/// Mengambil data sensor terbaru dari backend secara real-time.
/// Dipanggil oleh [Timer] setiap 5 detik sekali.
Future<void> fetchData({bool showLoading = false}) async {
final authState = _ref.read(authProvider);
final hasDevice = authState is AuthSuccess && authState.user.deviceId != null;
if (!hasDevice) return;
if (showLoading) {
state = state.copyWith(isLoading: true);
}
@ -175,11 +233,10 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
try {
final sensor = await _repo.getSensorData();
// Cek apakah data terbaru memiliki timestamp lebih baru dari yang ada di history terakhir
// Verifikasi apakah data yang masuk benar-benar baru berdasarkan timestamp-nya
bool isNew = true;
if (state.history.isNotEmpty) {
final lastPoint = state.history.last;
// Gunakan isAfter atau compare timestamp
if (!sensor.timestamp.isAfter(lastPoint.timestamp)) {
isNew = false;
}
@ -196,6 +253,7 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
updatedHistory.add(newPoint);
}
// Membatasi panjang riwayat real-time maksimal 200 data poin agar hemat RAM
final trimmed = updatedHistory.length > 200
? updatedHistory.sublist(updatedHistory.length - 200)
: updatedHistory;
@ -207,8 +265,8 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
errorMessage: null,
);
// Bug #7: Sinkronisasi status pompa dari relay_state yang dikirim ESP32
// Ini memastikan UI selalu mencerminkan kondisi relay yang sesungguhnya
// Sinkronisasi status pompa dan mode yang didapat dari payload data sensor ESP32
// Ini memastikan UI sinkron dengan hardware asli jika dikendalikan secara eksternal
try {
_ref.read(controlProvider.notifier).syncRelayFromSensor(sensor.relayOn);
_ref.read(controlProvider.notifier).syncModeFromSensor(sensor.mode);
@ -223,7 +281,53 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
}
}
/// Ambil data history yang sudah difilter berdasarkan range waktu
/// Memuat riwayat data agregasi harian/bulanan dengan offset tertentu (navigasi grafik).
Future<void> fetchHistoryForOffset({
required ChartRange range,
required int offset,
}) async {
try {
if (range == ChartRange.daily) {
final int targetDays = offset.abs() + 1;
final int fetchDays = targetDays < 7 ? 7 : targetDays;
final hourlyRaw = await _repo.getHourlyHistory(days: fetchDays);
final hourlyParsed = hourlyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
final DateTime date = (DateTime.tryParse(dateStr) ?? DateTime.now()).toLocal();
final num? t = map['avg_temp'] ?? map['avg_temperature'] ?? map['temperature'];
final num? h = map['avg_hum'] ?? map['avg_humidity'] ?? map['humidity'];
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
state = state.copyWith(
hourlyHistory: hourlyParsed,
errorMessage: null,
);
} else {
final int targetDays = (offset.abs() + 1) * 31;
final int fetchDays = targetDays < 30 ? 30 : targetDays;
final dailyRaw = await _repo.getDailyHistory(days: fetchDays);
final dailyParsed = dailyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
final DateTime date = (DateTime.tryParse(dateStr) ?? DateTime.now()).toLocal();
final num? t = map['avg_temp'] ?? map['avg_temperature'] ?? map['temperature'];
final num? h = map['avg_hum'] ?? map['avg_humidity'] ?? map['humidity'];
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
state = state.copyWith(
dailyHistory: dailyParsed,
errorMessage: null,
);
}
} catch (e) {
// Abaikan agar tidak memutus polling data sensor real-time
}
}
/// Ambil data riwayat yang sudah difilter berdasarkan rentang waktu hari ini/bulan ini.
List<ChartData> getFilteredData(ChartRange range) {
final now = DateTime.now();
@ -244,6 +348,7 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
}
}
/// Menghentikan polling berkala.
void stopRefresh() => _timer?.cancel();
@override
@ -254,12 +359,14 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
}
// Main provider
/// Provider utama untuk memantau data sensor. Digunakan oleh UI halaman Monitoring.
final monitoringProvider =
StateNotifierProvider<MonitoringNotifier, MonitoringState>(
(ref) => MonitoringNotifier(ref.read(iotRepositoryProvider), ref),
(ref) => MonitoringNotifier(ref.watch(iotRepositoryProvider), ref),
);
// Daily average provider
/// Model penampung nilai rata-rata suhu & kelembapan pada jam/hari tertentu.
class DailyAverage {
final DateTime date;
final double? suhu;
@ -268,6 +375,8 @@ class DailyAverage {
const DailyAverage({required this.date, this.suhu, this.kelembapan});
}
/// Provider yang memproses & mencocokkan riwayat data sensor dengan kalender lokal.
/// Menghasilkan daftar koordinat titik grafik yang siap digambar oleh FL Chart.
final dailyAverageProvider = Provider<List<DailyAverage>>((ref) {
final range = ref.watch(chartRangeProvider);
final monitoring = ref.watch(monitoringProvider);
@ -278,10 +387,12 @@ final dailyAverageProvider = Provider<List<DailyAverage>>((ref) {
final DateTime Function(int) dateAt;
if (range == ChartRange.daily) {
// Grafik Harian: 24 titik grafik (jam 00 s.d 23)
totalDays = 24;
final targetDate = now.add(Duration(days: offset)); // offset = 0 is today, -1 is yesterday
final targetDate = now.add(Duration(days: offset)); // offset = 0 adalah hari ini, -1 kemarin, dst.
dateAt = (i) => DateTime(targetDate.year, targetDate.month, targetDate.day, i);
} else {
// Grafik Bulanan: Titik sesuai jumlah hari dalam bulan terpilih (28 s.d 31)
final targetMonth = DateTime(now.year, now.month + offset, 1);
final firstNext = DateTime(targetMonth.year, targetMonth.month + 1, 1);
final lastDay = firstNext.subtract(const Duration(days: 1));
@ -289,8 +400,10 @@ final dailyAverageProvider = Provider<List<DailyAverage>>((ref) {
dateAt = (i) => DateTime(targetMonth.year, targetMonth.month, i + 1);
}
// Membuat daftar DateTime kosong untuk setiap jam/hari
final days = List.generate(totalDays, dateAt);
// Menyusun lookup map agar pencarian data agregasi dari API lebih cepat (O(1))
final lookup = <DateTime, DailyAverage>{};
if (range == ChartRange.daily) {
for (final item in monitoring.hourlyHistory) {
@ -304,6 +417,7 @@ final dailyAverageProvider = Provider<List<DailyAverage>>((ref) {
}
}
// Menggabungkan kalender kosong dengan data riwayat dari API (jika ada data di jam/hari tersebut)
return days.map((date) {
final apiItem = lookup[date];
if (apiItem != null) {

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
import 'user_guide_sheet.dart';
class GreetingSection extends StatelessWidget {
final String userName;
@ -19,10 +20,11 @@ class GreetingSection extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
Image.asset(
'assets/images/logo_jamur.png',
width: 48,
height: 40,
child: CustomPaint(painter: _MushroomPainter()),
fit: BoxFit.contain,
),
const SizedBox(height: 4),
Text(
@ -43,6 +45,16 @@ class GreetingSection extends StatelessWidget {
],
),
const Spacer(),
IconButton(
onPressed: () => UserGuideSheet.show(context),
icon: const Icon(
Icons.help_outline,
size: 26,
color: AppTheme.primary,
),
tooltip: 'Petunjuk Penggunaan',
),
const SizedBox(width: 8),
CircleAvatar(
radius: 24,
backgroundColor: AppTheme.accentLight,
@ -57,44 +69,4 @@ class GreetingSection extends StatelessWidget {
}
}
class _MushroomPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
// Batang
paint.color = const Color(0xFF795548);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
size.width * 0.35, size.height * 0.55,
size.width * 0.3, size.height * 0.4,
),
const Radius.circular(4),
),
paint,
);
// Tudung
paint.color = AppTheme.primary;
final path = Path()
..moveTo(size.width * 0.5, 0)
..quadraticBezierTo(size.width * 0.05, size.height * 0.3,
0, size.height * 0.6)
..lineTo(size.width, size.height * 0.6)
..quadraticBezierTo(size.width * 0.95, size.height * 0.3,
size.width * 0.5, 0)
..close();
canvas.drawPath(path, paint);
// Bintik
paint.color = Colors.white.withOpacity(0.6);
canvas.drawCircle(
Offset(size.width * 0.35, size.height * 0.3), size.width * 0.07, paint);
canvas.drawCircle(
Offset(size.width * 0.62, size.height * 0.22), size.width * 0.05, paint);
}
@override
bool shouldRepaint(covariant CustomPainter _) => false;
}

View File

@ -22,6 +22,30 @@ class MonitoringChart extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<int>(chartOffsetProvider, (previous, next) {
if (next != previous) {
final currentRange = ref.read(chartRangeProvider);
ref.read(monitoringProvider.notifier).fetchHistoryForOffset(
range: currentRange,
offset: next,
);
}
});
ref.listen<ChartRange>(chartRangeProvider, (previous, next) {
if (next != previous) {
Future.microtask(() {
ref.read(chartOffsetProvider.notifier).state = 0;
if (ref.read(chartOffsetProvider) == 0) {
ref.read(monitoringProvider.notifier).fetchHistoryForOffset(
range: next,
offset: 0,
);
}
});
}
});
final range = ref.watch(chartRangeProvider);
final type = ref.watch(chartTypeProvider);
final offset = ref.watch(chartOffsetProvider);

View File

@ -0,0 +1,361 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
class UserGuideSheet extends StatefulWidget {
const UserGuideSheet({super.key});
static void show(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const UserGuideSheet(),
);
}
@override
State<UserGuideSheet> createState() => _UserGuideSheetState();
}
class _UserGuideSheetState extends State<UserGuideSheet> {
final PageController _pageController = PageController();
int _currentStep = 0;
final int _totalSteps = 4;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _next() {
if (_currentStep < _totalSteps - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
Navigator.pop(context);
}
}
void _previous() {
if (_currentStep > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
return Container(
margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 50),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
padding: EdgeInsets.only(bottom: bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Handle bar
const SizedBox(height: 12),
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 16),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
const Icon(
Icons.menu_book,
color: AppTheme.primary,
size: 24,
),
const SizedBox(width: 8),
const Text(
"Panduan Penggunaan",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, color: AppTheme.textSecondary),
),
],
),
),
const Divider(height: 1),
// Step Stepper Indicator Row
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalSteps, (index) {
final isDone = index < _currentStep;
final isCurrent = index == _currentStep;
return Row(
children: [
// Step Dot
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isDone
? AppTheme.statusNormal
: (isCurrent ? AppTheme.primary : Colors.grey.shade200),
shape: BoxShape.circle,
border: isCurrent
? Border.all(color: AppTheme.primaryDark, width: 1)
: null,
),
child: Center(
child: isDone
? const Icon(Icons.check, color: Colors.white, size: 16)
: Text(
"${index + 1}",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isCurrent ? Colors.white : AppTheme.textSecondary,
),
),
),
),
// Line connector
if (index < _totalSteps - 1)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: MediaQuery.of(context).size.width * 0.12,
height: 3,
color: isDone ? AppTheme.statusNormal : Colors.grey.shade200,
),
],
);
}),
),
),
// Sliding step contents
SizedBox(
height: 340,
child: PageView(
controller: _pageController,
onPageChanged: (step) {
setState(() {
_currentStep = step;
});
},
children: [
_buildStepContent(
icon: Icons.developer_board,
title: "1. Persiapan Alat ESP32",
description: "Nyalakan dan sambungkan alat fisik ke jaringan internet Anda.",
points: [
"Hubungkan ESP32 ke adaptor daya micro-USB 5V.",
"Lampu indikator merah pada alat akan menyala saat aktif.",
"Hubungkan ESP32 ke jaringan WiFi kumbung agar alat dapat mengirim data ke internet secara real-time.",
],
),
_buildStepContent(
icon: Icons.qr_code_scanner,
title: "2. Hubungkan Alat ke Aplikasi",
description: "Gunakan Claim Code perangkat untuk sinkronisasi dengan akun Anda.",
points: [
"Cari 'Claim Code' pada stiker casing luar alat atau layar LCD (contoh: JTA-X97AB3).",
"Di halaman Monitoring, ketuk tombol tambah (+) pada kartu 'Belum Ada Alat Terhubung'.",
"Ketikkan Claim Code perangkat Anda, lalu ketuk 'Hubungkan'. Perangkat Anda akan terikat secara permanen ke akun Anda.",
],
),
_buildStepContent(
icon: Icons.analytics_outlined,
title: "3. Memantau Kondisi Kumbung",
description: "Pahami indikator suhu & kelembapan jamur tiram Anda.",
points: [
"Setelah terhubung, nilai Suhu (°C) dan Kelembapan (%) real-time akan terus diperbarui.",
"Status warna kartu mempermudah monitoring: Hijau (Optimal), Oranye (Perhatian/Warning), Merah (Kritis/Bahaya).",
"Gunakan filter rentang waktu (Hari, Minggu, Bulan) di bawah grafik untuk memantau tren perkembangan lingkungan kumbung.",
],
),
_buildStepContent(
icon: Icons.tune,
title: "4. Mengatur Kontrol & Jadwal",
description: "Gunakan mode penyiraman otomatis atau manual sesuai kebutuhan.",
points: [
"Buka tab 'Control' di bagian bawah aplikasi untuk manajemen penyiraman.",
"Mode Otomatis: Pompa menyala otomatis ketika suhu melewati batas maksimal atau kelembapan di bawah batas minimal.",
"Mode Manual: Ketuk tombol pompa, pilih durasi waktu siram (15s s/d 5m) dan nyalakan.",
"Jadwal: Klik '+ Tambah Jadwal' untuk menentukan penyiraman terjadwal rutin secara otomatis.",
],
),
],
),
),
const Divider(height: 1),
// Actions Area
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Back Button
Opacity(
opacity: _currentStep > 0 ? 1.0 : 0.0,
child: TextButton(
onPressed: _currentStep > 0 ? _previous : null,
child: const Row(
children: [
Icon(Icons.arrow_back, size: 18),
SizedBox(width: 4),
Text(
"Kembali",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
// Next / Finish Button
SizedBox(
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
padding: const EdgeInsets.symmetric(horizontal: 24),
),
onPressed: _next,
child: Row(
children: [
Text(
_currentStep == _totalSteps - 1 ? "Selesai" : "Lanjut",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (_currentStep < _totalSteps - 1) ...[
const SizedBox(width: 4),
const Icon(Icons.arrow_forward, size: 18, color: Colors.white),
],
],
),
),
),
],
),
),
const SizedBox(height: 10),
],
),
);
}
Widget _buildStepContent({
required IconData icon,
required String title,
required String description,
required List<String> points,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.accentLight,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: AppTheme.primary, size: 30),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
description,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 20),
...points.map((point) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 4),
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppTheme.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
point,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
height: 1.45,
),
),
),
],
),
)),
],
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/routes.dart';
import '../../core/app_theme.dart';
class LandingPage extends StatefulWidget {
const LandingPage({super.key});
@ -8,115 +9,169 @@ class LandingPage extends StatefulWidget {
State<LandingPage> createState() => _LandingPageState();
}
class _LandingPageState extends State<LandingPage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeIn),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_controller.forward();
}
});
}
class _LandingPageState extends State<LandingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
final int _numPages = 4;
@override
void dispose() {
_controller.dispose();
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < _numPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
}
void _skipToLast() {
_pageController.animateToPage(
_numPages - 1,
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
/// HERO LOGO
// Top Bar with Logo & Name
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
Hero(
tag: "appLogo",
child: Image.asset(
'assets/images/logo_jamur.png',
height: 140,
width: 140,
height: 40,
width: 40,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
/// TITLE
const SizedBox(width: 10),
const Text(
"Selamat Datang di\nJamur Tiram Antirogo",
textAlign: TextAlign.center,
"JTA Antirogo",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primary,
),
),
const SizedBox(height: 16),
/// DESCRIPTION
const Text(
"Solusi IoT cerdas untuk monitoring dan kendali budidaya jamur tiram secara real-time, efisien, akurat, berbasis teknologi otomatis modern.",
textAlign: TextAlign.center,
const Spacer(),
if (_currentPage < _numPages - 1)
TextButton(
onPressed: _skipToLast,
child: const Text(
"Lewati",
style: TextStyle(
fontSize: 14,
color: Colors.black54,
height: 1.5,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const SizedBox(height: 50),
/// BUTTON SECTION (Slide Up)
SlideTransition(
position: _slideAnimation,
child: Column(
// PageView Slider Content
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (int page) {
setState(() {
_currentPage = page;
});
},
children: [
_buildOnboardingPage(
title: "Monitoring Real-Time",
description: "Pantau suhu dan kelembapan kumbung jamur secara akurat dan real-time kapan saja langsung dari smartphone Anda.",
illustration: _buildMonitoringIllustration(),
),
_buildOnboardingPage(
title: "Kontrol Cerdas",
description: "Pilih Mode Otomatis dengan batas threshold cerdas atau kendalikan penyiram secara manual dengan opsi durasi waktu.",
illustration: _buildControlIllustration(),
),
_buildOnboardingPage(
title: "Penjadwalan Presisi",
description: "Atur jadwal penyiraman otomatis pada hari dan jam tertentu untuk memastikan pertumbuhan jamur tiram yang optimal.",
illustration: _buildSchedulingIllustration(),
),
_buildOnboardingPage(
title: "Hubungkan Alat IoT",
description: "Mudah dan cepat! Cukup masukkan Claim Code perangkat Anda untuk mengaktifkan pemantauan kumbung digital.",
illustration: _buildConnectIllustration(),
),
],
),
),
/// LOGIN BUTTON
// Dot Indicators & Action Buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Dot Indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_numPages,
(index) => _buildDot(index),
),
),
const SizedBox(height: 32),
// Actions area based on page index
AnimatedCrossFade(
firstChild: SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
onPressed: _nextPage,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Lanjut",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(width: 8),
Icon(Icons.arrow_forward, color: Colors.white),
],
),
),
),
secondChild: Column(
children: [
// LOGIN BUTTON
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3E5F6F),
backgroundColor: AppTheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
@ -134,17 +189,15 @@ class _LandingPageState extends State<LandingPage>
),
),
),
const SizedBox(height: 16),
/// REGISTER BUTTON
// REGISTER BUTTON
SizedBox(
width: double.infinity,
height: 55,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFF3E5F6F),
color: AppTheme.primary,
width: 1.5,
),
shape: RoundedRectangleBorder(
@ -158,7 +211,7 @@ class _LandingPageState extends State<LandingPage>
"Belum ada akun? Daftar dulu",
style: TextStyle(
fontSize: 16,
color: Color(0xFF3E5F6F),
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
@ -166,27 +219,356 @@ class _LandingPageState extends State<LandingPage>
),
],
),
crossFadeState: _currentPage < _numPages - 1
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 300),
),
const Spacer(),
/// TERMS
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: Text(
"Dengan masuk atau mendaftar, kamu menyetujui\nKetentuan Layanan dan Kebijakan Privasi",
const SizedBox(height: 24),
// Terms and conditions disclaimer
const Text(
"Dengan masuk atau mendaftar, Anda menyetujui\nKetentuan Layanan dan Kebijakan Privasi",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontSize: 11,
color: Colors.black45,
),
),
),
],
),
),
],
),
),
);
}
Widget _buildDot(int index) {
final isActive = _currentPage == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 4),
height: 8,
width: isActive ? 24 : 8,
decoration: BoxDecoration(
color: isActive ? AppTheme.primary : AppTheme.textHint.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
);
}
Widget _buildOnboardingPage({
required String title,
required String description,
required Widget illustration,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
flex: 6,
child: Center(
child: SingleChildScrollView(
child: illustration,
),
),
),
const SizedBox(height: 20),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
Text(
description,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
height: 1.5,
),
),
const Spacer(flex: 1),
],
),
);
}
// Illustration Widgets
Widget _buildIllustrationContainer({required Widget child}) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 220),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.bgPage,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: child,
);
}
Widget _buildMonitoringIllustration() {
return _buildIllustrationContainer(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
gradient: AppTheme.suhuGradient,
borderRadius: BorderRadius.circular(16),
),
child: const Column(
children: [
Icon(Icons.thermostat, color: Colors.white, size: 28),
SizedBox(height: 4),
Text("Suhu", style: TextStyle(color: Colors.white70, fontSize: 11)),
Text("27.8°C", style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
gradient: AppTheme.humidGradient,
borderRadius: BorderRadius.circular(16),
),
child: const Column(
children: [
Icon(Icons.water_drop, color: Colors.white, size: 28),
SizedBox(height: 4),
Text("Kelembapan", style: TextStyle(color: Colors.white70, fontSize: 11)),
Text("86.5%", style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
],
),
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: AppTheme.statusNormal,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text(
"Perangkat Online - Kumbung Optimal",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
],
),
);
}
Widget _buildControlIllustration() {
return _buildIllustrationContainer(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Operation Mode Card
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
const Icon(Icons.autorenew, color: AppTheme.primary, size: 24),
const SizedBox(width: 8),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Mode Operasi", style: TextStyle(fontSize: 10, color: AppTheme.textSecondary)),
Text("Otomatis (Aktif)", style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppTheme.textPrimary)),
],
),
),
Switch(value: true, onChanged: (_) {}),
],
),
),
const SizedBox(height: 12),
// Watering Status Card
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentLight,
shape: BoxShape.circle,
),
child: const Icon(Icons.shower, color: AppTheme.accent, size: 22),
),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Pompa Penyiraman", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
Text("Menyala otomatis (30s)", style: TextStyle(fontSize: 10, color: AppTheme.textSecondary)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.accent,
borderRadius: BorderRadius.circular(12),
),
child: const Text("ON", style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
),
],
),
),
],
),
);
}
Widget _buildSchedulingIllustration() {
return _buildIllustrationContainer(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildScheduleRow("Penyiraman Pagi", "07:30", ["Sen", "Rab", "Jum"], true),
const SizedBox(height: 10),
_buildScheduleRow("Penyiraman Sore", "16:00", ["Setiap Hari"], false),
],
),
);
}
Widget _buildScheduleRow(String title, String time, List<String> days, bool isActive) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
Icon(Icons.alarm, color: isActive ? AppTheme.primary : AppTheme.textHint, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(title, style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary)),
const Spacer(),
Text(time, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppTheme.textPrimary)),
],
),
const SizedBox(height: 4),
Text(
days.join(', '),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isActive ? AppTheme.primaryLight : AppTheme.textHint,
),
),
],
),
),
const SizedBox(width: 8),
Transform.scale(
scale: 0.8,
child: Switch(value: isActive, onChanged: (_) {}),
),
],
),
);
}
Widget _buildConnectIllustration() {
return _buildIllustrationContainer(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.qr_code_scanner, size: 50, color: AppTheme.primary),
const SizedBox(height: 12),
const Text(
"Masukkan Claim Code Alat",
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primary.withOpacity(0.3), width: 1.5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"JTA - X 9 7 A B 3",
style: TextStyle(
fontFamily: 'Courier',
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.primary.withOpacity(0.8),
letterSpacing: 2,
),
),
const SizedBox(width: 8),
const Icon(Icons.check_circle, color: AppTheme.statusNormal, size: 16),
],
),
),
],
),
);
}
}

View File

@ -1,26 +1,39 @@
import '../models/user_model.dart';
import '../services/auth_service.dart';
/// Repository auth lapisan antara UI/provider dan service.
/// Semua logika bisnis auth ada di sini.
/// Repository untuk fitur Autentikasi.
/// Berfungsi sebagai lapisan abstraksi antara State Notifier (Presentation Layer) dan Klien Layanan API/Supabase (Service Layer).
/// Menangani pembersihan input (*input cleaning/trimming*) dan meneruskan panggilan ke service autentikasi yang sesuai.
class AuthRepository {
final AuthService _service;
AuthRepository(this._service);
/// Masuk log (login) ke dalam sistem menggunakan email dan password.
/// Menghapus spasi berlebih pada awal/akhir email dan password sebelum diteruskan ke API/Supabase.
Future<UserModel> login(String email, String password) {
return _service.login(email.trim(), password.trim());
}
Future<UserModel> register(String name, String email, String password, String claimCode) {
return _service.register(name.trim(), email.trim(), password.trim(), claimCode.trim());
/// Mendaftarkan (register) akun pengguna baru ke sistem.
/// Menghapus spasi berlebih pada nama, email, dan password.
Future<UserModel> register(String name, String email, String password) {
return _service.register(name.trim(), email.trim(), password.trim());
}
/// Menghubungkan akun pengguna dengan perangkat IoT ESP32 menggunakan kode klaim (*claim code*).
/// Mengembalikan String berupa `device_id` yang berhasil terhubung.
Future<String> claimDevice(String userId, String claimCode) {
return _service.claimDevice(userId, claimCode.trim());
}
/// Keluar dari akun pengguna (logout) dan menghapus token sesi.
Future<void> logout() {
return _service.logout();
}
/// Cek apakah ada sesi login yang masih aktif (untuk auto-login saat buka app)
/// Memeriksa apakah ada sesi login aktif yang masih valid di memori lokal perangkat.
/// Digunakan untuk proses Auto-Login ketika aplikasi pertama kali dibuka.
Future<UserModel?> checkSession() {
return _service.checkSession();
}

View File

@ -2,16 +2,20 @@ import '../models/sensor_model.dart';
import '../services/iot_service.dart';
import '../services/api_iot_service.dart'; // import api service untuk case khusus
/// Repository IoT lapisan antara provider dan service sensor.
/// Repository IoT lapisan jembatan antara State Notifier (Presentation Layer) dan Layanan API Hardware (Service Layer).
/// Menyediakan data sensor real-time serta agregasi riwayat sensor (per jam / harian).
class IotRepository {
final IotService _service;
IotRepository(this._service);
/// Mengambil data status sensor real-time terbaru dari hardware.
Future<SensorModel> getSensorData() {
return _service.fetchSensorData();
}
/// Mengambil data riwayat sensor mentah (history) untuk grafik.
/// Hanya dieksekusi jika tipe service yang terpasang adalah [ApiIotService].
Future<List<SensorModel>> getHistoryData() {
if (_service is ApiIotService) {
return _service.fetchHistory();
@ -19,6 +23,8 @@ class IotRepository {
return Future.value([]);
}
/// Mengambil data agregasi riwayat sensor harian (rata-rata per hari) untuk grafik bulanan.
/// [days] menentukan berapa hari ke belakang data yang akan diambil (default: 30 hari).
Future<List<Map<String, dynamic>>> getDailyHistory({int days = 30}) {
if (_service is ApiIotService) {
return _service.fetchDailyHistory(days: days);
@ -26,6 +32,8 @@ class IotRepository {
return Future.value([]);
}
/// Mengambil data agregasi riwayat sensor per jam (rata-rata per jam) untuk grafik harian.
/// [days] menentukan rentang hari ke belakang data yang diambil (default: 7 hari).
Future<List<Map<String, dynamic>>> getHourlyHistory({int days = 7}) {
if (_service is ApiIotService) {
return _service.fetchHourlyHistory(days: days);

View File

@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import '../core/api_config.dart';
/// Layanan Klien untuk mengirimkan instruksi kontrol hardware (ESP32) ke API Backend.
/// Menangani pemanggilan REST API untuk: pengaturan mode (Auto/Manual), threshold, penyiraman manual, dan penjadwalan.
class ApiControlService {
final Dio _dio;
final String deviceId;
@ -8,6 +10,7 @@ class ApiControlService {
ApiControlService(this.deviceId, [Dio? dio]) : _dio = dio ?? Dio();
// Mode Operasi
/// Mengambil mode operasi perangkat saat ini dari backend.
Future<String> getMode() async {
try {
final res = await _dio.get('${ApiConfig.baseUrl}/mode/$deviceId');
@ -20,6 +23,7 @@ class ApiControlService {
}
}
/// Mengubah mode operasi perangkat (Otomatis/Manual) di server.
Future<void> setMode(String mode) async {
try {
await _dio.post(
@ -31,6 +35,7 @@ class ApiControlService {
}
}
/// Mengambil nilai batas threshold sensor suhu dan kelembapan saat ini.
Future<Map<String, dynamic>> getThreshold() async {
try {
final res = await _dio.get('${ApiConfig.baseUrl}/threshold/$deviceId');
@ -43,6 +48,7 @@ class ApiControlService {
}
}
/// Memperbarui nilai batas threshold suhu ([tempMax]) dan kelembapan ([humMax]) di server.
Future<void> updateThreshold(double tempMax, double humMax) async {
try {
await _dio.post(
@ -58,6 +64,7 @@ class ApiControlService {
}
// Manual Control (Pompa)
/// Mengirimkan instruksi menyalakan pompa secara instan selama [durationSeconds] detik.
Future<void> siramManual(int durationSeconds) async {
try {
await _dio.post(
@ -69,6 +76,7 @@ class ApiControlService {
}
}
/// Menghentikan aktivitas pompa secara instan (mematikan pompa).
Future<void> stopPump() async {
try {
await _dio.post('${ApiConfig.baseUrl}/schedule/$deviceId/stop');
@ -78,6 +86,7 @@ class ApiControlService {
}
// Schedules (Jadwal)
/// Mengambil daftar semua jadwal penyiraman otomatis yang terdaftar untuk perangkat ini.
Future<List<dynamic>> getSchedules() async {
try {
final res = await _dio.get('${ApiConfig.baseUrl}/schedule/$deviceId');
@ -90,6 +99,8 @@ class ApiControlService {
}
}
/// Menambahkan jadwal penyiraman baru ke backend.
/// [cron] format string cron UTC, [durationSeconds] durasi siram, [label] penanda nama jadwal.
Future<Map<String, dynamic>> addSchedule(String cron, int durationSeconds, String label) async {
try {
final res = await _dio.post(
@ -109,6 +120,7 @@ class ApiControlService {
}
}
/// Menghapus jadwal tertentu berdasarkan ID.
Future<void> deleteSchedule(String id) async {
try {
await _dio.delete('${ApiConfig.baseUrl}/schedule/$id');
@ -117,6 +129,7 @@ class ApiControlService {
}
}
/// Mengaktifkan atau menonaktifkan saklar jadwal tertentu (toggle status is_active).
Future<void> toggleSchedule(String id) async {
try {
await _dio.patch('${ApiConfig.baseUrl}/schedule/$id/toggle');

View File

@ -3,7 +3,8 @@ import '../models/sensor_model.dart';
import '../core/api_config.dart';
import 'iot_service.dart';
/// Implementasi IotService yang terhubung ke Backend Express
/// Implementasi [IotService] yang berkomunikasi ke Backend Express melalui REST API.
/// Menangani pemrosesan data mentah sensor, pemfilteran nilai kosong (null), serta riwayat grafik.
class ApiIotService implements IotService {
final Dio _dio;
final String deviceId;
@ -13,7 +14,8 @@ class ApiIotService implements IotService {
@override
Future<SensorModel> fetchSensorData() async {
try {
// Ambil beberapa data terbaru agar bisa skip entry yang null (misal dari relay log)
// Ambil 30 data riwayat terbaru dari backend.
// Hal ini dilakukan agar kita bisa memfilter dan melewati data log yang nilainya null (misalnya log perubahan relay saja).
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId?limit=30');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
@ -22,7 +24,8 @@ class ApiIotService implements IotService {
}
final firstItem = data.first;
// Cari entry pertama yang punya nilai sensor valid (bukan null)
// Mencari entri pertama yang memiliki nilai sensor (suhu & kelembapan) yang valid (bukan null)
final validItem = data.firstWhere(
(item) => item['temperature'] != null && item['humidity'] != null,
orElse: () => null,
@ -37,7 +40,7 @@ class ApiIotService implements IotService {
timestamp: DateTime.parse(firstItem['created_at']).toLocal(),
);
} else {
// Belum ada data sensor valid kembalikan placeholder tapi relay state tetap valid
// Jika belum ada data sensor yang valid, kembalikan nilai placeholder (0) namun status relay & mode tetap valid
return SensorModel(
temperature: 0,
humidity: 0,
@ -54,18 +57,21 @@ class ApiIotService implements IotService {
}
}
// Fungsi tambahan untuk mengambil histori secara lengkap
/// Mengambil data histori sensor secara lengkap untuk kebutuhan pembuatan grafik real-time.
/// [limit] menentukan jumlah maksimal entri data yang diambil.
Future<List<SensorModel>> fetchHistory({int limit = 100}) async {
try {
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId?limit=$limit');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
// Data dari API diurutkan DESC (terbaru di atas),
// kita reverse agar berurutan ASC (terlama ke terbaru) untuk grafik.
// Skip entry yang temperature/humidity-nya null (misal dari relay log)
// kita balik urutannya (reverse) agar berurutan ASC (terlama ke terbaru) untuk penggambaran grafik.
// Lewati entri yang nilai suhu/kelembapannya null.
final validData = data.where(
(item) => item['temperature'] != null && item['humidity'] != null,
).toList();
return validData.reversed.map((item) => SensorModel(
temperature: (item['temperature'] as num).toDouble(),
humidity: (item['humidity'] as num).toDouble(),
@ -81,7 +87,8 @@ class ApiIotService implements IotService {
}
}
// Fungsi untuk mengambil agregasi harian
/// Mengambil riwayat data agregasi harian (rata-rata harian) untuk grafik bulanan.
/// [days] menentukan berapa hari data ke belakang yang akan diambil.
Future<List<Map<String, dynamic>>> fetchDailyHistory({int days = 30}) async {
try {
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId/daily?days=$days');
@ -96,7 +103,8 @@ class ApiIotService implements IotService {
}
}
// Fungsi untuk mengambil agregasi per jam
/// Mengambil riwayat data agregasi per jam (rata-rata per jam) untuk grafik harian.
/// [days] menentukan rentang hari ke belakang data yang akan diambil.
Future<List<Map<String, dynamic>>> fetchHourlyHistory({int days = 7}) async {
try {
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId/hourly?days=$days');

View File

@ -1,10 +1,20 @@
import '../models/user_model.dart';
/// Abstract contract untuk auth service.
/// Swap implementasi ini ke SupabaseAuthService / ApiAuthService kapanpun siap.
/// Kontrak/Interface abstrak untuk Layanan Autentikasi.
/// Mendefinisikan method standar yang wajib diimplementasikan oleh penyedia layanan autentikasi (misal: Supabase, Firebase, atau Custom API).
abstract class AuthService {
/// Melakukan login menggunakan email dan password. Mengembalikan data profil user [UserModel].
Future<UserModel> login(String email, String password);
Future<UserModel> register(String name, String email, String password, String claimCode);
/// Mendaftarkan akun baru dengan Nama, Email, dan Password.
Future<UserModel> register(String name, String email, String password);
/// Menghubungkan perangkat keras ESP32 dengan akun user berdasarkan kode klaim.
Future<String> claimDevice(String userId, String claimCode);
/// Keluar dari sesi login saat ini dan membersihkan token autentikasi.
Future<void> logout();
Future<UserModel?> checkSession(); // Cek sesi yang tersimpan saat app dibuka
/// Memeriksa status sesi yang tersimpan di lokal perangkat saat aplikasi pertama kali dibuka.
Future<UserModel?> checkSession();
}

View File

@ -1,6 +1,8 @@
import '../models/sensor_model.dart';
/// Abstract contract untuk IoT service.
/// Kontrak/Interface abstrak untuk Layanan Sensor IoT.
/// Mendefinisikan method standar untuk pengambilan data sensor dari hardware/backend.
abstract class IotService {
/// Mengambil data sensor (suhu, kelembapan, relay, mode) terbaru.
Future<SensorModel> fetchSensorData();
}

View File

@ -5,7 +5,8 @@ import '../models/user_model.dart';
import '../core/api_config.dart';
import 'auth_service.dart';
/// Implementasi AuthService menggunakan Supabase
/// Implementasi [AuthService] menggunakan layanan **Supabase Auth** untuk autentikasi user
/// dan **SharedPreferences** untuk menyimpan cache lokal data `device_id` IoT.
class SupabaseAuthService implements AuthService {
final supabase.SupabaseClient _client = supabase.Supabase.instance.client;
final Dio _dio = Dio();
@ -13,6 +14,7 @@ class SupabaseAuthService implements AuthService {
@override
Future<UserModel> login(String email, String password) async {
try {
// 1. Melakukan sign in ke Supabase Auth
final response = await _client.auth.signInWithPassword(
email: email,
password: password,
@ -23,7 +25,7 @@ class SupabaseAuthService implements AuthService {
throw Exception('Login gagal: User tidak ditemukan');
}
// Ambil device_id dari backend
// 2. Mengambil informasi device_id yang terhubung ke pengguna dari database backend
String? deviceId;
try {
final deviceRes = await _dio.get('${ApiConfig.baseUrl}/device/my-device/${user.id}');
@ -32,10 +34,10 @@ class SupabaseAuthService implements AuthService {
}
} catch (e) {
print('Gagal mengambil info device: $e');
// Tidak melempar error agar login tetap sukses meskipun belum ada device
// Tidak melempar error agar login tetap sukses meskipun user belum menghubungkan device
}
// Simpan device_id ke cache lokal agar auto-login selanjutnya super cepat
// 3. Menyimpan device_id ke cache lokal (SharedPreferences) agar auto-login selanjutnya lebih cepat dan offline-safe
if (deviceId != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('cached_device_id', deviceId);
@ -49,6 +51,7 @@ class SupabaseAuthService implements AuthService {
deviceId: deviceId,
);
} on supabase.AuthException catch (e) {
// Pemetaan pesan error dari Supabase ke bahasa Indonesia yang ramah pengguna
if (e.message.contains('Invalid login credentials')) {
throw Exception('Email atau password salah.');
} else if (e.message.contains('Email not confirmed')) {
@ -61,8 +64,9 @@ class SupabaseAuthService implements AuthService {
}
@override
Future<UserModel> register(String name, String email, String password, String claimCode) async {
Future<UserModel> register(String name, String email, String password) async {
try {
// Melakukan pendaftaran akun ke Supabase Auth beserta meta data nama lengkap
final response = await _client.auth.signUp(
email: email,
password: password,
@ -74,35 +78,12 @@ class SupabaseAuthService implements AuthService {
throw Exception('Registrasi gagal');
}
// Klaim device setelah berhasil mendaftar ke Supabase
String? deviceId;
try {
final claimRes = await _dio.post('${ApiConfig.baseUrl}/device/claim', data: {
'claim_code': claimCode,
'user_id': user.id,
});
if (claimRes.statusCode == 200) {
deviceId = claimRes.data['device']['device_id'];
// Simpan device_id ke cache lokal
final prefs = await SharedPreferences.getInstance();
await prefs.setString('cached_device_id', deviceId!);
}
} catch (e) {
// Jika gagal klaim
String errorMsg = 'Gagal klaim perangkat.';
if (e is DioException && e.response?.data != null) {
errorMsg = e.response?.data['message'] ?? errorMsg;
}
throw Exception('Akun terbuat, tapi $errorMsg');
}
return UserModel(
id: user.id,
name: name,
email: email,
token: response.session?.accessToken ?? '',
deviceId: deviceId,
deviceId: null,
);
} on supabase.AuthException catch (e) {
if (e.message.contains('already registered')) {
@ -116,23 +97,52 @@ class SupabaseAuthService implements AuthService {
}
}
@override
Future<String> claimDevice(String userId, String claimCode) async {
try {
// Mengirim request klaim alat ke REST API backend
final claimRes = await _dio.post('${ApiConfig.baseUrl}/device/claim', data: {
'claim_code': claimCode.trim(),
'user_id': userId,
});
if (claimRes.statusCode == 200) {
final deviceId = claimRes.data['device']['device_id'] as String;
// Menyimpan ID perangkat yang berhasil diklaim ke SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString('cached_device_id', deviceId);
return deviceId;
} else {
throw Exception('Gagal klaim perangkat: ${claimRes.statusCode}');
}
} catch (e) {
String errorMsg = 'Gagal klaim perangkat.';
if (e is DioException && e.response?.data != null) {
errorMsg = e.response?.data['message'] ?? errorMsg;
}
throw Exception(errorMsg);
}
}
@override
Future<void> logout() async {
// Hapus sesi Supabase
// 1. Membersihkan sesi Supabase Auth di server
await _client.auth.signOut();
// Hapus cache device_id agar tidak terbawa jika user login pakai akun lain
// 2. Menghapus cache lokal device_id agar bersih saat login pengguna lain
final prefs = await SharedPreferences.getInstance();
await prefs.remove('cached_device_id');
}
// Fungsi tambahan untuk memulihkan sesi
/// Memulihkan sesi login yang tersimpan di internal storage untuk Auto-Login.
/// Mengambil data user dari sesi aktif Supabase, dan device_id dari SharedPreferences.
@override
Future<UserModel?> checkSession() async {
final session = _client.auth.currentSession;
if (session != null) {
final user = session.user;
// Ambil device_id langsung dari cache lokal (cepat & offline-safe)
// Mengambil device_id dari cache lokal (cepat & aman saat offline)
String? deviceId;
try {
final prefs = await SharedPreferences.getInstance();

130
rencana.md Normal file
View File

@ -0,0 +1,130 @@
# Rencana Implementasi: Revisi Aplikasi JTA
Dokumen ini memuat rencana kerja untuk 5 revisi utama pada aplikasi monitoring kumbung jamur JTA, berdasarkan masukan dosen pembimbing.
---
## Ringkasan Revisi
| # | Revisi | Pendekatan Singkat |
|---|---|---|
| 1 | Interior 3D lumbung + posisi sensor | Isometrik 2.5D via `CustomPainter` (bukan engine 3D) |
| 2 | Heatmap suhu & kelembapan | Radial gradient overlay di atas peta isometrik |
| 3 | Dashboard referensi budidaya | Panel edukasi statis di halaman peta/monitoring |
| 4 | Pengukuran debit air | Field baru di `SensorModel` + kartu dashboard |
| 5 | Rentang batas suhu (min-max) | `RangeSlider` menggantikan slider tunggal |
---
## 1. Interior 3D Lumbung & Posisi Sensor
**Pendekatan:** Isometrik 2.5D dengan `CustomPainter` + transformasi matriks — menghindari library 3D berat (Unity/OpenGL) yang sebelumnya membuat APK membengkak.
**Visual:** Denah box transparan, rak baglog sebagai balok 3D, koridor, dan pin sensor melayang dengan animasi pulsing glow menunjukkan posisi di dalam rak.
**Arsitektur painter — dipisah 3 layer agar performa terjaga:**
| Layer | Isi | Repaint saat |
|---|---|---|
| Struktur statis | Grid lantai, dinding, balok rak | Kamera/zoom berubah (bungkus `RepaintBoundary`) |
| Heatmap overlay | Radial gradient per sensor | Data sensor update / toggle suhu-kelembapan |
| Pin sensor + animasi | Pin melayang, pulsing glow, tag pembacaan | Tiap frame animasi (`AnimationController`) |
Layer paling murah (pin) yang repaint terus-menerus, bukan seluruh scene rak — ini krusial supaya animasi pulsing tidak memicu redraw grid/rak 60x/detik.
**Perlu diputuskan sebelum coding UI:** posisi sensor (X, Y, Z, radius) disimpan di backend (persist antar device) atau local config saja?
---
## 2. Heatmap Suhu & Kelembapan
**Pendekatan:** Lapisan radial gradient semi-transparan di sekitar posisi sensor pada peta isometrik.
**Skala warna:**
- Suhu: Biru (dingin) → Hijau (optimal) → Merah/Oranye (panas)
- Kelembapan: Kuning/Oranye (kering) → Hijau (cukup) → Biru tua/Sian (basah)
Toggle UI untuk berpindah mode.
**Perhatian teknis:** jika area coverage antar sensor overlap, perlu logika blending (ambil nilai maksimum atau rata-rata terbobot di titik pertemuan) agar transisi warna halus — bukan sekadar menumpuk gradient satu-satu.
---
## 3. Dashboard Referensi Budidaya Jamur
**Pendekatan:** Panel edukasi di bawah peta kumbung / halaman monitoring utama.
**Isi:**
- Rentang ideal: suhu 22°C28°C, kelembapan 80%90%
- Waktu penyiraman terbaik: pagi 06:0008:00, sore 16:0018:00
**Catatan:** nilai-nilai ini sebaiknya dibuat dapat dikonfigurasi (backend/local settings), bukan hardcode di UI — supaya mudah diubah jika jenis jamur atau rekomendasi berubah nanti.
---
## 4. Pengukuran Debit Air
**Pendekatan:** Menambahkan `waterFlow` (L/menit) dan `waterVolume` (Liter) ke `SensorModel` dan `ApiIotService`, terintegrasi dengan flow meter ESP32.
**UI:** Kartu status "Debit Air" bergradien biru di dashboard utama.
**Prasyarat:** pastikan flow meter sudah terpasang dan datanya terbaca di firmware ESP32 — tanpa ini, verifikasi hanya bisa dengan data mock, belum data sensor asli.
---
## 5. Rentang Batas Suhu (Min-Max)
**Pendekatan:** Slider tunggal (`suhuLimit`) → `RangeSlider` dengan batas minimum & maksimum (contoh: 22°C28°C). Status/otomatisasi penyiraman berubah warna (kuning/merah) jika suhu keluar rentang.
**Dependensi penting — bukan cuma perubahan UI:**
- Endpoint API/backend harus disesuaikan untuk menerima & menyimpan dua nilai (`suhuMin`, `suhuMax`), bukan satu.
- Perlu strategi migrasi untuk data lama yang masih pakai `suhuLimit` tunggal (misal fallback: `suhuMinLimit = suhuLimit - offset`).
- Validasi `RangeSlider`: batas bawah tidak boleh melebihi batas atas, termasuk saat data dari API ternyata invalid/null.
---
## Proposed Changes
### Data Model & Network Layer
| File | Perubahan |
|---|---|
| `[MODIFY]` `sensor_model.dart` | + `waterFlow` (double, L/min), `waterVolume` (double, L) |
| `[MODIFY]` `control_state.dart` | + `suhuMinLimit`, `suhuMaxLimit` (menggantikan `suhuLimit`) |
| `[MODIFY]` `control_notifier.dart` | `saveThreshold` menerima `suhuMin` & `suhuMax`; sinkronisasi parsing respon API |
### UI Components
| File | Perubahan |
|---|---|
| `[NEW]` `kumbung_map_page.dart` | Peta isometrik, toggle heatmap, panel referensi, pengaturan koordinat sensor |
| `[NEW]` `isometric_kumbung_painter.dart` | 3 `CustomPainter` terpisah: struktur statis, heatmap, pin sensor (lihat §1) |
| `[NEW]` `isometric_transform.dart` | Utility konversi koordinat world (X,Y,Z) → screen 2D — dipakai oleh ketiga painter di atas |
| `[MODIFY]` `sensor_limit_card.dart` | Slider suhu tunggal → `RangeSlider` |
| `[MODIFY]` `home_page.dart` | + Kartu "Debit Air", info waktu penyiraman otomatis |
| `[MODIFY]` `main_page.dart` | Aktifkan tab `Peta` (`KumbungMapPage`) di `BottomNavigationBar` |
---
## Urutan Eksekusi yang Disarankan
1. **Model & state** (`sensor_model.dart`, `control_state.dart`, `control_notifier.dart`) — koordinasikan dulu kontrak data dengan backend, terutama untuk poin 4 & 5.
2. **`isometric_transform.dart`** — fondasi matematika, harus benar sebelum painter apa pun dibangun di atasnya.
3. **3 layer painter** (§1) → gabung di `kumbung_map_page.dart`.
4. **UI pendukung**: `sensor_limit_card.dart`, `home_page.dart`, `main_page.dart`.
Membangun UI di atas kontrak data yang masih berubah-ubah berisiko menyebabkan rework.
---
## Verification Plan
### Automated Tests
- `flutter analyze` — memastikan tidak ada warning/error.
- Build APK lokal — memastikan kompilasi berhasil.
- **Tambahan disarankan:** unit test untuk `isometric_transform.dart` (bug transformasi koordinat sulit dilacak dari tampilan visual saja), widget test untuk validasi `RangeSlider` (batas min < max).
### Manual Verification
- **Peta 3D:** ubah koordinat sensor via pengaturan, toggle heatmap on/off, pastikan pin berpindah sesuai posisi baru.
- **Rentang suhu:** geser `RangeSlider` min & maks, simpan, verifikasi indikator status berubah dinamis di dashboard.
- **Debit air:** simulasikan nilai via data mock/sensor, verifikasi angka & indikator visual pada kartu dashboard.