wip: simpan perubahan sebelum revisi heatmap dan sensor
|
|
@ -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"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 737 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 737 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 553 KiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 7.4 MiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 553 KiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 7.4 MiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 878 KiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 9.1 MiB |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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';
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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°C–28°C, kelembapan 80%–90%
|
||||
- Waktu penyiraman terbaik: pagi 06:00–08:00, sore 16:00–18: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°C–28°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.
|
||||