Initial commit
|
|
@ -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
|
||||
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = "../.."
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.example.jta
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
After Width: | Height: | Size: 892 KiB |
|
After Width: | Height: | Size: 69 B |
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
After Width: | Height: | Size: 892 KiB |
|
After Width: | Height: | Size: 69 B |
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
After Width: | Height: | Size: 878 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
**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)
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
|
@ -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:
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import 'env.dart';
|
||||
|
||||
class ApiConfig {
|
||||
static const String baseUrl = Env.baseUrl;
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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!,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/// Status kondisi sensor: normal, warning, atau danger.
|
||||
enum MonitoringStatus { normal, warning, danger }
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import '../models/sensor_model.dart';
|
||||
|
||||
/// Abstract contract untuk IoT service.
|
||||
abstract class IotService {
|
||||
Future<SensorModel> fetchSensorData();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||