Initial commit

This commit is contained in:
bimabuana 2026-05-21 04:34:12 +07:00
commit a06195f75a
95 changed files with 6803 additions and 0 deletions

93
.gitignore vendored Normal file
View File

@ -0,0 +1,93 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.flutter-plugins
.packages
.pub-cache/
.pub/
/build/
# Web related
.web/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Android signing keys and properties
*.keystore
*.jks
key.properties
# iOS related
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/flutter_export_environment.sh
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Runner/GeneratedPluginRegistrant.[mh]
**/ios/Runner/GeneratedPluginRegistrant.swift
**/ios/Runner/sdk_info.json
**/ios/Runner/Assets.xcassets/AppIcon.appiconset/
**/ios/.symlinks/
**/ios/Pods/
**/ios/Podfile.lock
# macOS related
**/macos/Flutter/ephemeral/
**/macos/Flutter/GeneratedPluginRegistrant.swift
**/macos/Flutter/flutter_export_environment.sh
**/macos/Runner/GeneratedPluginRegistrant.swift
**/macos/Runner/sdk_info.json
**/macos/.symlinks/
**/macos/Pods/
**/macos/Podfile.lock
# Windows related
**/windows/flutter/ephemeral/
**/windows/flutter/GeneratedPluginRegistrant.cc
**/windows/flutter/generated_plugin_registrant.h
**/windows/flutter/generated_plugins.cmake
# Linux related
**/linux/flutter/ephemeral/
**/linux/flutter/generated_plugin_registrant.cc
**/linux/flutter/generated_plugin_registrant.h
**/linux/flutter/generated_plugins.cmake
# Environment variables & secrets
.env
.env.*
lib/core/env.dart

30
.metadata Normal file
View File

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "c6f67dede3d4aa1aa7a69dd56a3494a5cde6cc80"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: c6f67dede3d4aa1aa7a69dd56a3494a5cde6cc80
base_revision: c6f67dede3d4aa1aa7a69dd56a3494a5cde6cc80
- platform: android
create_revision: c6f67dede3d4aa1aa7a69dd56a3494a5cde6cc80
base_revision: c6f67dede3d4aa1aa7a69dd56a3494a5cde6cc80
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

95
README.md Normal file
View File

@ -0,0 +1,95 @@
# 🍄 JTA Jamur Tiram Antirogo (Aplikasi Mobile IoT)
Aplikasi mobile **JTA (Jamur Tiram Antirogo)** merupakan komponen antarmuka (*frontend*) dalam ekosistem IoT budidaya jamur tiram. Aplikasi ini memungkinkan pengguna untuk memantau kondisi lingkungan (suhu dan kelembapan) serta mengontrol perangkat keras penyiram (sprayer) secara near real-time.
---
## 🌟 Fitur Utama
* **Near Real-Time Monitoring:** Pemantauan suhu dan kelembapan kumbung jamur secara berkala (polling otomatis setiap 5 detik) dilengkapi grafik historis harian dan per jam.
* **IoT Remote Control:** Pengaturan mode operasi (Otomatis / Manual) dan tombol penyiraman instan.
* **Threshold Settings:** Batasi rentang suhu dan kelembapan ideal untuk mengaktifkan penyiraman otomatis.
* **Irrigation Scheduling:** Penjadwalan penyiraman otomatis berdasarkan waktu/jam tertentu.
* **Secure Authentication:** Keamanan akun pengguna (login/register) serta persistensi sesi menggunakan layanan **Supabase Auth**.
* **Push Notifications:** Notifikasi status perangkat atau peringatan kondisi kumbung via **OneSignal**.
---
## 🏗️ Arsitektur Sistem
Aplikasi dikembangkan menggunakan **Layered Architecture** dengan pembagian tanggung jawab sebagai berikut:
1. **Presentation Layer:** Antarmuka pengguna (UI) reaktif (`MonitoringPage`, `ControlPage`, `SchedulePage`, dsb.).
2. **State Management (Riverpod):** Manajemen state terpusat (`MonitoringNotifier`, `ControlNotifier`, `AuthNotifier`).
3. **Repository Layer:** Abstraksi akses data (`IotRepository`, `AuthRepository`).
4. **Service Layer:** Jaringan komunikasi HTTP menggunakan **Dio** (`ApiIotService`, `ApiControlService`) dan **Supabase Flutter SDK** (`SupabaseAuthService`).
---
## 🛠️ Teknologi & Dependensi
* **Framework:** Flutter (Dart)
* **State Management:** Flutter Riverpod & StateNotifier
* **Database & Auth:** Supabase Flutter
* **HTTP Client:** Dio
* **Notification Service:** OneSignal Flutter
* **Chart Visuals:** Fl Chart / Grafik Kustom
---
## 🚀 Langkah Persiapan & Cara Menjalankan Proyek
Ikuti langkah-langkah di bawah ini untuk mengkloning dan menjalankan proyek ini di lingkungan lokal Anda.
### 1. Kloning Repositori
Jalankan perintah berikut di terminal komputer Anda:
```bash
git clone <URL_REPOSITORI_GITHUB_ANDA>
cd JTA
```
### 2. Konfigurasi Lingkungan Lokal (PENTING 🔑)
Untuk keamanan kredensial, berkas asli `env.dart` tidak diunggah ke GitHub (sudah terdaftar di `.gitignore`). Anda harus membuat berkas tersebut secara manual menggunakan template contoh:
1. Salin berkas template [**`lib/core/env.example.dart`**](file:///c:/project/JTA/lib/core/env.example.dart) dan buat berkas baru dengan nama [**`lib/core/env.dart`**](file:///c:/project/JTA/lib/core/env.dart).
2. Buka berkas [**`lib/core/env.dart`**](file:///c:/project/JTA/lib/core/env.dart), kemudian **hilangkan komentar** pada kelas `Env` dan isi dengan kredensial proyek Supabase & OneSignal Anda sendiri:
```dart
class Env {
// --- BASE API URL ---
static const String baseUrl = 'https://api.jta.web.id/api'; // Ganti jika memakai emulator/WiFi lokal
// --- SUPABASE CONFIGURATION ---
static const String supabaseUrl = 'https://YOUR_SUPABASE_PROJECT.supabase.co';
static const String supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY';
// --- ONESIGNAL CONFIGURATION ---
static const String oneSignalAppId = 'YOUR_ONESIGNAL_APP_ID';
}
```
### 3. Install Dependensi
Jalankan perintah berikut untuk mengunduh semua paket dependensi Flutter yang dibutuhkan:
```bash
flutter pub get
```
### 4. Jalankan Aplikasi
Hubungkan ponsel asli (aktifkan USB Debugging) atau jalankan emulator Android/iOS Anda, kemudian ketik:
```bash
flutter run
```
---
## 📁 Struktur Direktori Utama
```text
lib/
├── core/ # Tema aplikasi, rute navigasi, dan konfigurasi Env
├── features/ # Modul fitur utama (auth, monitoring, control, schedule)
│ ├── auth/
│ ├── monitoring/
│ ├── control/
│ └── schedule/
├── services/ # Akses layanan eksternal (Supabase Auth & API REST)
└── main.dart # Titik masuk utama (Entry point) aplikasi
```

28
analysis_options.yaml Normal file
View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.jta"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.jta"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="JTA"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.example.jta
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#FFFFFF</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#FFFFFF</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#FFFFFF</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#FFFFFF</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

BIN
assets/images/jamur.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -0,0 +1,90 @@
# 4.1.4 Arsitektur dan Integrasi Sistem
---
## Paragraf 1 — Gambaran Arsitektur Sistem
Aplikasi JTA merupakan komponen antarmuka (*frontend*) dalam sebuah ekosistem IoT yang terdiri dari empat lapisan utama: perangkat keras sensor (ESP32 + DHT22), server backend (Node.js di Railway), layanan database dan autentikasi (Supabase), serta aplikasi mobile Flutter sebagai ujung akses pengguna. Data sensor mengalir dari ESP32 melalui protokol MQTT menuju backend, kemudian disimpan ke database Supabase. Aplikasi Flutter mengambil data tersebut melalui REST API dan menampilkannya secara near real-time kepada pengguna. Lingkup pengembangan pada laporan ini berfokus pada **aplikasi mobile Flutter** sebagai komponen antarmuka sistem.
![Gambaran Umum Arsitektur Sistem](C:/Users/user/.gemini/antigravity/brain/f5437a05-a1d5-4e3b-aa37-20e3f692fed9/diagram_arsitektur_overview_1778563146372.png)
**Gambar 4.x** — Gambaran umum arsitektur sistem JTA. Lingkup laporan ini adalah lapisan Aplikasi Flutter (paling kanan).
---
## Paragraf 2 — Arsitektur Internal Aplikasi Flutter
Aplikasi Flutter dikembangkan menggunakan pola arsitektur berlapis (*layered architecture*) yang memisahkan tanggung jawab antara tampilan, logika bisnis, dan akses data. Terdapat lima lapisan yang disusun secara hierarkis:
1. **Presentation Layer** — halaman dan widget yang ditampilkan ke pengguna (`MonitoringPage`, `ControlPage`, `SchedulePage`, dsb.)
2. **State Management (Riverpod)** — mengelola state aplikasi secara reaktif melalui `MonitoringNotifier`, `ControlNotifier`, dan `AuthNotifier`
3. **Repository Layer** — abstraksi antara notifier dan sumber data, diimplementasikan oleh `IotRepository` dan `AuthRepository`
4. **Service Layer** — bertanggung jawab atas komunikasi jaringan: `ApiIotService` untuk data sensor, `ApiControlService` untuk perintah kontrol, dan `AuthService` untuk autentikasi
5. **External Services** — REST API backend (Railway) dan Supabase (autentikasi + database)
![Arsitektur Internal Aplikasi Flutter](C:/Users/user/.gemini/antigravity/brain/f5437a05-a1d5-4e3b-aa37-20e3f692fed9/diagram_layer_flutter_1778563225044.png)
**Gambar 4.x** — Arsitektur internal aplikasi Flutter (JTA) dengan lima lapisan terpisah.
Pemisahan lapisan ini memudahkan pengujian dan penggantian implementasi—misalnya, `IotRepository` dapat menerima implementasi `ApiIotService` atau implementasi mock untuk keperluan pengujian tanpa mengubah kode di lapisan atas.
---
## Paragraf 3 — Koneksi dan Konfigurasi Layanan Eksternal
Aplikasi menggunakan dua layanan eksternal: **REST API backend** dan **Supabase**. Konfigurasi base URL API terpusat pada kelas `ApiConfig`, sehingga pergantian environment (lokal, staging, production) cukup dilakukan di satu titik:
```dart
// lib/core/api_config.dart
class ApiConfig {
static const String baseUrl =
'https://backendjamur-production.up.railway.app/api';
}
```
Seluruh request HTTP menggunakan package **`dio` (v5.4.0)** yang diinstansiasi per service. Endpoint yang dikonsumsi aplikasi meliputi:
| Endpoint | Metode | Fungsi |
|---|---|---|
| `/history/{deviceId}` | GET | Ambil data sensor terbaru & histori |
| `/history/{deviceId}/daily` | GET | Rata-rata sensor per hari |
| `/history/{deviceId}/hourly` | GET | Rata-rata sensor per jam |
| `/mode/{deviceId}` | GET / POST | Baca & ubah mode operasi |
| `/threshold/{deviceId}` | GET / POST | Baca & ubah batas suhu/kelembapan |
| `/schedule/{deviceId}` | GET / POST | Kelola jadwal irigasi |
| `/schedule/{deviceId}/now` | POST | Siram manual langsung |
Koneksi ke **Supabase** diinisialisasi satu kali saat aplikasi pertama dijalankan menggunakan package **`supabase_flutter` (v2.8.0)**. Supabase dimanfaatkan khusus untuk modul **autentikasi**—login, registrasi, dan persistensi sesi berbasis JWT—sementara akses data sensor sepenuhnya melalui REST API backend.
```dart
// lib/main.dart
await Supabase.initialize(
url: 'https://tqibzvranbroouzdwhhh.supabase.co',
anonKey: '<anon_key>',
);
```
Sesi login pengguna disimpan secara persisten oleh `supabase_flutter` sehingga pengguna tidak perlu login ulang setiap kali membuka aplikasi. Data `device_id` yang terikat ke akun pengguna diambil dari sesi ini dan digunakan sebagai parameter di setiap request API.
---
## Paragraf 4 — Mekanisme Pembaruan Data Sensor
Data sensor diperbarui secara near real-time melalui mekanisme **polling periodik** yang dikelola oleh `MonitoringNotifier`. Saat halaman monitoring dibuka, notifier melakukan inisialisasi awal dengan mengambil histori penuh dari API, kemudian mengaktifkan timer yang memanggil ulang API setiap **5 detik**. Hasilnya dikomparasi dengan data terakhir berdasarkan timestamp—hanya data baru yang ditambahkan ke state, sehingga grafik historis tidak memuat duplikat.
```dart
// Inisialisasi dan polling otomatis
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchData());
```
Setiap respons API memperbarui `MonitoringState` melalui `state.copyWith()`, yang secara otomatis memicu rebuild hanya pada widget yang bergantung pada state tersebut (reaktivitas Riverpod). State juga menyertakan sinkronisasi status relay dan mode operasi ke `ControlNotifier` agar seluruh halaman aplikasi mencerminkan kondisi perangkat yang sesungguhnya.
![Mekanisme Polling Data Sensor](C:/Users/user/.gemini/antigravity/brain/f5437a05-a1d5-4e3b-aa37-20e3f692fed9/diagram_polling_flow_1778563276653.png)
**Gambar 4.x** — Alur pembaruan data sensor: dari inisialisasi hingga polling periodik 5 detik.
---
## Paragraf 5 — Penutup Sub-bab 4.1
Seluruh komponen aplikasi mobile JTA—mulai dari konfigurasi koneksi, arsitektur berlapis, manajemen state, hingga mekanisme pembaruan data—telah berhasil dikembangkan dan diintegrasikan dengan layanan backend dan Supabase. Pemisahan lapisan yang jelas antara presentation, state management, repository, dan service menjadikan aplikasi mudah dipelihara dan dikembangkan lebih lanjut. Pembahasan selanjutnya pada **Sub-bab 4.2 Implementasi Sistem** akan merinci tampilan antarmuka dan hasil implementasi fitur-fitur aplikasi secara keseluruhan.

View File

@ -0,0 +1,78 @@
# 4.2.1 Implementasi pada Perangkat Android
---
## Paragraf 1 — Proses Build dan Distribusi APK
Aplikasi JTA dikompilasi menjadi file APK (*Android Package*) menggunakan Flutter versi **3.38.10** (channel stable, Dart SDK 3.10.9). Proses build dilakukan menggunakan perintah berikut di direktori root proyek:
```bash
flutter build apk --release
```
Build **release** dipilih dibanding debug karena menghasilkan binary yang telah melalui proses *tree-shaking*, *code minification*, dan optimasi AOT (*Ahead-of-Time*) compilation — sehingga performa aplikasi lebih baik dan ukuran file lebih kecil. Sebagai pembanding, build debug menyertakan *debug symbols* dan *observatory* yang tidak diperlukan di lingkungan produksi. Proses kompilasi memerlukan waktu sekitar **85 detik** pada mesin pengembang melalui Gradle task `assembleRelease`.
File APK yang dihasilkan adalah `app-release.apk` dengan ukuran **56,67 MB**. Ukuran ini dipengaruhi oleh Flutter engine, asset gambar, dan package yang digunakan (terutama `supabase_flutter` dan `onesignal_flutter`). Distribusi APK ke mitra dilakukan melalui **Google Drive** dengan cara membagikan tautan unduhan langsung, sehingga mitra dapat mengunduh dan menginstal APK secara mandiri tanpa memerlukan akses ke Google Play Store. `[DUMMY — ganti sesuai cara distribusi aktual yang digunakan]`
---
## Paragraf 2 — Spesifikasi Minimum Perangkat
Aplikasi JTA dikonfigurasi dengan nilai `minSdk` mengikuti nilai default Flutter (`flutter.minSdkVersion`), yang secara bawaan bernilai **21**, setara dengan **Android 5.0 (Lollipop)**. Dengan demikian, aplikasi dapat berjalan pada mayoritas perangkat Android yang beredar di pasaran saat ini. Berikut ringkasan persyaratan teknis minimum:
| Parameter | Nilai Minimum |
|---|---|
| Versi Android | Android 5.0 (API Level 21 / Lollipop) |
| RAM | 2 GB (disarankan 3 GB untuk performa optimal) |
| Penyimpanan kosong | ± 100 MB (untuk instalasi + cache data) |
| Koneksi internet | Wajib — WiFi atau data seluler (untuk polling sensor & push notifikasi) |
| Resolusi layar | 360 × 640 dp ke atas |
Izin (*permission*) yang diminta aplikasi saat instalasi dan pertama kali dijalankan:
| Izin | Keterangan |
|---|---|
| `INTERNET` | Dideklarasikan di `AndroidManifest.xml`; wajib untuk seluruh komunikasi API dan Supabase |
| `POST_NOTIFICATIONS` | Diminta secara runtime oleh OneSignal untuk pengiriman push notifikasi peringatan sensor |
| `RECEIVE_BOOT_COMPLETED` | Digunakan OneSignal untuk memulihkan registrasi notifikasi setelah perangkat restart |
Aplikasi tidak meminta izin akses kamera, lokasi, maupun penyimpanan eksternal, sehingga profil izin yang dibutuhkan tergolong minimal dan tidak menimbulkan kekhawatiran privasi yang signifikan bagi pengguna.
---
## Paragraf 3 — Spesifikasi Perangkat yang Digunakan Saat Implementasi
Pengujian dan implementasi aplikasi dilakukan pada perangkat Android milik mitra budidaya jamur tiram sebagai pengguna akhir. Berikut spesifikasi perangkat yang digunakan:
| Spesifikasi | Detail |
|---|---|
| Merek & Model | `[DUMMY — isi merek dan model HP mitra]` |
| Sistem Operasi | `[DUMMY — misal: Android 12 (API 31)]` |
| Prosesor | `[DUMMY — misal: Mediatek Helio G85, Octa-core 2.0 GHz]` |
| RAM | `[DUMMY — misal: 4 GB]` |
| Penyimpanan Internal | `[DUMMY — misal: 64 GB]` |
| Koneksi yang Digunakan | `[DUMMY — misal: WiFi 2.4 GHz / Data seluler 4G LTE]` |
| Resolusi Layar | `[DUMMY — misal: 1080 × 2408 piksel, 6.5 inci]` |
> **Catatan:** Baris bertanda `[DUMMY]` harus diisi dengan data aktual spesifikasi perangkat mitra sebelum laporan diserahkan.
---
## Paragraf 4 — Proses Instalasi
Karena APK didistribusikan di luar Google Play Store, instalasi memerlukan satu langkah konfigurasi tambahan pada perangkat Android mitra. Secara keseluruhan, proses instalasi terdiri dari empat tahap berikut:
![Alur Instalasi APK JTA](C:/Users/user/.gemini/antigravity/brain/f5437a05-a1d5-4e3b-aa37-20e3f692fed9/diagram_instalasi_apk_1778573674521.png)
**Gambar 4.x** — Alur instalasi APK JTA pada perangkat Android.
**Langkah 1 — Menerima File APK.** Mitra mengunduh file `app-release.apk` dari tautan Google Drive yang dikirimkan oleh pengembang. `[DUMMY — sesuaikan media distribusi]`
**Langkah 2 — Mengaktifkan Izin Install dari Sumber Tidak Dikenal.** Pada Android 8.0 ke atas, izin ini bersifat per-aplikasi. Mitra membuka **Pengaturan → Aplikasi → Browser/File Manager → Instal aplikasi tidak dikenal → Izinkan**. Pada Android versi lama (< 8.0), pengaturan ini terdapat di **Pengaturan → Keamanan → Sumber Tidak Dikenal**.
**Langkah 3 — Instalasi APK.** Mitra membuka file APK menggunakan file manager, kemudian mengikuti prompt instalasi sistem Android. Proses instalasi selesai dalam waktu kurang dari 30 detik. `[DUMMY — catat durasi aktual saat instalasi di lapangan]`
**Langkah 4 — Verifikasi dan Login.** Setelah instalasi berhasil, aplikasi dibuka dan menampilkan *splash screen* JTA, diikuti halaman login. Mitra memasukkan kredensial akun yang telah disiapkan pengembang, dan berhasil masuk ke halaman dashboard monitoring. Screenshot tampilan aplikasi pertama kali terpasang di perangkat mitra:
> **[DUMMY — Sisipkan screenshot halaman login dan dashboard pertama kali di perangkat mitra]**
> Screenshot dapat diambil langsung dari perangkat mitra menggunakan tombol Power + Volume Bawah, kemudian dipindahkan ke dokumen laporan.

6
lib/core/api_config.dart Normal file
View File

@ -0,0 +1,6 @@
import 'env.dart';
class ApiConfig {
static const String baseUrl = Env.baseUrl;
}

96
lib/core/app_theme.dart Normal file
View File

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
/// Satu sumber kebenaran untuk semua warna, style, dan theme aplikasi.
class AppTheme {
AppTheme._();
// Brand Colors
static const Color primary = Color(0xFF3E5F6F);
static const Color primaryLight = Color(0xFF5A7F91);
static const Color primaryDark = Color(0xFF2B4555);
static const Color accent = Color(0xFF2196F3);
static const Color accentLight = Color(0xFFE3F2FD);
// Backgrounds
static const Color bgPage = Color(0xFFF4F6FA);
static const Color bgCard = Colors.white;
static const Color bgAuth = Color(0xFFF6F6F6);
// Text
static const Color textPrimary = Color(0xFF1A1A2E);
static const Color textSecondary = Color(0xFF6B7280);
static const Color textHint = Color(0xFF9CA3AF);
// Status
static const Color statusNormal = Color(0xFF4CAF50);
static const Color statusWarning = Colors.orange;
static const Color statusDanger = Colors.red;
// Gradients
static const LinearGradient suhuGradient = LinearGradient(
colors: [Color(0xFFB026FF), Color(0xFF7B2FBE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
static const LinearGradient humidGradient = LinearGradient(
colors: [Color(0xFF56CCF2), Color(0xFF2F80ED)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
// Card Decoration
static BoxDecoration cardDecoration({double radius = 16}) => BoxDecoration(
color: bgCard,
borderRadius: BorderRadius.circular(radius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
);
// ThemeData
static ThemeData get light {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: primary),
scaffoldBackgroundColor: bgPage,
appBarTheme: const AppBarTheme(
backgroundColor: bgPage,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: textPrimary,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: bgCard,
selectedItemColor: primary,
unselectedItemColor: textSecondary,
type: BottomNavigationBarType.fixed,
elevation: 8,
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.white;
return Colors.grey.shade400;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return accent;
return Colors.grey.shade300;
}),
),
sliderTheme: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
),
);
}
}

14
lib/core/env.example.dart Normal file
View File

@ -0,0 +1,14 @@
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 = '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';
// --- ONESIGNAL CONFIGURATION ---
static const String oneSignalAppId = 'YOUR_ONESIGNAL_APP_ID';
}

47
lib/core/routes.dart Normal file
View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../features/landing/splash_screen.dart';
import '../features/landing/landing_page.dart';
import '../features/auth/login_page.dart';
import '../features/auth/register_page.dart';
import '../features/iot/page/main_page.dart';
class AppRoutes {
AppRoutes._();
static const String splash = '/';
static const String landing = '/landing';
static const String login = '/login';
static const String register = '/register';
static const String main = '/main';
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case splash:
return _fadeRoute(const SplashScreen());
case landing:
return _fadeRoute(const LandingPage());
case login:
return _fadeRoute(const LoginPage());
case register:
return _fadeRoute(const RegisterPage());
case main:
return _fadeRoute(const MainPage());
default:
return _fadeRoute(const SplashScreen());
}
}
static PageRouteBuilder<dynamic> _fadeRoute(Widget page) {
return PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 400),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
child: child,
);
},
);
}
}

View File

@ -0,0 +1,39 @@
class CronUtils {
static const List<String> shortDays = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'];
/// Mengkonversi array of integers [0, 1, 2, ...] ke string (Misal: "Setiap Hari" atau "Sen, Sel")
static String getRepeatText(List<int> selectedDays) {
if (selectedDays.isEmpty) return 'Tidak diulang';
if (selectedDays.length == 7) return 'Setiap Hari';
// Sortir agar hari selalu urut (Min=0, Sen=1, ... Sab=6)
final sortedDays = List<int>.from(selectedDays)..sort();
return sortedDays.map((d) => shortDays[d]).join(', ');
}
/// Mengubah list dari [1,3,5] menjadi "1,3,5" untuk ditaruh di cron.
/// Jika 7 hari terpilih, kembalikan "*"
static String daysToCron(List<int> selectedDays) {
if (selectedDays.isEmpty) return '*'; // Default fallback
if (selectedDays.length == 7) return '*';
// Selalu urutkan agar cron string konsisten, terlepas dari urutan user memilih
final sorted = List<int>.from(selectedDays)..sort();
return sorted.join(',');
}
/// Mengekstrak hari aktif (array int 0-6) dari cron "30 17 * * 1,3,5"
static List<int> parseDaysFromCron(String cron) {
final parts = cron.split(' ');
if (parts.length < 5) return [0, 1, 2, 3, 4, 5, 6]; // Fallback setiap hari
final dayPart = parts[4];
if (dayPart == '*') return [0, 1, 2, 3, 4, 5, 6];
try {
final days = dayPart.split(',').map((e) => int.parse(e.trim())).toList();
return days;
} catch (_) {
return [0, 1, 2, 3, 4, 5, 6]; // Fallback jika gagal parse
}
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import '../app_theme.dart';
class AuthPasswordField extends StatefulWidget {
final TextEditingController controller;
final String label;
final String? Function(String?) validator;
const AuthPasswordField({
super.key,
required this.controller,
required this.label,
required this.validator,
});
@override
State<AuthPasswordField> createState() => _AuthPasswordFieldState();
}
class _AuthPasswordFieldState extends State<AuthPasswordField> {
bool _obscure = true;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: widget.controller,
obscureText: _obscure,
validator: widget.validator,
style: const TextStyle(fontSize: 15),
decoration: InputDecoration(
labelText: widget.label,
filled: true,
fillColor: Colors.white,
prefixIcon: const Icon(Icons.lock_outline, color: AppTheme.textHint, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade200, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppTheme.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppTheme.statusDanger, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppTheme.statusDanger, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 20),
color: AppTheme.textHint,
onPressed: () => setState(() => _obscure = !_obscure),
),
),
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../core/app_theme.dart';
class AuthPrimaryButton extends StatelessWidget {
final bool enabled;
final VoidCallback onPressed;
final String text;
final bool isLoading;
const AuthPrimaryButton({
super.key,
required this.enabled,
required this.onPressed,
required this.text,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: enabled && !isLoading ? onPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
disabledBackgroundColor: Colors.grey.shade300,
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: enabled && !isLoading ? 4 : 0,
shadowColor: AppTheme.primary.withValues(alpha: 0.4),
),
child: isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
);
}
}

View File

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../app_theme.dart';
class AuthScaffold extends StatelessWidget {
final String title;
final String subtitle;
final Widget child;
final bool showBackButton;
const AuthScaffold({
super.key,
required this.title,
required this.subtitle,
required this.child,
this.showBackButton = true,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.bgAuth,
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
// Header row
Row(
children: [
if (showBackButton)
GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.opaque,
child: const SizedBox(
width: 32,
height: 32,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(Icons.arrow_back, size: 24),
),
),
),
const Spacer(),
GestureDetector(
onTap: () {},
child: const SizedBox(
width: 32,
height: 32,
child: Align(
alignment: Alignment.centerRight,
child: Icon(Icons.info_outline, size: 24),
),
),
),
],
),
const SizedBox(height: 24),
Text(
title,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
height: 1.2,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
height: 1.5,
),
),
const SizedBox(height: 40),
child,
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../app_theme.dart';
class AuthTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String? Function(String?) validator;
final TextInputType keyboardType;
final IconData? prefixIcon;
const AuthTextField({
super.key,
required this.controller,
required this.label,
required this.validator,
this.keyboardType = TextInputType.text,
this.prefixIcon,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboardType,
style: const TextStyle(fontSize: 15),
decoration: InputDecoration(
labelText: label,
filled: true,
fillColor: Colors.white,
prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: AppTheme.textHint, size: 20) : null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade200, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppTheme.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppTheme.statusDanger, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppTheme.statusDanger, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';
import '../../repositories/auth_repository.dart';
import '../../services/supabase_auth_service.dart'; // Dibutuhkan untuk DI di authRepositoryProvider
import 'auth_state.dart';
// Provider
// Provider untuk AuthRepository yang disuntik dengan SupabaseAuthService
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(SupabaseAuthService()),
);
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(ref.read(authRepositoryProvider)),
);
// Notifier
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repo;
AuthNotifier(this._repo) : super(const AuthIdle());
Future<void> checkAuthStatus() async {
state = const AuthLoading();
try {
// Gunakan _repo.checkSession() lewat DI tidak perlu buat instance baru
final user = await _repo.checkSession();
if (user != null) {
OneSignal.login(user.id);
state = AuthSuccess(user);
} else {
state = const AuthIdle();
}
} catch (e) {
state = const AuthIdle();
}
}
Future<void> login(String email, String password) async {
state = const AuthLoading();
try {
final user = await _repo.login(email, password);
OneSignal.login(user.id);
state = AuthSuccess(user);
} catch (e) {
state = AuthError(e.toString().replaceFirst('Exception: ', ''));
}
}
Future<void> register(
String name, String email, String password, String claimCode) async {
state = const AuthLoading();
try {
final user = await _repo.register(name, email, password, claimCode);
OneSignal.login(user.id);
state = AuthSuccess(user);
} catch (e) {
state = AuthError(e.toString().replaceFirst('Exception: ', ''));
}
}
Future<void> logout() async {
OneSignal.logout();
await _repo.logout();
state = const AuthIdle();
}
void resetError() {
if (state is AuthError) state = const AuthIdle();
}
}

View File

@ -0,0 +1,28 @@
import '../../models/user_model.dart';
/// State untuk auth provider semua kemungkinan kondisi auth.
sealed class AuthState {
const AuthState();
}
/// Kondisi awal / idle
class AuthIdle extends AuthState {
const AuthIdle();
}
/// Sedang memproses (login/register)
class AuthLoading extends AuthState {
const AuthLoading();
}
/// Berhasil membawa data user
class AuthSuccess extends AuthState {
final UserModel user;
const AuthSuccess(this.user);
}
/// Gagal membawa pesan error
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
}

View File

@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_theme.dart';
import '../../core/routes.dart';
import '../../core/widgets/auth_primary_button.dart';
import '../../core/widgets/auth_password_field.dart';
import '../../core/widgets/auth_scaffold.dart';
import '../../core/widgets/auth_text_field.dart';
import 'auth_notifier.dart';
import 'auth_state.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
AutovalidateMode _autoValidate = AutovalidateMode.disabled;
bool _isButtonEnabled = false;
@override
void initState() {
super.initState();
_emailCtrl.addListener(_checkButton);
_passwordCtrl.addListener(_checkButton);
}
void _checkButton() {
final filled = _emailCtrl.text.isNotEmpty &&
_passwordCtrl.text.isNotEmpty;
if (filled != _isButtonEnabled) {
setState(() => _isButtonEnabled = filled);
}
}
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() => _autoValidate = AutovalidateMode.onUserInteraction);
if (!_formKey.currentState!.validate()) return;
await ref.read(authProvider.notifier).login(
_emailCtrl.text,
_passwordCtrl.text,
);
if (!mounted) return;
final state = ref.read(authProvider);
if (state is AuthSuccess) {
Navigator.pushNamedAndRemoveUntil(context, AppRoutes.main, (route) => false);
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.statusDanger,
),
);
ref.read(authProvider.notifier).resetError();
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AuthLoading;
return AuthScaffold(
title: 'Log in',
subtitle: 'Masukkan email & password yang telah terdaftar',
child: Form(
key: _formKey,
autovalidateMode: _autoValidate,
child: Column(
children: [
AuthTextField(
controller: _emailCtrl,
label: 'Email',
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email_outlined,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email tidak boleh kosong';
}
if (!value.contains('@') || !value.contains('.')) {
return 'Format email tidak valid';
}
return null;
},
),
const SizedBox(height: 20),
AuthPasswordField(
controller: _passwordCtrl,
label: 'Password',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password tidak boleh kosong';
}
if (value.length < 6) {
return 'Minimal 6 karakter';
}
return null;
},
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {},
child: const Text(
'Lupa password?',
style: TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 40),
AuthPrimaryButton(
text: 'Masuk',
enabled: _isButtonEnabled,
isLoading: isLoading,
onPressed: _submit,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Belum punya akun? ',
style: TextStyle(color: AppTheme.textSecondary),
),
GestureDetector(
onTap: () => Navigator.pushReplacementNamed(context, AppRoutes.register),
child: const Text(
'Daftar di sini',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_theme.dart';
import '../../core/routes.dart';
import '../../core/widgets/auth_primary_button.dart';
import '../../core/widgets/auth_password_field.dart';
import '../../core/widgets/auth_scaffold.dart';
import '../../core/widgets/auth_text_field.dart';
import 'auth_notifier.dart';
import 'auth_state.dart';
class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key});
@override
ConsumerState<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _confirmCtrl = TextEditingController();
final _claimCodeCtrl = TextEditingController();
AutovalidateMode _autoValidate = AutovalidateMode.disabled;
bool _isButtonEnabled = false;
@override
void initState() {
super.initState();
for (final c in [_nameCtrl, _emailCtrl, _passwordCtrl, _confirmCtrl, _claimCodeCtrl]) {
c.addListener(_checkButton);
}
}
void _checkButton() {
final filled = _nameCtrl.text.isNotEmpty &&
_emailCtrl.text.isNotEmpty &&
_passwordCtrl.text.isNotEmpty &&
_confirmCtrl.text.isNotEmpty &&
_claimCodeCtrl.text.isNotEmpty;
if (filled != _isButtonEnabled) {
setState(() => _isButtonEnabled = filled);
}
}
@override
void dispose() {
_nameCtrl.dispose();
_emailCtrl.dispose();
_passwordCtrl.dispose();
_confirmCtrl.dispose();
_claimCodeCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() => _autoValidate = AutovalidateMode.onUserInteraction);
if (!_formKey.currentState!.validate()) return;
await ref.read(authProvider.notifier).register(
_nameCtrl.text,
_emailCtrl.text,
_passwordCtrl.text,
_claimCodeCtrl.text,
);
if (!mounted) return;
final state = ref.read(authProvider);
if (state is AuthSuccess) {
Navigator.pushNamedAndRemoveUntil(context, AppRoutes.main, (route) => false);
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.statusDanger,
),
);
ref.read(authProvider.notifier).resetError();
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AuthLoading;
return AuthScaffold(
title: 'Register',
subtitle: 'Daftarkan dirimu untuk membuat akun baru',
child: Form(
key: _formKey,
autovalidateMode: _autoValidate,
child: Column(
children: [
AuthTextField(
controller: _nameCtrl,
label: 'Nama Lengkap',
prefixIcon: Icons.person_outline,
validator: (v) =>
(v == null || v.isEmpty) ? 'Nama tidak boleh kosong' : null,
),
const SizedBox(height: 20),
AuthTextField(
controller: _emailCtrl,
label: 'Email',
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email_outlined,
validator: (v) {
if (v == null || v.isEmpty) return 'Email tidak boleh kosong';
if (!v.contains('@') || !v.contains('.')) {
return 'Format email tidak valid';
}
return null;
},
),
const SizedBox(height: 20),
AuthPasswordField(
controller: _passwordCtrl,
label: 'Password',
validator: (v) =>
(v == null || v.length < 6) ? 'Minimal 6 karakter' : null,
),
const SizedBox(height: 20),
AuthPasswordField(
controller: _confirmCtrl,
label: 'Konfirmasi Password',
validator: (v) =>
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(
text: 'Daftar Sekarang',
enabled: _isButtonEnabled,
isLoading: isLoading,
onPressed: _submit,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Sudah punya akun? ',
style: TextStyle(color: AppTheme.textSecondary),
),
GestureDetector(
onTap: () => Navigator.pushReplacementNamed(context, AppRoutes.login),
child: const Text(
'Masuk',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../../../features/auth/auth_notifier.dart';
import '../../../features/auth/auth_state.dart';
import '../providers/control_notifier.dart';
import '../providers/monitoring_provider.dart';
import '../widgets/device_status_card.dart';
import '../widgets/device_control_section.dart';
import '../widgets/greeting_section.dart';
import '../widgets/schedule_section.dart';
import '../widgets/add_schedule_sheet.dart';
import '../widgets/operation_mode_card.dart';
class ControlPage extends ConsumerWidget {
const ControlPage({super.key});
void _onAddSchedule(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => AddScheduleSheet(
onSave: (time, days, durationS) {
ref.read(controlProvider.notifier).addSchedule(time, days, durationS);
},
),
);
}
void _showManualDurationDialog(BuildContext context, WidgetRef ref) {
int selectedDuration = 30; // default
showDialog(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Lama Penyiraman'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pilih durasi pompa menyala secara manual:'),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
{'label': '15s', 'val': 15},
{'label': '30s', 'val': 30},
{'label': '1m', 'val': 60},
{'label': '2m', 'val': 120},
{'label': '5m', 'val': 300},
].map((opt) {
final val = opt['val'] as int;
final isSelected = selectedDuration == val;
return ChoiceChip(
label: Text(opt['label'] as String),
selected: isSelected,
onSelected: (bool selected) {
if (selected) {
setState(() => selectedDuration = val);
}
},
selectedColor: AppTheme.primary,
labelStyle: TextStyle(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
backgroundColor: Colors.grey.shade200,
);
}).toList(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary),
onPressed: () {
Navigator.pop(ctx);
ref.read(controlProvider.notifier).togglePump(selectedDuration);
},
child: const Text('Mulai', style: TextStyle(color: Colors.white)),
),
],
);
},
);
},
);
}
void _showStopConfirmationDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Hentikan Penyiraman?'),
content: const Text('Apakah Anda yakin ingin mematikan pompa saat ini?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.statusDanger),
onPressed: () {
Navigator.pop(ctx);
ref.read(controlProvider.notifier).togglePump();
},
child: const Text('Matikan', style: TextStyle(color: Colors.white)),
),
],
),
);
}
void _confirmDelete(BuildContext context, WidgetRef ref, String id) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Hapus Jadwal?'),
content: const Text('Jadwal ini akan dihapus secara permanen.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Batal'),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: AppTheme.statusDanger),
onPressed: () {
Navigator.of(ctx).pop();
ref.read(controlProvider.notifier).removeSchedule(id);
},
child: const Text('Hapus'),
),
],
),
);
}
String _getUserName(WidgetRef ref) {
final authState = ref.watch(authProvider);
if (authState is AuthSuccess) return authState.user.name;
return 'Pengguna';
}
String _getDeviceCode(WidgetRef ref) {
final authState = ref.watch(authProvider);
if (authState is AuthSuccess) return authState.user.deviceId ?? '-';
return '-';
}
bool _checkIsOnline(WidgetRef ref) {
final monitoringState = ref.watch(monitoringProvider);
final latest = monitoringState.latest;
if (latest == null) return false;
// Jika data terakhir masuk dalam 5 menit, dianggap online
final diff = DateTime.now().difference(latest.timestamp);
return diff.inMinutes < 5;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Listener untuk Snackbar Error & Success
ref.listen(controlProvider, (prev, next) {
if (next.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.errorMessage!),
backgroundColor: AppTheme.statusDanger,
),
);
ref.read(controlProvider.notifier).clearMessage();
}
if (next.successMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.successMessage!),
backgroundColor: AppTheme.statusNormal,
),
);
ref.read(controlProvider.notifier).clearMessage();
}
});
final control = ref.watch(controlProvider);
final notifier = ref.read(controlProvider.notifier);
final userName = _getUserName(ref);
final deviceCode = _getDeviceCode(ref);
final isOnline = _checkIsOnline(ref);
return Scaffold(
backgroundColor: AppTheme.bgPage,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GreetingSection(
userName: userName,
subtitle: 'Selamat Datang di Kontrol Panel',
),
const SizedBox(height: 20),
DeviceStatusCard(
isOnline: isOnline,
deviceCode: deviceCode,
lastSeen: ref.watch(monitoringProvider).latest?.timestamp,
),
const SizedBox(height: 20),
const OperationModeCard(),
const SizedBox(height: 16),
DeviceControlSection(
isPumpOn: control.isPumpOn,
isOnline: isOnline,
suhuLimit: control.suhuLimit,
kelembapanLimit: control.kelembapanLimit,
activePumpDuration: control.activePumpDuration,
pumpTurnedOnAt: control.pumpTurnedOnAt,
onPumpToggle: () {
if (control.isPumpOn) {
_showStopConfirmationDialog(context, ref);
} else {
_showManualDurationDialog(context, ref);
}
},
onSaveThreshold: (suhu, kelembapan) =>
notifier.saveThreshold(suhu, kelembapan),
),
const SizedBox(height: 20),
// Jika sedang loading initial data, tampilkan loading utama
if (control.isLoading && control.schedules.isEmpty)
const Center(child: CircularProgressIndicator())
else ...[
// Jika loading tapi schedules sudah ada (misal proses tambah jadwal),
// tampilkan progress bar tipis di atas section
if (control.isLoading && control.schedules.isNotEmpty)
const LinearProgressIndicator(),
ScheduleSection(
schedules: control.schedules,
onAddSchedule: () => _onAddSchedule(context, ref),
onToggleSchedule: (id, _) => notifier.toggleSchedule(id),
onRemoveSchedule: (id) => _confirmDelete(context, ref, id),
),
],
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../../auth/auth_notifier.dart';
import '../../auth/auth_state.dart';
import '../providers/monitoring_provider.dart';
import '../widgets/chart_range_selector.dart';
import '../widgets/chart_type_selector.dart';
import '../widgets/greeting_section.dart';
import '../widgets/device_status_card.dart';
import '../widgets/monitoring_card.dart';
import '../widgets/monitoring_chart.dart';
class MonitoringPage extends ConsumerWidget {
const MonitoringPage({super.key});
String _getUserName(WidgetRef ref) {
final authState = ref.watch(authProvider);
if (authState is AuthSuccess) return authState.user.name;
return 'Pengguna';
}
String _getDeviceCode(WidgetRef ref) {
final authState = ref.watch(authProvider);
if (authState is AuthSuccess) return authState.user.deviceId ?? '-';
return '-';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final monitoring = ref.watch(monitoringProvider);
final userName = _getUserName(ref);
return Scaffold(
backgroundColor: AppTheme.bgPage,
body: SafeArea(
child: RefreshIndicator(
onRefresh: () =>
ref.read(monitoringProvider.notifier).fetchData(showLoading: true),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GreetingSection(
userName: userName,
subtitle: 'Monitoring Real-Time Kumbung',
),
const SizedBox(height: 20),
DeviceStatusCard(
isOnline: monitoring.latest != null && DateTime.now().difference(monitoring.latest!.timestamp).inMinutes < 5,
deviceCode: _getDeviceCode(ref),
lastSeen: monitoring.latest?.timestamp,
),
const SizedBox(height: 20),
// Error banner
if (monitoring.errorMessage != null)
_ErrorBanner(message: monitoring.errorMessage!),
// Loading skeleton
if (monitoring.isLoading)
const _LoadingSkeleton()
else if (monitoring.latest == null) ...[
// Belum ada data sensor
_NoDataBanner(),
] else ...[
// Sensor cards
Row(
children: [
Expanded(
child: MonitoringCard(
value: monitoring.temperature.toStringAsFixed(1),
unit: '°C',
subtitle: 'Suhu Real Time',
status: monitoring.tempStatus,
gradient: AppTheme.suhuGradient,
),
),
const SizedBox(width: 16),
Expanded(
child: MonitoringCard(
value: monitoring.humidity.toStringAsFixed(1),
unit: '%',
subtitle: 'Kelembapan Real Time',
status: monitoring.humidStatus,
gradient: AppTheme.humidGradient,
),
),
],
),
const SizedBox(height: 30),
// Chart filter
const ChartRangeSelector(),
const SizedBox(height: 12),
const ChartTypeSelector(),
const SizedBox(height: 20),
// Chart
const MonitoringChart(),
],
const SizedBox(height: 20),
],
),
),
),
),
);
}
}
// Error banner
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.statusDanger.withOpacity(0.1),
border: Border.all(color: AppTheme.statusDanger.withOpacity(0.3)),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.wifi_off, color: AppTheme.statusDanger, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
'Gagal mengambil data: $message',
style: const TextStyle(
color: AppTheme.statusDanger,
fontSize: 13,
),
),
),
],
),
);
}
}
// Loading skeleton
class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: _skeletonBox(height: 120)),
const SizedBox(width: 16),
Expanded(child: _skeletonBox(height: 120)),
],
);
}
Widget _skeletonBox({required double height}) {
return Container(
height: height,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(20),
),
);
}
}
// No Data banner
class _NoDataBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(16),
),
child: const Column(
children: [
Icon(Icons.sensors_off, color: Colors.grey, size: 40),
SizedBox(height: 12),
Text(
'Belum Ada Data Sensor',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 6),
Text(
'Pastikan perangkat ESP32 menyala dan\nterhubung ke internet.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 13),
),
],
),
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../../../features/auth/auth_notifier.dart';
import '../../../core/routes.dart';
import 'home_page.dart';
import 'control_page.dart';
class MainPage extends ConsumerStatefulWidget {
const MainPage({super.key});
@override
ConsumerState<MainPage> createState() => _MainPageState();
}
class _MainPageState extends ConsumerState<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = const [
MonitoringPage(),
ControlPage(),
];
Future<void> _logout() async {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Keluar'),
content: const Text('Apakah kamu yakin ingin keluar?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Keluar',
style: TextStyle(color: AppTheme.statusDanger),
),
),
],
),
);
if (confirm == true && mounted) {
await ref.read(authProvider.notifier).logout();
if (mounted) {
Navigator.pushNamedAndRemoveUntil(
context, AppRoutes.landing, (_) => false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard_outlined),
activeIcon: Icon(Icons.dashboard),
label: 'Monitoring',
),
BottomNavigationBarItem(
icon: Icon(Icons.tune_outlined),
activeIcon: Icon(Icons.tune),
label: 'Control',
),
],
),
floatingActionButton: FloatingActionButton.small(
onPressed: _logout,
backgroundColor: Colors.white,
foregroundColor: AppTheme.textSecondary,
elevation: 2,
child: const Icon(Icons.logout, size: 20),
)
);
}
}

View File

@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/cron_utils.dart';
import '../../../services/api_control_service.dart';
import '../../auth/auth_notifier.dart';
import '../../auth/auth_state.dart';
import 'control_state.dart';
// Provider
final controlProvider = NotifierProvider<ControlNotifier, ControlState>(ControlNotifier.new);
// Notifier
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)
DateTime? _syncLockedUntil;
// Sync lock: cegah syncModeFromSensor menimpa optimistic update
// selama 10 detik setelah user melakukan ganti mode (setDeviceMode)
DateTime? _modeSyncLockedUntil;
@override
ControlState build() {
final authState = ref.watch(authProvider);
if (authState is AuthSuccess && authState.user.deviceId != null) {
_deviceId = authState.user.deviceId!;
}
_api = ApiControlService(_deviceId);
// Fetch initial data secara asinkron tanpa memblokir build
if (_deviceId.isNotEmpty) {
Future.microtask(() => _initData());
}
return ControlState.initial();
}
Future<void> _initData() async {
state = state.copyWith(isLoading: true);
try {
final thresholdTask = _api.getThreshold();
final schedulesTask = _api.getSchedules();
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
final currentMode = await _api.getMode();
// Parsing schedules dari backend ke 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)
final cronString = s['cron'].toString();
final cronParts = cronString.split(' ');
int utcMinute = 0;
int utcHour = 0;
if (cronParts.length >= 2) {
utcMinute = int.tryParse(cronParts[0]) ?? 0;
utcHour = int.tryParse(cronParts[1]) ?? 0;
}
// Konversi UTC -> WIB (+7 jam) agar tampilan jam di UI sesuai input user
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
var daysArray = CronUtils.parseDaysFromCron(cronString);
// BUG FIX: Jika penambahan 7 jam menyebabkan hari berganti ke besoknya, geser hari ke depan
if (utcTotalMinutes + 7 * 60 >= 24 * 60) {
daysArray = daysArray.map((d) => (d + 1) % 7).toList();
}
final repeatText = CronUtils.getRepeatText(daysArray);
return WateringSchedule(
id: s['id'].toString(),
time: TimeOfDay(hour: wibHour, minute: wibMinute),
repeat: repeatText,
isEnabled: s['is_active'] ?? true,
cron: cronString,
durationS: s['duration_s'] ?? 30,
);
}).toList();
state = state.copyWith(
isLoading: false,
suhuLimit: (threshold['temp_max'] as num?)?.toDouble() ?? 30.0,
kelembapanLimit: (threshold['hum_max'] as num?)?.toDouble() ?? 75.0,
schedules: parsedSchedules,
deviceMode: DeviceModeX.fromString(currentMode),
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: 'Gagal memuat data awal. Periksa koneksi internet.',
);
print('Gagal _initData Control: $e');
}
}
// Pesan Error & Success
void clearMessage() {
state = state.copyWith(clearError: true, clearSuccess: true);
}
// Pompa
Future<void> togglePump([int? durationSeconds]) async {
final willTurnOn = !state.isPumpOn;
// Optimistic update
state = state.copyWith(
isPumpOn: willTurnOn,
activePumpDuration: willTurnOn ? (durationSeconds ?? 30) : null,
pumpTurnedOnAt: willTurnOn ? DateTime.now() : null,
clearTimer: !willTurnOn,
);
// Kunci sync selama 10 detik agar polling dari monitoringProvider tidak
// menimpa optimistic update sebelum ESP32 sempat melaporkan state baru
_syncLockedUntil = DateTime.now().add(const Duration(seconds: 10));
try {
if (willTurnOn) {
final duration = durationSeconds ?? 30; // Default 30 detik
await _api.siramManual(duration);
state = state.copyWith(successMessage: 'Menyalakan pompa (${duration}s)');
} else {
await _api.stopPump();
state = state.copyWith(successMessage: 'Pompa dimatikan');
}
} catch (e) {
// Revert jika gagal, dan lepas lock agar sync bisa jalan kembali
_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.
void syncRelayFromSensor(bool relayOn) {
// Jika sync sedang dikunci (user baru tekan tombol), abaikan update dari polling
if (_syncLockedUntil != null && DateTime.now().isBefore(_syncLockedUntil!)) {
return;
}
// Hanya update jika berbeda, untuk menghindari rebuild yang tidak perlu
if (state.isPumpOn != relayOn) {
state = state.copyWith(
isPumpOn: relayOn,
clearTimer: !relayOn,
);
}
}
void syncModeFromSensor(String modeStr) {
// Jika sync sedang dikunci (user baru tekan tombol), abaikan update dari polling
if (_modeSyncLockedUntil != null && DateTime.now().isBefore(_modeSyncLockedUntil!)) {
return;
}
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);
}
}
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.
Future<void> saveThreshold(double suhu, double kelembapan) async {
// Optimistic update UI langsung mencerminkan nilai baru
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
state = state.copyWith(
suhuLimit: state.suhuLimit,
kelembapanLimit: state.kelembapanLimit,
errorMessage: 'Gagal menyimpan batasan sensor',
);
print('Gagal save threshold: $e');
}
}
// Jadwal
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.
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
List<int> adjustedDays = List.from(days);
if (localMinutes - 7 * 60 < 0) {
adjustedDays = days.map((d) => (d - 1) < 0 ? 6 : (d - 1)).toList();
}
final daysStr = CronUtils.daysToCron(adjustedDays);
final cron = '$utcMinute $utcHour * * $daysStr';
final result = await _api.addSchedule(cron, durationS, 'Jadwal Otomatis');
final newSchedule = WateringSchedule(
id: result['id'].toString(),
time: time,
repeat: CronUtils.getRepeatText(days),
isEnabled: result['is_active'] ?? true,
cron: result['cron']?.toString(),
durationS: (result['duration_s'] as num?)?.toInt() ?? durationS,
);
state = state.copyWith(
isLoading: false,
schedules: [...state.schedules, newSchedule],
successMessage: 'Jadwal baru ditambahkan',
);
} catch (e) {
state = state.copyWith(isLoading: false, errorMessage: 'Gagal menambah jadwal');
print('Gagal tambah schedule: $e');
}
}
Future<void> toggleSchedule(String id) async {
// Optimistic update
final oldSchedules = state.schedules;
state = state.copyWith(
schedules: state.schedules.map((s) {
return s.id == id ? s.copyWith(isEnabled: !s.isEnabled) : s;
}).toList(),
);
try {
await _api.toggleSchedule(id);
} catch (e) {
// Revert
state = state.copyWith(schedules: oldSchedules, errorMessage: 'Gagal mengubah status jadwal');
print('Gagal toggle schedule: $e');
}
}
Future<void> removeSchedule(String id) async {
final oldSchedules = state.schedules;
state = state.copyWith(
schedules: state.schedules.where((s) => s.id != id).toList(),
isLoading: true,
);
try {
await _api.deleteSchedule(id);
state = state.copyWith(isLoading: false, successMessage: 'Jadwal berhasil dihapus');
} catch (e) {
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
print('Gagal hapus schedule: $e');
}
}
// Mode Operasi
Future<void> setDeviceMode(DeviceMode mode) async {
if (state.isModeLoading) return;
if (state.deviceMode == mode) return; // Mencegah request berulang jika menekan mode yang sama
final prevMode = state.deviceMode;
// 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
_modeSyncLockedUntil = DateTime.now().add(const Duration(seconds: 10));
try {
await _api.setMode(mode.apiValue);
state = state.copyWith(
isModeLoading: false,
successMessage: 'Mode diubah ke ${mode.label}',
);
} catch (e) {
// Revert
_modeSyncLockedUntil = null;
state = state.copyWith(
deviceMode: prevMode,
isModeLoading: false,
errorMessage: 'Gagal mengubah mode operasi',
);
}
}
}

View File

@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
// Model: WateringSchedule
class WateringSchedule {
final String id;
final TimeOfDay time;
final String repeat;
final bool isEnabled;
final String? cron;
final int durationS;
const WateringSchedule({
required this.id,
required this.time,
this.repeat = 'Setiap Hari',
this.isEnabled = true,
this.cron,
this.durationS = 30,
});
String get formattedTime {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
return '$h.$m';
}
WateringSchedule copyWith({
String? id,
TimeOfDay? time,
String? repeat,
bool? isEnabled,
String? cron,
int? durationS,
}) {
return WateringSchedule(
id: id ?? this.id,
time: time ?? this.time,
repeat: repeat ?? this.repeat,
isEnabled: isEnabled ?? this.isEnabled,
cron: cron ?? this.cron,
durationS: durationS ?? this.durationS,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is WateringSchedule &&
runtimeType == other.runtimeType &&
id == other.id);
@override
int get hashCode => id.hashCode;
}
// Enum: DeviceMode
enum DeviceMode { auto_, manual, offline }
extension DeviceModeX on DeviceMode {
String get apiValue {
switch (this) {
case DeviceMode.auto_: return 'auto';
case DeviceMode.manual: return 'manual';
case DeviceMode.offline: return 'offline';
}
}
String get label {
switch (this) {
case DeviceMode.auto_: return 'AUTO';
case DeviceMode.manual: return 'MANUAL';
case DeviceMode.offline: return 'OFFLINE';
}
}
static DeviceMode fromString(String s) {
final str = s.toLowerCase();
if (str.startsWith('manual')) {
return DeviceMode.manual;
} else if (str.startsWith('offline')) {
return DeviceMode.offline;
} else {
return DeviceMode.auto_;
}
}
}
// State: ControlState
class ControlState {
final bool isLoading;
final bool isPumpOn;
final double suhuLimit;
final double kelembapanLimit;
final List<WateringSchedule> schedules;
final String? errorMessage;
final String? successMessage;
final int? activePumpDuration;
final DateTime? pumpTurnedOnAt;
final DeviceMode deviceMode;
final bool isModeLoading;
const ControlState({
this.isLoading = false,
this.isPumpOn = false,
this.suhuLimit = 30,
this.kelembapanLimit = 75,
this.schedules = const [],
this.errorMessage,
this.successMessage,
this.activePumpDuration,
this.pumpTurnedOnAt,
this.deviceMode = DeviceMode.auto_,
this.isModeLoading = false,
});
factory ControlState.initial() => const ControlState(
isLoading: true, // Saat mulai aplikasi, data sedang di-fetch
schedules: [],
);
ControlState copyWith({
bool? isLoading,
bool? isPumpOn,
double? suhuLimit,
double? kelembapanLimit,
List<WateringSchedule>? schedules,
String? errorMessage,
String? successMessage,
int? activePumpDuration,
DateTime? pumpTurnedOnAt,
DeviceMode? deviceMode,
bool? isModeLoading,
bool clearError = false,
bool clearSuccess = false,
bool clearTimer = false,
}) {
return ControlState(
isLoading: isLoading ?? this.isLoading,
isPumpOn: isPumpOn ?? this.isPumpOn,
suhuLimit: suhuLimit ?? this.suhuLimit,
kelembapanLimit: kelembapanLimit ?? this.kelembapanLimit,
schedules: schedules ?? this.schedules,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
successMessage: clearSuccess ? null : (successMessage ?? this.successMessage),
activePumpDuration: clearTimer ? null : (activePumpDuration ?? this.activePumpDuration),
pumpTurnedOnAt: clearTimer ? null : (pumpTurnedOnAt ?? this.pumpTurnedOnAt),
deviceMode: deviceMode ?? this.deviceMode,
isModeLoading: isModeLoading ?? this.isModeLoading,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ControlState &&
isLoading == other.isLoading &&
isPumpOn == other.isPumpOn &&
suhuLimit == other.suhuLimit &&
kelembapanLimit == other.kelembapanLimit &&
errorMessage == other.errorMessage &&
successMessage == other.successMessage &&
activePumpDuration == other.activePumpDuration &&
pumpTurnedOnAt == other.pumpTurnedOnAt &&
schedules == other.schedules);
@override
int get hashCode =>
isLoading.hashCode ^
isPumpOn.hashCode ^
suhuLimit.hashCode ^
kelembapanLimit.hashCode ^
errorMessage.hashCode ^
successMessage.hashCode ^
activePumpDuration.hashCode ^
pumpTurnedOnAt.hashCode ^
schedules.hashCode;
}

View File

@ -0,0 +1,316 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/chart_data.dart';
import '../../../models/monitoring_status.dart';
import '../../../models/sensor_model.dart';
import '../../../repositories/iot_repository.dart';
import '../../../services/api_iot_service.dart';
import '../../auth/auth_notifier.dart';
import '../../auth/auth_state.dart';
import 'control_notifier.dart';
// Enums
enum ChartRange { daily, monthly }
enum ChartType { suhu, kelembapan }
// Supporting providers
final iotRepositoryProvider = Provider<IotRepository>((ref) {
final authState = ref.watch(authProvider);
String deviceId = 'esp32-01'; // Default fallback
if (authState is AuthSuccess && authState.user.deviceId != null) {
deviceId = authState.user.deviceId!;
}
return IotRepository(ApiIotService(deviceId));
});
final chartRangeProvider = StateProvider<ChartRange>(
(_) => ChartRange.daily,
);
final chartTypeProvider = StateProvider<ChartType>(
(_) => ChartType.suhu,
);
final chartOffsetProvider = StateProvider<int>(
(_) => 0,
);
// State
class MonitoringState {
final SensorModel? latest;
final bool isLoading;
final String? errorMessage;
final List<ChartData> history;
final List<DailyAverage> dailyHistory;
final List<DailyAverage> hourlyHistory;
const MonitoringState({
this.latest,
this.isLoading = false,
this.errorMessage,
this.history = const [],
this.dailyHistory = const [],
this.hourlyHistory = const [],
});
factory MonitoringState.initial() => const MonitoringState(isLoading: true);
double get temperature => latest?.temperature ?? 0;
double get humidity => latest?.humidity ?? 0;
MonitoringStatus get tempStatus {
if (temperature >= 33) return MonitoringStatus.danger;
if (temperature > 30) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
MonitoringStatus get humidStatus {
if (humidity < 65) return MonitoringStatus.danger;
if (humidity < 70) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
String get overallStatus {
final statuses = [tempStatus, humidStatus];
if (statuses.contains(MonitoringStatus.danger)) return 'Danger';
if (statuses.contains(MonitoringStatus.warning)) return 'Warning';
return 'Normal';
}
MonitoringState copyWith({
Object? latest = _sentinel,
bool? isLoading,
String? errorMessage,
List<ChartData>? history,
List<DailyAverage>? dailyHistory,
List<DailyAverage>? hourlyHistory,
}) {
return MonitoringState(
latest: latest == _sentinel ? this.latest : latest as SensorModel?,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage, // null = clear error
history: history ?? this.history,
dailyHistory: dailyHistory ?? this.dailyHistory,
hourlyHistory: hourlyHistory ?? this.hourlyHistory,
);
}
}
// Sentinel object untuk membedakan "tidak diisi" vs "diisi null" di copyWith
const _sentinel = Object();
// Notifier
class MonitoringNotifier extends StateNotifier<MonitoringState> {
final IotRepository _repo;
final Ref _ref;
Timer? _timer;
MonitoringNotifier(this._repo, this._ref) : super(MonitoringState.initial()) {
_startAutoRefresh();
}
Future<void> _startAutoRefresh() async {
await _initData();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchData());
}
Future<void> _initData() async {
state = state.copyWith(isLoading: true);
try {
final historyData = await _repo.getHistoryData();
final latest = historyData.isNotEmpty ? historyData.last : null;
final historyCharts = historyData.map((s) => ChartData(
timestamp: s.timestamp,
suhu: s.temperature,
kelembapan: s.humidity,
)).toList();
List<DailyAverage> dailyParsed = [];
List<DailyAverage> hourlyParsed = [];
try {
final dailyRaw = await _repo.getDailyHistory(days: 30);
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();
final hourlyRaw = await _repo.getHourlyHistory(days: 7);
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();
} catch (e) {
// Abaikan jika endpoint belum tersedia atau error (fallback chart akan kosong)
}
state = state.copyWith(
latest: latest,
history: historyCharts,
dailyHistory: dailyParsed,
hourlyHistory: hourlyParsed,
isLoading: false,
errorMessage: null,
);
} catch (e) {
state = state.copyWith(isLoading: false, errorMessage: e.toString());
}
}
Future<void> fetchData({bool showLoading = false}) async {
if (showLoading) {
state = state.copyWith(isLoading: true);
}
try {
final sensor = await _repo.getSensorData();
// Cek apakah data terbaru memiliki timestamp lebih baru dari yang ada di history terakhir
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;
}
}
List<ChartData> updatedHistory = List.from(state.history);
if (isNew) {
final newPoint = ChartData(
timestamp: sensor.timestamp,
suhu: sensor.temperature,
kelembapan: sensor.humidity,
);
updatedHistory.add(newPoint);
}
final trimmed = updatedHistory.length > 200
? updatedHistory.sublist(updatedHistory.length - 200)
: updatedHistory;
state = state.copyWith(
latest: sensor,
isLoading: false,
history: trimmed,
errorMessage: null,
);
// Bug #7: Sinkronisasi status pompa dari relay_state yang dikirim ESP32
// Ini memastikan UI selalu mencerminkan kondisi relay yang sesungguhnya
try {
_ref.read(controlProvider.notifier).syncRelayFromSensor(sensor.relayOn);
_ref.read(controlProvider.notifier).syncModeFromSensor(sensor.mode);
} catch (_) {
// Abaikan jika controlProvider belum siap (misal saat startup)
}
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
);
}
}
/// Ambil data history yang sudah difilter berdasarkan range waktu
List<ChartData> getFilteredData(ChartRange range) {
final now = DateTime.now();
if (range == ChartRange.daily) {
final startOfDay = DateTime(now.year, now.month, now.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
return state.history.where((d) =>
!d.timestamp.isBefore(startOfDay) &&
d.timestamp.isBefore(endOfDay),
).toList();
} else {
final firstDay = DateTime(now.year, now.month, 1);
final firstNext = DateTime(now.year, now.month + 1, 1);
return state.history.where((d) =>
d.timestamp.isAfter(firstDay.subtract(const Duration(seconds: 1))) &&
d.timestamp.isBefore(firstNext),
).toList();
}
}
void stopRefresh() => _timer?.cancel();
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
// Main provider
final monitoringProvider =
StateNotifierProvider<MonitoringNotifier, MonitoringState>(
(ref) => MonitoringNotifier(ref.read(iotRepositoryProvider), ref),
);
// Daily average provider
class DailyAverage {
final DateTime date;
final double? suhu;
final double? kelembapan;
const DailyAverage({required this.date, this.suhu, this.kelembapan});
}
final dailyAverageProvider = Provider<List<DailyAverage>>((ref) {
final range = ref.watch(chartRangeProvider);
final monitoring = ref.watch(monitoringProvider);
final offset = ref.watch(chartOffsetProvider);
final now = DateTime.now();
final int totalDays;
final DateTime Function(int) dateAt;
if (range == ChartRange.daily) {
totalDays = 24;
final targetDate = now.add(Duration(days: offset)); // offset = 0 is today, -1 is yesterday
dateAt = (i) => DateTime(targetDate.year, targetDate.month, targetDate.day, i);
} else {
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));
totalDays = lastDay.day;
dateAt = (i) => DateTime(targetMonth.year, targetMonth.month, i + 1);
}
final days = List.generate(totalDays, dateAt);
final lookup = <DateTime, DailyAverage>{};
if (range == ChartRange.daily) {
for (final item in monitoring.hourlyHistory) {
final key = DateTime(item.date.year, item.date.month, item.date.day, item.date.hour);
lookup[key] = item;
}
} else {
for (final item in monitoring.dailyHistory) {
final key = DateTime(item.date.year, item.date.month, item.date.day);
lookup[key] = item;
}
}
return days.map((date) {
final apiItem = lookup[date];
if (apiItem != null) {
return DailyAverage(date: date, suhu: apiItem.suhu, kelembapan: apiItem.kelembapan);
}
return DailyAverage(date: date);
}).toList();
});

View File

@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import '../../../core/app_theme.dart';
import '../../../core/utils/cron_utils.dart';
class AddScheduleSheet extends StatefulWidget {
final Function(TimeOfDay time, List<int> days, int durationS) onSave;
const AddScheduleSheet({super.key, required this.onSave});
@override
State<AddScheduleSheet> createState() => _AddScheduleSheetState();
}
class _AddScheduleSheetState extends State<AddScheduleSheet> {
DateTime _selectedTime = DateTime.now();
// Set of selected days, initially all days (0 = Sunday ... 6 = Saturday)
final Set<int> _selectedDays = {0, 1, 2, 3, 4, 5, 6};
// Durasi penyiraman dalam detik
int _durationSeconds = 30;
// Pilihan durasi cepat
final List<Map<String, dynamic>> _durationOptions = [
{'label': '15s', 'value': 15},
{'label': '30s', 'value': 30},
{'label': '1m', 'value': 60},
{'label': '2m', 'value': 120},
{'label': '5m', 'value': 300},
];
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: AppTheme.bgPage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle / Drag Indicator
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
// Title
const Text(
'Tambah Jadwal Penyiraman',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Time Picker (Wheel)
SizedBox(
height: 180,
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.time,
initialDateTime: _selectedTime,
use24hFormat: true,
onDateTimeChanged: (DateTime newTime) {
setState(() {
_selectedTime = newTime;
});
},
),
),
const SizedBox(height: 24),
// Day Selector
const Text(
'Ulangi pada hari:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(7, (index) {
// Urutan hari: Sen(1), Sel(2), Rab(3), Kam(4), Jum(5), Sab(6), Min(0)
// Kita ubah indeks UI agar Senin di kiri, Minggu di kanan
final int realDayIndex = (index + 1) % 7;
final isSelected = _selectedDays.contains(realDayIndex);
return GestureDetector(
onTap: () {
setState(() {
if (isSelected) {
// Jangan biarkan kosong, minimal 1 hari terpilih
if (_selectedDays.length > 1) {
_selectedDays.remove(realDayIndex);
}
} else {
_selectedDays.add(realDayIndex);
}
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? AppTheme.primary : Colors.grey.shade200,
),
alignment: Alignment.center,
child: Text(
CronUtils.shortDays[realDayIndex][0], // Ambil huruf pertama
style: TextStyle(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
);
}),
),
const SizedBox(height: 24),
// Duration Selector
const Text(
'Lama Penyiraman:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: _durationOptions.map((option) {
final int value = option['value'];
final bool isSelected = _durationSeconds == value;
return ChoiceChip(
label: Text(option['label']),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() {
_durationSeconds = value;
});
}
},
selectedColor: AppTheme.primary,
labelStyle: TextStyle(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
backgroundColor: Colors.grey.shade200,
);
}).toList(),
),
const SizedBox(height: 32),
// Save Button
ElevatedButton(
onPressed: () {
widget.onSave(
TimeOfDay(hour: _selectedTime.hour, minute: _selectedTime.minute),
_selectedDays.toList(),
_durationSeconds,
);
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'Simpan Jadwal',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 10),
],
),
),
),
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../providers/monitoring_provider.dart';
class ChartRangeSelector extends ConsumerWidget {
const ChartRangeSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(chartRangeProvider);
return LayoutBuilder(
builder: (context, constraints) {
final double width = constraints.maxWidth;
final double tabWidth = (width - 8) / ChartRange.values.length;
final selectedIndex = ChartRange.values.indexOf(selected);
return Container(
height: 44,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(14),
),
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: selectedIndex * tabWidth,
top: 0,
bottom: 0,
width: tabWidth,
child: Container(
decoration: BoxDecoration(
color: AppTheme.bgCard,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
),
Row(
children: ChartRange.values.map((range) {
final isSelected = selected == range;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => ref.read(chartRangeProvider.notifier).state = range,
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 250),
style: TextStyle(
fontFamily: 'Inter',
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
fontSize: 13,
color: isSelected
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
child: Text(
range == ChartRange.daily ? 'Harian' : 'Bulanan',
),
),
),
),
);
}).toList(),
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../providers/monitoring_provider.dart';
class ChartTypeSelector extends ConsumerWidget {
const ChartTypeSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(chartTypeProvider);
return LayoutBuilder(
builder: (context, constraints) {
final double width = constraints.maxWidth;
final double tabWidth = (width - 8) / ChartType.values.length;
final selectedIndex = ChartType.values.indexOf(selected);
return Container(
height: 48,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(30),
),
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: selectedIndex * tabWidth,
top: 0,
bottom: 0,
width: tabWidth,
child: Container(
decoration: BoxDecoration(
color: AppTheme.bgCard,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
),
),
Row(
children: ChartType.values.map((type) {
final isSelected = selected == type;
final label = type == ChartType.suhu ? 'Suhu' : 'Kelembapan';
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => ref.read(chartTypeProvider.notifier).state = type,
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 250),
style: TextStyle(
fontFamily: 'Inter',
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
fontSize: 14,
color: isSelected
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
child: Text(label),
),
),
),
);
}).toList(),
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'pump_card.dart';
import 'sensor_limit_card.dart';
class DeviceControlSection extends StatelessWidget {
final bool isPumpOn;
final bool isOnline;
final double suhuLimit;
final double kelembapanLimit;
final int? activePumpDuration;
final DateTime? pumpTurnedOnAt;
final VoidCallback onPumpToggle;
final Future<void> Function(double suhu, double kelembapan) onSaveThreshold;
const DeviceControlSection({
super.key,
required this.isPumpOn,
required this.isOnline,
required this.suhuLimit,
required this.kelembapanLimit,
this.activePumpDuration,
this.pumpTurnedOnAt,
required this.onPumpToggle,
required this.onSaveThreshold,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SensorLimitCard(
suhuLimit: suhuLimit,
kelembapanLimit: kelembapanLimit,
onSave: onSaveThreshold,
),
const SizedBox(height: 12),
PumpCard(
isPumpOn: isPumpOn,
isOnline: isOnline,
activePumpDuration: activePumpDuration,
pumpTurnedOnAt: pumpTurnedOnAt,
onToggle: onPumpToggle,
),
],
);
}
}

View File

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
class DeviceStatusCard extends StatelessWidget {
final bool isOnline;
final String deviceCode;
final DateTime? lastSeen;
const DeviceStatusCard({
super.key,
required this.isOnline,
required this.deviceCode,
this.lastSeen,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: AppTheme.cardDecoration(),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isOnline
? AppTheme.statusNormal.withValues(alpha: 0.12)
: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isOnline ? Icons.wifi : Icons.wifi_off,
color: isOnline ? AppTheme.statusNormal : AppTheme.statusDanger,
size: 26,
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isOnline ? 'Perangkat Online' : 'Perangkat Offline',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
'Kode: $deviceCode',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
if (!isOnline && lastSeen != null) ...[
const SizedBox(height: 2),
Text(
'Terakhir aktif: ${_formatDate(lastSeen!)}',
style: TextStyle(
fontSize: 11,
color: Colors.red.shade400,
fontWeight: FontWeight.w500,
),
),
],
],
),
const Spacer(),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: isOnline ? AppTheme.statusNormal : AppTheme.statusDanger,
shape: BoxShape.circle,
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0 && now.day == date.day) {
// Hari ini
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else if (diff.inDays < 2 && (now.day - date.day == 1 || now.day - date.day == -30)) {
// Kemarin
return 'Kemarin, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else {
// Tanggal penuh
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Ags', 'Sep', 'Okt', 'Nov', 'Des'];
return '${date.day} ${months[date.month - 1]} ${date.year}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}
}

View File

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
class GreetingSection extends StatelessWidget {
final String userName;
final String subtitle;
const GreetingSection({
super.key,
required this.userName,
this.subtitle = 'Selamat Datang di Kontrol Panel',
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 48,
height: 40,
child: CustomPaint(painter: _MushroomPainter()),
),
const SizedBox(height: 4),
Text(
'Halo, $userName!',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
Text(
subtitle,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
const Spacer(),
CircleAvatar(
radius: 24,
backgroundColor: AppTheme.accentLight,
child: const Icon(
Icons.person,
size: 26,
color: AppTheme.accent,
),
),
],
);
}
}
class _MushroomPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
// Batang
paint.color = const Color(0xFF795548);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
size.width * 0.35, size.height * 0.55,
size.width * 0.3, size.height * 0.4,
),
const Radius.circular(4),
),
paint,
);
// Tudung
paint.color = AppTheme.primary;
final path = Path()
..moveTo(size.width * 0.5, 0)
..quadraticBezierTo(size.width * 0.05, size.height * 0.3,
0, size.height * 0.6)
..lineTo(size.width, size.height * 0.6)
..quadraticBezierTo(size.width * 0.95, size.height * 0.3,
size.width * 0.5, 0)
..close();
canvas.drawPath(path, paint);
// Bintik
paint.color = Colors.white.withOpacity(0.6);
canvas.drawCircle(
Offset(size.width * 0.35, size.height * 0.3), size.width * 0.07, paint);
canvas.drawCircle(
Offset(size.width * 0.62, size.height * 0.22), size.width * 0.05, paint);
}
@override
bool shouldRepaint(covariant CustomPainter _) => false;
}

View File

@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
import '../../../models/monitoring_status.dart';
class MonitoringCard extends StatelessWidget {
final String value;
final String? unit;
final String subtitle;
final MonitoringStatus status;
final Gradient gradient;
const MonitoringCard({
super.key,
required this.value,
this.unit,
required this.subtitle,
required this.status,
required this.gradient,
});
Color get _statusColor {
switch (status) {
case MonitoringStatus.danger: return AppTheme.statusDanger;
case MonitoringStatus.warning: return AppTheme.statusWarning;
case MonitoringStatus.normal: return AppTheme.statusNormal;
}
}
String get _statusText {
switch (status) {
case MonitoringStatus.danger: return 'BAHAYA';
case MonitoringStatus.warning: return 'PERINGATAN';
case MonitoringStatus.normal: return 'NORMAL';
}
}
IconData get _bgIcon {
return subtitle.toLowerCase().contains('suhu')
? Icons.thermostat
: Icons.water_drop;
}
@override
Widget build(BuildContext context) {
return Container(
height: 160,
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
children: [
// Background Watermark Icon
Positioned(
right: -15,
bottom: -15,
child: Icon(
_bgIcon,
size: 130,
color: Colors.white.withValues(alpha: 0.12),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ikon di kiri atas agar tidak kosong
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
_bgIcon,
color: Colors.white,
size: 20,
),
),
// Badge Status di kanan atas
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
status == MonitoringStatus.normal
? Icons.check_circle_rounded
: Icons.warning_rounded,
size: 12,
color: _statusColor,
),
const SizedBox(width: 4),
Text(
_statusText,
style: TextStyle(
color: _statusColor,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 34,
fontWeight: FontWeight.bold,
),
),
if (unit != null)
Padding(
padding: const EdgeInsets.only(top: 8, left: 4),
child: Text(
unit!,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
],
),
const Spacer(),
SizedBox(
height: 34,
child: Align(
alignment: Alignment.bottomLeft,
child: Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
),
],
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,208 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../providers/monitoring_provider.dart';
class MonitoringChart extends ConsumerWidget {
const MonitoringChart({super.key});
String _getRangeLabel(ChartRange range, List<DailyAverage> dailyData) {
if (dailyData.isEmpty) return '-';
final first = dailyData.first.date;
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Ags', 'Sep', 'Okt', 'Nov', 'Des'];
if (range == ChartRange.daily) {
return '${first.day} ${months[first.month - 1]} ${first.year}';
} else {
return '${months[first.month - 1]} ${first.year}';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final range = ref.watch(chartRangeProvider);
final type = ref.watch(chartTypeProvider);
final offset = ref.watch(chartOffsetProvider);
final dailyData = ref.watch(dailyAverageProvider);
final values = dailyData
.map((d) => type == ChartType.suhu ? d.suhu : d.kelembapan)
.whereType<double>()
.toList();
final maxY = values.isEmpty
? 100.0
: (values.reduce((a, b) => a > b ? a : b) + 15).ceilToDouble();
final barColor =
type == ChartType.suhu ? Colors.deepOrange : Colors.teal;
Widget chart = BarChart(
BarChartData(
alignment: BarChartAlignment.spaceBetween,
maxY: maxY,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: (maxY / 4).ceilToDouble(),
getDrawingHorizontalLine: (_) => FlLine(
color: Colors.grey.withValues(alpha: 0.1),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: false, // Matikan interaksi sentuh karena angka sudah muncul di atas
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.transparent,
tooltipPadding: const EdgeInsets.only(bottom: 0),
tooltipMargin: 2,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
if (rod.toY == 0) return null; // Sembunyikan untuk batang kosong
final val = rod.toY.round();
return BarTooltipItem(
'$val',
TextStyle(
color: barColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
);
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, _) {
final i = value.toInt();
if (i < 0 || i >= dailyData.length) {
return const SizedBox();
}
final date = dailyData[i].date;
final label = range == ChartRange.daily
? '${date.hour.toString().padLeft(2, '0')}:00'
: date.day.toString();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
label,
style: const TextStyle(
fontSize: 11, color: Colors.grey),
),
);
},
),
),
),
barGroups: List.generate(dailyData.length, (i) {
final v = type == ChartType.suhu ? dailyData[i].suhu : dailyData[i].kelembapan;
final val = v != null ? v.roundToDouble() : 0.0;
return BarChartGroupData(
x: i,
showingTooltipIndicators: v != null ? [0] : [], // Selalu tampilkan angka jika ada data
barRods: [
BarChartRodData(
toY: val,
width: range == ChartRange.daily ? 16 : 12,
borderRadius: BorderRadius.circular(6),
color: v == null ? Colors.grey.shade200 : barColor,
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxY,
color: Colors.grey.shade100, // Background bar semu
),
),
],
);
}),
),
);
if (range == ChartRange.monthly || range == ChartRange.daily) {
chart = SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: dailyData.length * (range == ChartRange.daily ? 40.0 : 40.0),
child: chart,
),
);
}
return Container(
padding: const EdgeInsets.all(20),
decoration: AppTheme.cardDecoration(radius: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Monitoring Chart',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: barColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
type == ChartType.suhu ? '°C (Suhu)' : '% (Kelembapan)',
style: TextStyle(
fontSize: 12, color: barColor, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 16),
// Navigasi Waktu (Kalender)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: () => ref.read(chartOffsetProvider.notifier).state--,
icon: const Icon(Icons.chevron_left, color: AppTheme.textSecondary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
Text(
_getRangeLabel(range, dailyData),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
IconButton(
onPressed: offset < 0
? () => ref.read(chartOffsetProvider.notifier).state++
: null, // Disable jika sudah di minggu/bulan ini
icon: Icon(
Icons.chevron_right,
color: offset < 0 ? AppTheme.textSecondary : Colors.grey.shade300
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 30), // Beri jarak extra untuk angka di atas bar
SizedBox(height: 220, child: chart),
],
),
);
}
}

View File

@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_theme.dart';
import '../providers/control_notifier.dart';
import '../providers/control_state.dart';
class OperationModeCard extends ConsumerWidget {
const OperationModeCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final control = ref.watch(controlProvider);
final mode = control.deviceMode;
final isLoading = control.isModeLoading;
return Container(
padding: const EdgeInsets.all(20),
decoration: AppTheme.cardDecoration(radius: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.tune_rounded, color: AppTheme.primary, size: 20),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode Operasi',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
Text(
'Tentukan bagaimana perangkat bekerja',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
if (isLoading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 16),
_ModeOptions(currentMode: mode, isLoading: isLoading),
],
),
);
}
}
class _ModeOptions extends ConsumerWidget {
final DeviceMode currentMode;
final bool isLoading;
const _ModeOptions({required this.currentMode, required this.isLoading});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
children: DeviceMode.values.where((m) => m != DeviceMode.offline).map((mode) {
final isSelected = currentMode == mode;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: _ModeTile(
mode: mode,
isSelected: isSelected,
isLoading: isLoading,
onTap: () => ref.read(controlProvider.notifier).setDeviceMode(mode),
),
),
);
}).toList(),
);
}
}
class _ModeTile extends StatelessWidget {
final DeviceMode mode;
final bool isSelected;
final bool isLoading;
final VoidCallback onTap;
const _ModeTile({
required this.mode,
required this.isSelected,
required this.isLoading,
required this.onTap,
});
(IconData, Color, Color, String) get _modeStyle {
switch (mode) {
case DeviceMode.auto_:
return (Icons.auto_mode_rounded, const Color(0xFF10B981), const Color(0xFFD1FAE5), 'Otomatis');
case DeviceMode.manual:
return (Icons.pan_tool_alt_rounded, const Color(0xFF3B82F6), const Color(0xFFDBEAFE), 'Manual');
case DeviceMode.offline:
return (Icons.wifi_off_rounded, const Color(0xFFF59E0B), const Color(0xFFFEF3C7), 'Offline');
}
}
@override
Widget build(BuildContext context) {
final (icon, activeColor, bgColor, label) = _modeStyle;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: isSelected ? activeColor : Colors.grey.shade100,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? activeColor : Colors.grey.shade200,
width: isSelected ? 0 : 1,
),
boxShadow: isSelected
? [BoxShadow(color: activeColor.withValues(alpha: 0.35), blurRadius: 10, offset: const Offset(0, 4))]
: [],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: isLoading ? null : onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isSelected ? Colors.white.withValues(alpha: 0.25) : bgColor,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 20,
color: isSelected ? Colors.white : activeColor,
),
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: isSelected ? Colors.white : AppTheme.textSecondary,
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
class PumpCard extends StatefulWidget {
final bool isPumpOn;
final bool isOnline;
final int? activePumpDuration;
final DateTime? pumpTurnedOnAt;
final VoidCallback onToggle;
const PumpCard({
super.key,
required this.isPumpOn,
required this.isOnline,
this.activePumpDuration,
this.pumpTurnedOnAt,
required this.onToggle,
});
@override
State<PumpCard> createState() => _PumpCardState();
}
class _PumpCardState extends State<PumpCard> {
Timer? _timer;
int _remainingSeconds = 0;
@override
void initState() {
super.initState();
_updateTimer();
}
@override
void didUpdateWidget(PumpCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isPumpOn != oldWidget.isPumpOn ||
widget.pumpTurnedOnAt != oldWidget.pumpTurnedOnAt ||
widget.activePumpDuration != oldWidget.activePumpDuration) {
_updateTimer();
}
}
void _updateTimer() {
_timer?.cancel();
if (widget.isPumpOn && widget.activePumpDuration != null && widget.pumpTurnedOnAt != null) {
final elapsed = DateTime.now().difference(widget.pumpTurnedOnAt!).inSeconds;
final remaining = widget.activePumpDuration! - elapsed;
if (remaining > 0) {
setState(() {
_remainingSeconds = remaining;
});
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
final currentElapsed = DateTime.now().difference(widget.pumpTurnedOnAt!).inSeconds;
final currentRemaining = widget.activePumpDuration! - currentElapsed;
if (currentRemaining > 0) {
setState(() {
_remainingSeconds = currentRemaining;
});
} else {
setState(() {
_remainingSeconds = 0;
});
timer.cancel();
}
});
} else {
setState(() {
_remainingSeconds = 0;
});
}
} else {
setState(() {
_remainingSeconds = 0;
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String get _formattedTime {
final m = (_remainingSeconds ~/ 60).toString().padLeft(2, '0');
final s = (_remainingSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: widget.isOnline ? AppTheme.accent : Colors.grey.shade400,
borderRadius: BorderRadius.circular(16),
boxShadow: [
if (widget.isOnline)
BoxShadow(
color: AppTheme.accent.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.water_drop, color: Colors.white, size: 20),
),
const SizedBox(width: 12),
const Text(
'Pompa Air',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
],
),
const SizedBox(height: 12),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
!widget.isOnline
? 'Status: Offline'
: (widget.isPumpOn
? 'Status: Menyala${_remainingSeconds > 0 ? ' ($_formattedTime)' : ''}'
: 'Status: Mati'),
key: ValueKey('${widget.isPumpOn}_${widget.isOnline}_$_remainingSeconds'),
style: TextStyle(
color: widget.isPumpOn || !widget.isOnline ? Colors.white : Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: widget.isOnline ? widget.onToggle : () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tidak dapat mengontrol pompa saat alat offline'),
backgroundColor: Colors.orange,
),
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.isPumpOn
? Colors.white.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.15),
border: Border.all(color: Colors.white, width: 2.5),
),
child: Icon(
Icons.power_settings_new,
color: widget.isPumpOn ? Colors.white : Colors.white70,
size: 32,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
class ScheduleItem extends StatelessWidget {
final String id;
final String time;
final String repeat;
final bool isEnabled;
final void Function(String id, bool value) onToggle;
final void Function(String id) onRemove;
const ScheduleItem({
super.key,
required this.id,
required this.time,
required this.repeat,
required this.isEnabled,
required this.onToggle,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Time & label
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
time,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: isEnabled
? AppTheme.textPrimary
: AppTheme.textHint,
letterSpacing: 0.5,
),
),
Text(
repeat,
style: const TextStyle(
fontSize: 12, color: AppTheme.textHint),
),
],
),
const Spacer(),
Switch(
value: isEnabled,
onChanged: (val) => onToggle(id, val),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
onPressed: () => onRemove(id),
),
],
),
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
import '../providers/control_state.dart';
import 'schedule_item.dart';
class ScheduleSection extends StatelessWidget {
final List<WateringSchedule> schedules;
final VoidCallback onAddSchedule;
final void Function(String id, bool value) onToggleSchedule;
final void Function(String id) onRemoveSchedule;
const ScheduleSection({
super.key,
required this.schedules,
required this.onAddSchedule,
required this.onToggleSchedule,
required this.onRemoveSchedule,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: AppTheme.cardDecoration(),
child: Column(
children: [
_Header(onAdd: onAddSchedule),
if (schedules.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text(
'Belum ada jadwal. Tap + untuk menambah.',
style: TextStyle(
color: AppTheme.textHint, fontSize: 13),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: schedules.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: Colors.grey.shade200,
indent: 16,
endIndent: 16,
),
itemBuilder: (_, i) {
final s = schedules[i];
return ScheduleItem(
id: s.id,
time: s.formattedTime,
repeat: s.repeat,
isEnabled: s.isEnabled,
onToggle: onToggleSchedule,
onRemove: onRemoveSchedule,
);
},
),
],
),
);
}
}
class _Header extends StatelessWidget {
final VoidCallback onAdd;
const _Header({required this.onAdd});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.accent,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
const Icon(Icons.calendar_month, color: Colors.white, size: 20),
const SizedBox(width: 10),
const Text(
'Jadwal Penyiraman',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const Spacer(),
GestureDetector(
onTap: onAdd,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
shape: BoxShape.circle,
),
child: const Icon(Icons.add, color: Colors.white, size: 18),
),
),
],
),
);
}
}

View File

@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import '../../../core/app_theme.dart';
class SensorLimitCard extends StatefulWidget {
final double suhuLimit;
final double kelembapanLimit;
final Future<void> Function(double suhu, double kelembapan) onSave;
const SensorLimitCard({
super.key,
required this.suhuLimit,
required this.kelembapanLimit,
required this.onSave,
});
// Nilai default untuk tanaman jamur
static const double defaultSuhu = 28.0;
static const double defaultKelembapan = 90.0;
@override
State<SensorLimitCard> createState() => _SensorLimitCardState();
}
class _SensorLimitCardState extends State<SensorLimitCard> {
late double _draftSuhu;
late double _draftKelembapan;
bool _isSaving = false;
@override
void initState() {
super.initState();
_draftSuhu = widget.suhuLimit;
_draftKelembapan = widget.kelembapanLimit;
}
// Sinkronisasi draft jika nilai dari provider berubah dari luar
// (misal setelah reload awal / restore dari server)
@override
void didUpdateWidget(SensorLimitCard oldWidget) {
super.didUpdateWidget(oldWidget);
final serverChanged = oldWidget.suhuLimit != widget.suhuLimit ||
oldWidget.kelembapanLimit != widget.kelembapanLimit;
final draftUnchanged = _draftSuhu == oldWidget.suhuLimit &&
_draftKelembapan == oldWidget.kelembapanLimit;
// Update draft hanya jika user belum mengedit
if (serverChanged && draftUnchanged) {
_draftSuhu = widget.suhuLimit;
_draftKelembapan = widget.kelembapanLimit;
}
}
bool get _hasChanges =>
_draftSuhu != widget.suhuLimit ||
_draftKelembapan != widget.kelembapanLimit;
bool get _isDefault =>
_draftSuhu == SensorLimitCard.defaultSuhu &&
_draftKelembapan == SensorLimitCard.defaultKelembapan;
void _applyDefault() {
setState(() {
_draftSuhu = SensorLimitCard.defaultSuhu;
_draftKelembapan = SensorLimitCard.defaultKelembapan;
});
}
void _cancel() {
setState(() {
_draftSuhu = widget.suhuLimit;
_draftKelembapan = widget.kelembapanLimit;
});
}
Future<void> _save() async {
setState(() => _isSaving = true);
await widget.onSave(_draftSuhu, _draftKelembapan);
if (mounted) setState(() => _isSaving = false);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.accent,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.accent.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
const Icon(Icons.tune, color: Colors.white, size: 18),
const SizedBox(width: 6),
const Expanded(
child: Text(
'Batasan Sensor Otomatis',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
),
// Tombol Default tampil jika nilai draft bukan default
if (!_isDefault)
GestureDetector(
onTap: _applyDefault,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Default',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
const SizedBox(height: 16),
// Slider Suhu
_SliderRow(
icon: Icons.thermostat,
label: 'Suhu Maks',
value: _draftSuhu,
displayValue: '${_draftSuhu.round()}°C',
min: 15,
max: 50,
divisions: 35,
onChanged: (v) => setState(() => _draftSuhu = v),
),
const SizedBox(height: 8),
// Slider Kelembapan
_SliderRow(
icon: Icons.water_drop_outlined,
label: 'Kelembapan Min',
value: _draftKelembapan,
displayValue: '${_draftKelembapan.round()}%',
min: 50,
max: 100,
divisions: 50,
onChanged: (v) => setState(() => _draftKelembapan = v),
),
// Tombol Aksi (hanya tampil jika ada perubahan)
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _hasChanges
? Padding(
padding: const EdgeInsets.only(top: 14),
child: Row(
children: [
// Tombol Batal
Expanded(
child: OutlinedButton(
onPressed: _isSaving ? null : _cancel,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text(
'Batal',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(width: 10),
// Tombol Simpan
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: _isSaving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppTheme.accent,
padding: const EdgeInsets.symmetric(vertical: 10),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: _isSaving
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, size: 16),
SizedBox(width: 6),
Text(
'Simpan',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.bold),
),
],
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
),
);
}
}
// Slider Row
class _SliderRow extends StatelessWidget {
final IconData icon;
final String label;
final double value;
final String displayValue;
final double min;
final double max;
final int divisions;
final ValueChanged<double> onChanged;
const _SliderRow({
required this.icon,
required this.label,
required this.value,
required this.displayValue,
required this.min,
required this.max,
required this.divisions,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Colors.white70, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
Text(
displayValue,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white24,
trackHeight: 3,
),
child: Slider(
value: value,
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
),
),
],
);
}
}

View File

@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import '../../core/routes.dart';
class LandingPage extends StatefulWidget {
const LandingPage({super.key});
@override
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();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@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
Hero(
tag: "appLogo",
child: Image.asset(
'assets/images/logo_jamur.png',
height: 140,
width: 140,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
/// TITLE
const Text(
"Selamat Datang di\nJamur Tiram Antirogo",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
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,
style: TextStyle(
fontSize: 14,
color: Colors.black54,
height: 1.5,
),
),
const SizedBox(height: 50),
/// BUTTON SECTION (Slide Up)
SlideTransition(
position: _slideAnimation,
child: Column(
children: [
/// LOGIN BUTTON
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3E5F6F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
onPressed: () {
Navigator.pushNamed(context, AppRoutes.login);
},
child: const Text(
"Masuk",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
const SizedBox(height: 16),
/// REGISTER BUTTON
SizedBox(
width: double.infinity,
height: 55,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFF3E5F6F),
width: 1.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
onPressed: () {
Navigator.pushNamed(context, AppRoutes.register);
},
child: const Text(
"Belum ada akun? Daftar dulu",
style: TextStyle(
fontSize: 16,
color: Color(0xFF3E5F6F),
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
const Spacer(),
/// TERMS
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: Text(
"Dengan masuk atau mendaftar, kamu menyetujui\nKetentuan Layanan dan Kebijakan Privasi",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Colors.black45,
),
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/routes.dart';
import '../auth/auth_notifier.dart';
import '../auth/auth_state.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_navigate();
});
}
Future<void> _navigate() async {
// Jalankan delay splash screen
await Future.delayed(const Duration(milliseconds: 1500));
// Mengecek apakah sudah login
await ref.read(authProvider.notifier).checkAuthStatus();
if (!mounted) return;
final state = ref.read(authProvider);
if (state is AuthSuccess) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
} else {
Navigator.of(context).pushReplacementNamed(AppRoutes.landing); // kembali ke landing/login
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Hero(
tag: 'appLogo',
child: Image.asset(
'assets/images/logo_jamur.png',
height: 180,
width: 180,
fit: BoxFit.contain,
),
),
),
);
}
}

49
lib/main.dart Normal file
View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';
import 'core/app_theme.dart';
import 'core/env.dart';
import 'core/routes.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Inisialisasi Supabase
await Supabase.initialize(
url: Env.supabaseUrl,
anonKey: Env.supabaseAnonKey,
);
// Inisialisasi OneSignal
OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
OneSignal.initialize(Env.oneSignalAppId);
OneSignal.Notifications.requestPermission(true);
runApp(const ProviderScope(child: JtaApp()));
}
class JtaApp extends StatelessWidget {
const JtaApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'JTA Jamur Tiram Antirogo',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
initialRoute: AppRoutes.splash,
onGenerateRoute: AppRoutes.generateRoute,
builder: (context, child) {
// Nonaktifkan text scaling sistem agar UI tidak rusak
return MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.noScaling),
child: child!,
);
},
);
}
}

View File

@ -0,0 +1,12 @@
/// Model untuk satu titik data pada grafik monitoring.
class ChartData {
final DateTime timestamp;
final double suhu;
final double kelembapan;
const ChartData({
required this.timestamp,
required this.suhu,
required this.kelembapan,
});
}

View File

@ -0,0 +1,2 @@
/// Status kondisi sensor: normal, warning, atau danger.
enum MonitoringStatus { normal, warning, danger }

View File

@ -0,0 +1,58 @@
/// Model untuk data sensor IoT (suhu & kelembapan).
class SensorModel {
final double temperature;
final double humidity;
final bool relayOn;
final String mode;
final DateTime timestamp;
const SensorModel({
required this.temperature,
required this.humidity,
this.relayOn = false,
this.mode = 'auto',
required this.timestamp,
});
factory SensorModel.fromJson(Map<String, dynamic> json) {
return SensorModel(
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.0,
humidity: (json['humidity'] as num?)?.toDouble() ?? 0.0,
relayOn: json['relay_state'] as bool? ?? false,
mode: json['mode'] as String? ?? 'auto',
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: (json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: DateTime.now()),
);
}
Map<String, dynamic> toJson() => {
'temperature': temperature,
'humidity': humidity,
'relay_state': relayOn,
'mode': mode,
'timestamp': timestamp.toIso8601String(),
};
SensorModel copyWith({
double? temperature,
double? humidity,
bool? relayOn,
String? mode,
DateTime? timestamp,
}) {
return SensorModel(
temperature: temperature ?? this.temperature,
humidity: humidity ?? this.humidity,
relayOn: relayOn ?? this.relayOn,
mode: mode ?? this.mode,
timestamp: timestamp ?? this.timestamp,
);
}
@override
String toString() =>
'SensorModel(temp: $temperature°C, hum: $humidity%, relay: $relayOn, mode: $mode, ts: $timestamp)';
}

View File

@ -0,0 +1,53 @@
/// Model untuk data pengguna yang login.
class UserModel {
final String id;
final String name;
final String email;
final String? token;
final String? deviceId;
const UserModel({
required this.id,
required this.name,
required this.email,
this.token,
this.deviceId,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'].toString(),
name: json['name'] as String,
email: json['email'] as String,
token: json['token'] as String?,
deviceId: json['device_id'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
if (token != null) 'token': token,
if (deviceId != null) 'device_id': deviceId,
};
UserModel copyWith({
String? id,
String? name,
String? email,
String? token,
String? deviceId,
}) {
return UserModel(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
token: token ?? this.token,
deviceId: deviceId ?? this.deviceId,
);
}
@override
String toString() => 'UserModel(id: $id, name: $name, email: $email, deviceId: $deviceId)';
}

View File

@ -0,0 +1,27 @@
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.
class AuthRepository {
final AuthService _service;
AuthRepository(this._service);
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());
}
Future<void> logout() {
return _service.logout();
}
/// Cek apakah ada sesi login yang masih aktif (untuk auto-login saat buka app)
Future<UserModel?> checkSession() {
return _service.checkSession();
}
}

View File

@ -0,0 +1,35 @@
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.
class IotRepository {
final IotService _service;
IotRepository(this._service);
Future<SensorModel> getSensorData() {
return _service.fetchSensorData();
}
Future<List<SensorModel>> getHistoryData() {
if (_service is ApiIotService) {
return _service.fetchHistory();
}
return Future.value([]);
}
Future<List<Map<String, dynamic>>> getDailyHistory({int days = 30}) {
if (_service is ApiIotService) {
return _service.fetchDailyHistory(days: days);
}
return Future.value([]);
}
Future<List<Map<String, dynamic>>> getHourlyHistory({int days = 7}) {
if (_service is ApiIotService) {
return _service.fetchHourlyHistory(days: days);
}
return Future.value([]);
}
}

View File

@ -0,0 +1,127 @@
import 'package:dio/dio.dart';
import '../core/api_config.dart';
class ApiControlService {
final Dio _dio;
final String deviceId;
ApiControlService(this.deviceId, [Dio? dio]) : _dio = dio ?? Dio();
// Mode Operasi
Future<String> getMode() async {
try {
final res = await _dio.get('${ApiConfig.baseUrl}/mode/$deviceId');
if (res.statusCode == 200 && res.data != null) {
return res.data['current_mode'] ?? 'auto';
}
return 'auto';
} catch (e) {
return 'auto';
}
}
Future<void> setMode(String mode) async {
try {
await _dio.post(
'${ApiConfig.baseUrl}/mode/$deviceId',
data: {'mode': mode},
);
} catch (e) {
throw Exception('Gagal mengubah mode: $e');
}
}
Future<Map<String, dynamic>> getThreshold() async {
try {
final res = await _dio.get('${ApiConfig.baseUrl}/threshold/$deviceId');
if (res.statusCode == 200 && res.data != null) {
return res.data;
}
throw Exception('Gagal memuat threshold');
} catch (e) {
throw Exception('Error: $e');
}
}
Future<void> updateThreshold(double tempMax, double humMax) async {
try {
await _dio.post(
'${ApiConfig.baseUrl}/threshold/$deviceId',
data: {
'temp_max': tempMax,
'hum_max': humMax,
},
);
} catch (e) {
throw Exception('Gagal update threshold: $e');
}
}
// Manual Control (Pompa)
Future<void> siramManual(int durationSeconds) async {
try {
await _dio.post(
'${ApiConfig.baseUrl}/schedule/$deviceId/now',
data: {'duration_s': durationSeconds},
);
} catch (e) {
throw Exception('Gagal menyalakan pompa: $e');
}
}
Future<void> stopPump() async {
try {
await _dio.post('${ApiConfig.baseUrl}/schedule/$deviceId/stop');
} catch (e) {
throw Exception('Gagal mematikan pompa: $e');
}
}
// Schedules (Jadwal)
Future<List<dynamic>> getSchedules() async {
try {
final res = await _dio.get('${ApiConfig.baseUrl}/schedule/$deviceId');
if (res.statusCode == 200 && res.data != null) {
return res.data as List<dynamic>;
}
return [];
} catch (e) {
throw Exception('Gagal memuat jadwal: $e');
}
}
Future<Map<String, dynamic>> addSchedule(String cron, int durationSeconds, String label) async {
try {
final res = await _dio.post(
'${ApiConfig.baseUrl}/schedule/$deviceId',
data: {
'cron': cron,
'duration_s': durationSeconds,
'label': label,
},
);
if (res.statusCode == 200 || res.statusCode == 201) {
return res.data;
}
throw Exception('Gagal menambah jadwal');
} catch (e) {
throw Exception('Error tambah jadwal: $e');
}
}
Future<void> deleteSchedule(String id) async {
try {
await _dio.delete('${ApiConfig.baseUrl}/schedule/$id');
} catch (e) {
throw Exception('Gagal menghapus jadwal: $e');
}
}
Future<void> toggleSchedule(String id) async {
try {
await _dio.patch('${ApiConfig.baseUrl}/schedule/$id/toggle');
} catch (e) {
throw Exception('Gagal toggle jadwal: $e');
}
}
}

View File

@ -0,0 +1,113 @@
import 'package:dio/dio.dart';
import '../models/sensor_model.dart';
import '../core/api_config.dart';
import 'iot_service.dart';
/// Implementasi IotService yang terhubung ke Backend Express
class ApiIotService implements IotService {
final Dio _dio;
final String deviceId;
ApiIotService(this.deviceId, [Dio? dio]) : _dio = dio ?? Dio();
@override
Future<SensorModel> fetchSensorData() async {
try {
// Ambil beberapa data terbaru agar bisa skip entry yang null (misal dari relay log)
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId?limit=30');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
if (data.isEmpty) {
return SensorModel(temperature: 0, humidity: 0, timestamp: DateTime.now());
}
final firstItem = data.first;
// Cari entry pertama yang punya nilai sensor valid (bukan null)
final validItem = data.firstWhere(
(item) => item['temperature'] != null && item['humidity'] != null,
orElse: () => null,
);
if (validItem != null) {
return SensorModel(
temperature: (validItem['temperature'] as num).toDouble(),
humidity: (validItem['humidity'] as num).toDouble(),
relayOn: firstItem['relay_state'] as bool? ?? false,
mode: firstItem['mode'] as String? ?? 'auto',
timestamp: DateTime.parse(firstItem['created_at']).toLocal(),
);
} else {
// Belum ada data sensor valid kembalikan placeholder tapi relay state tetap valid
return SensorModel(
temperature: 0,
humidity: 0,
relayOn: firstItem['relay_state'] as bool? ?? false,
mode: firstItem['mode'] as String? ?? 'auto',
timestamp: DateTime.parse(firstItem['created_at']).toLocal()
);
}
} else {
throw Exception('Failed to fetch sensor data: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching data: $e');
}
}
// Fungsi tambahan untuk mengambil histori secara lengkap
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)
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(),
relayOn: item['relay_state'] as bool? ?? false,
mode: item['mode'] as String? ?? 'auto',
timestamp: DateTime.parse(item['created_at']).toLocal(),
)).toList();
} else {
throw Exception('Failed to fetch history: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching history: $e');
}
}
// Fungsi untuk mengambil agregasi harian
Future<List<Map<String, dynamic>>> fetchDailyHistory({int days = 30}) async {
try {
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId/daily?days=$days');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.cast<Map<String, dynamic>>();
} else {
throw Exception('Failed to fetch daily history: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching daily history: $e');
}
}
// Fungsi untuk mengambil agregasi per jam
Future<List<Map<String, dynamic>>> fetchHourlyHistory({int days = 7}) async {
try {
final response = await _dio.get('${ApiConfig.baseUrl}/history/$deviceId/hourly?days=$days');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.cast<Map<String, dynamic>>();
} else {
throw Exception('Failed to fetch hourly history: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching hourly history: $e');
}
}
}

View File

@ -0,0 +1,10 @@
import '../models/user_model.dart';
/// Abstract contract untuk auth service.
/// Swap implementasi ini ke SupabaseAuthService / ApiAuthService kapanpun siap.
abstract class AuthService {
Future<UserModel> login(String email, String password);
Future<UserModel> register(String name, String email, String password, String claimCode);
Future<void> logout();
Future<UserModel?> checkSession(); // Cek sesi yang tersimpan saat app dibuka
}

View File

@ -0,0 +1,6 @@
import '../models/sensor_model.dart';
/// Abstract contract untuk IoT service.
abstract class IotService {
Future<SensorModel> fetchSensorData();
}

View File

@ -0,0 +1,154 @@
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user_model.dart';
import '../core/api_config.dart';
import 'auth_service.dart';
/// Implementasi AuthService menggunakan Supabase
class SupabaseAuthService implements AuthService {
final supabase.SupabaseClient _client = supabase.Supabase.instance.client;
final Dio _dio = Dio();
@override
Future<UserModel> login(String email, String password) async {
try {
final response = await _client.auth.signInWithPassword(
email: email,
password: password,
);
final user = response.user;
if (user == null) {
throw Exception('Login gagal: User tidak ditemukan');
}
// Ambil device_id dari backend
String? deviceId;
try {
final deviceRes = await _dio.get('${ApiConfig.baseUrl}/device/my-device/${user.id}');
if (deviceRes.statusCode == 200) {
deviceId = deviceRes.data['device_id'];
}
} catch (e) {
print('Gagal mengambil info device: $e');
// Tidak melempar error agar login tetap sukses meskipun belum ada device
}
// Simpan device_id ke cache lokal agar auto-login selanjutnya super cepat
if (deviceId != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('cached_device_id', deviceId);
}
return UserModel(
id: user.id,
name: user.userMetadata?['name'] ?? user.email?.split('@').first ?? 'User',
email: user.email ?? email,
token: response.session?.accessToken ?? '',
deviceId: deviceId,
);
} on supabase.AuthException catch (e) {
if (e.message.contains('Invalid login credentials')) {
throw Exception('Email atau password salah.');
} else if (e.message.contains('Email not confirmed')) {
throw Exception('Email belum diverifikasi.');
}
throw Exception(e.message);
} catch (e) {
throw Exception('Terjadi kesalahan koneksi atau server.');
}
}
@override
Future<UserModel> register(String name, String email, String password, String claimCode) async {
try {
final response = await _client.auth.signUp(
email: email,
password: password,
data: {'name': name},
);
final user = response.user;
if (user == null) {
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,
);
} on supabase.AuthException catch (e) {
if (e.message.contains('already registered')) {
throw Exception('Email sudah terdaftar.');
} else if (e.message.contains('weak')) {
throw Exception('Password terlalu lemah.');
}
throw Exception(e.message);
} catch (e) {
throw Exception('Terjadi kesalahan koneksi atau server.');
}
}
@override
Future<void> logout() async {
// Hapus sesi Supabase
await _client.auth.signOut();
// Hapus cache device_id agar tidak terbawa jika user login pakai akun lain
final prefs = await SharedPreferences.getInstance();
await prefs.remove('cached_device_id');
}
// Fungsi tambahan untuk memulihkan sesi
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)
String? deviceId;
try {
final prefs = await SharedPreferences.getInstance();
deviceId = prefs.getString('cached_device_id');
} catch (e) {
print('Gagal membaca cache device_id: $e');
}
return UserModel(
id: user.id,
name: user.userMetadata?['name'] ?? user.email?.split('@').first ?? 'User',
email: user.email ?? '',
token: session.accessToken,
deviceId: deviceId,
);
}
return null;
}
}

914
pubspec.lock Normal file
View File

@ -0,0 +1,914 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_links:
dependency: transitive
description:
name: app_links
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
url: "https://pub.dev"
source: hosted
version: "3.4.1"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d"
url: "https://pub.dev"
source: hosted
version: "0.66.2"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_native_splash:
dependency: "direct main"
description:
name: flutter_native_splash
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
functions_client:
dependency: transitive
description:
name: functions_client
sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gotrue:
dependency: transitive
description:
name: gotrue
sha256: "7a4172601553e61716f5c3dd243aa3297e13308e07eb85b7853c941ba585dcf5"
url: "https://pub.dev"
source: hosted
version: "2.20.0"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
jwt_decode:
dependency: transitive
description:
name: jwt_decode
sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb
url: "https://pub.dev"
source: hosted
version: "0.3.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
onesignal_flutter:
dependency: "direct main"
description:
name: onesignal_flutter
sha256: "2f9508817cc17fe7088dedc5eaa9b06c8db7b149dcfd4c8736b918ea50b9fcbf"
url: "https://pub.dev"
source: hosted
version: "5.5.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
postgrest:
dependency: transitive
description:
name: postgrest
sha256: "9d61b3d4a88fcf9424d400127c54d49ed1b56ec30838fc0a33a64f31d4e694cc"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
realtime_client:
dependency: transitive
description:
name: realtime_client
sha256: "7dfccf372d2f55aacfeefb6186f65a06f3ffae383fe042dbeef9d85d33487576"
url: "https://pub.dev"
source: hosted
version: "2.7.3"
retry:
dependency: transitive
description:
name: retry
sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
storage_client:
dependency: transitive
description:
name: storage_client
sha256: "4801e8ca219a35e51cbb30589aba5306667ae8935b792504595a45273cef0b18"
url: "https://pub.dev"
source: hosted
version: "2.5.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
supabase:
dependency: transitive
description:
name: supabase
sha256: "40e5a8833c8834e140ef53b60a6181849667eba9ca125acb7f8e24c6a769d418"
url: "https://pub.dev"
source: hosted
version: "2.10.6"
supabase_flutter:
dependency: "direct main"
description:
name: supabase_flutter
sha256: c02ce58abcaf86cb8055ad40bfd98bbf5b93fed3b5b56b8220d88ed03842818b
url: "https://pub.dev"
source: hosted
version: "2.12.4"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
url: "https://pub.dev"
source: hosted
version: "15.1.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
yet_another_json_isolate:
dependency: transitive
description:
name: yet_another_json_isolate
sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
sdks:
dart: ">=3.10.9 <4.0.0"
flutter: ">=3.38.4"

115
pubspec.yaml Normal file
View File

@ -0,0 +1,115 @@
name: jta
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.10.9
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
fl_chart: ^0.66.0
flutter_native_splash: ^2.4.4
dio: ^5.4.0
supabase_flutter: ^2.8.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
shared_preferences: ^2.5.5
onesignal_flutter: ^5.5.1
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/images/logo_jamur.png
- assets/images/jamur.png
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_native_splash:
color: "#FFFFFF"
android: true
ios: true
android_12:
image: assets/images/logo_jamur.png
icon_background_color: "#FFFFFF"
android_gravity: center
ios_content_mode: center
flutter_launcher_icons:
android: "launcher_icon"
ios: false
image_path: "assets/images/jamur.png"
min_sdk_android: 21

30
test/widget_test.dart Normal file
View File

@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jta/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const JtaApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}