first commit
|
|
@ -0,0 +1,2 @@
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -1,382 +0,0 @@
|
||||||
# 📱 APK Monitoring Pengering Ikan - FINAL VERSION
|
|
||||||
|
|
||||||
## 🎯 Aplikasi Monitoring Only (Tanpa Kontrol)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Perubahan Terakhir
|
|
||||||
|
|
||||||
### 1. **Hapus Lamp** 🔦
|
|
||||||
- ❌ Lamp dihapus dari tampilan
|
|
||||||
- ✅ Hanya 3 device: **Heater, Fan, Exhaust**
|
|
||||||
- ✅ Grid 3 kolom (lebih rapi)
|
|
||||||
|
|
||||||
### 2. **Hapus Semua Kontrol** 🎮
|
|
||||||
- ❌ Tidak ada tombol toggle device
|
|
||||||
- ❌ Tidak ada floating action button (START/RESET)
|
|
||||||
- ✅ **Monitoring Only** - hanya menampilkan status
|
|
||||||
- ✅ Device cards tidak bisa diklik
|
|
||||||
|
|
||||||
### 3. **Auto-Update Data** 🔄
|
|
||||||
- ✅ Data update otomatis setiap **5 detik**
|
|
||||||
- ✅ Tidak perlu pull-to-refresh manual
|
|
||||||
- ✅ Polling dari Railway API
|
|
||||||
- ✅ Real-time via MQTT (jika tersedia)
|
|
||||||
|
|
||||||
### 4. **Notifikasi Push** 🔔
|
|
||||||
- ✅ **Pengeringan Siap** (status READY)
|
|
||||||
- ✅ **Ikan Terdeteksi** (status BERJALAN)
|
|
||||||
- ✅ **Pengeringan Selesai** (status SELESAI)
|
|
||||||
- ✅ Notifikasi muncul di dalam & luar aplikasi
|
|
||||||
- ✅ Notifikasi tersimpan di history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Tampilan Aplikasi
|
|
||||||
|
|
||||||
### Status Card
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ 🟣 SCANNING │
|
|
||||||
│ Menunggu deteksi ikan... │
|
|
||||||
│ │
|
|
||||||
│ Waktu Mulai: 10:30:45 │
|
|
||||||
│ Durasi: 00:15:32 │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metrics
|
|
||||||
```
|
|
||||||
┌──────────────┐ ┌──────────────┐
|
|
||||||
│ 🌡️ Suhu │ │ ⚖️ Berat │
|
|
||||||
│ 25.9°C │ │ 2183 g │
|
|
||||||
└──────────────┘ └──────────────┘
|
|
||||||
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ 🎯 Target Berat │
|
|
||||||
│ 628 g │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Device Status (3 Kolom)
|
|
||||||
```
|
|
||||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
||||||
│ 🔥 Heater│ │ 💨 Fan │ │ 🌪️ Exhaust│
|
|
||||||
│ ON │ │ ON │ │ OFF │
|
|
||||||
└──────────┘ └──────────┘ └──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔔 Notifikasi
|
|
||||||
|
|
||||||
### Kapan Notifikasi Muncul?
|
|
||||||
|
|
||||||
#### 1. **Pengeringan Dimulai** (READY)
|
|
||||||
```
|
|
||||||
🔵 Pengeringan Dimulai
|
|
||||||
Proses pengeringan ikan telah dimulai
|
|
||||||
```
|
|
||||||
- Trigger: Status berubah ke READY
|
|
||||||
- Muncul: Saat ESP32 mulai proses
|
|
||||||
|
|
||||||
#### 2. **Ikan Terdeteksi** (BERJALAN)
|
|
||||||
```
|
|
||||||
🔵 Ikan Terdeteksi
|
|
||||||
Berat ikan: 2183 gram
|
|
||||||
```
|
|
||||||
- Trigger: Status berubah ke BERJALAN
|
|
||||||
- Muncul: Saat ikan diletakkan di timbangan
|
|
||||||
|
|
||||||
#### 3. **Pengeringan Selesai** (SELESAI)
|
|
||||||
```
|
|
||||||
🟢 Pengeringan Selesai
|
|
||||||
Target tercapai! Berat akhir: 628 gram
|
|
||||||
```
|
|
||||||
- Trigger: Status berubah ke SELESAI
|
|
||||||
- Muncul: Saat berat mencapai target
|
|
||||||
|
|
||||||
### Notifikasi di Luar Aplikasi
|
|
||||||
- ✅ Muncul di notification bar Android
|
|
||||||
- ✅ Dengan suara & vibration
|
|
||||||
- ✅ Bisa diklik untuk buka aplikasi
|
|
||||||
- ✅ Tersimpan di history notifikasi
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Auto-Update Mechanism
|
|
||||||
|
|
||||||
### Polling Timer (5 Detik)
|
|
||||||
```dart
|
|
||||||
Timer.periodic(Duration(seconds: 5), (timer) {
|
|
||||||
// Fetch latest data dari API
|
|
||||||
_loadLatestData();
|
|
||||||
|
|
||||||
// Cek perubahan status
|
|
||||||
if (newStatus != previousStatus) {
|
|
||||||
// Trigger notifikasi
|
|
||||||
_notificationManager.notify...();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flow:
|
|
||||||
1. **Setiap 5 detik** → Fetch data dari Railway API
|
|
||||||
2. **Cek status** → Bandingkan dengan status sebelumnya
|
|
||||||
3. **Jika berubah** → Trigger notifikasi
|
|
||||||
4. **Update UI** → Tampilkan data terbaru
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 APK Info
|
|
||||||
|
|
||||||
- **Nama:** PengeringIkan.apk
|
|
||||||
- **Lokasi:** Desktop
|
|
||||||
- **Ukuran:** 20.65 MB
|
|
||||||
- **Build:** Release
|
|
||||||
- **Version:** 1.0.0 (Monitoring Only)
|
|
||||||
|
|
||||||
### Fitur:
|
|
||||||
- ✅ Monitoring only (no control)
|
|
||||||
- ✅ Auto-update setiap 5 detik
|
|
||||||
- ✅ Push notifications
|
|
||||||
- ✅ Connection indicators
|
|
||||||
- ✅ 3 device cards (Heater, Fan, Exhaust)
|
|
||||||
- ✅ Status tracking
|
|
||||||
- ✅ Timer durasi
|
|
||||||
- ✅ Data dari Railway API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Cara Install & Test
|
|
||||||
|
|
||||||
### 1. Uninstall APK Lama
|
|
||||||
```
|
|
||||||
Settings → Apps → Pengering Ikan → Uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install APK Baru
|
|
||||||
```
|
|
||||||
File Manager → Desktop → PengeringIkan.apk → Install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Izinkan Notifikasi
|
|
||||||
```
|
|
||||||
Saat pertama buka, izinkan:
|
|
||||||
- ✅ Show notifications
|
|
||||||
- ✅ Sound
|
|
||||||
- ✅ Vibration
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test Auto-Update
|
|
||||||
- Buka aplikasi
|
|
||||||
- Tunggu 5 detik
|
|
||||||
- Data harus update otomatis
|
|
||||||
- Tidak perlu pull-to-refresh
|
|
||||||
|
|
||||||
### 5. Test Notifikasi
|
|
||||||
- Ubah status di ESP32 (READY → BERJALAN → SELESAI)
|
|
||||||
- Notifikasi harus muncul otomatis
|
|
||||||
- Cek notification bar Android
|
|
||||||
- Cek history notifikasi di app
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 UI Changes
|
|
||||||
|
|
||||||
### Before (4 Device Cards):
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌─────────┐
|
|
||||||
│ Heater │ │ Fan │
|
|
||||||
└─────────┘ └─────────┘
|
|
||||||
┌─────────┐ ┌─────────┐
|
|
||||||
│ Lamp │ │ Exhaust │
|
|
||||||
└─────────┘ └─────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### After (3 Device Cards):
|
|
||||||
```
|
|
||||||
┌────────┐ ┌────────┐ ┌────────┐
|
|
||||||
│ Heater │ │ Fan │ │Exhaust │
|
|
||||||
└────────┘ └────────┘ └────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Device Card Styling:
|
|
||||||
- ✅ Compact design
|
|
||||||
- ✅ Icon dengan background color
|
|
||||||
- ✅ Status badge (ON/OFF)
|
|
||||||
- ✅ Tidak bisa diklik
|
|
||||||
- ✅ Responsive 3 kolom
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### Notifikasi Tidak Muncul?
|
|
||||||
|
|
||||||
**Cek 1: Permission**
|
|
||||||
```
|
|
||||||
Settings → Apps → Pengering Ikan → Notifications
|
|
||||||
- Pastikan "Show notifications" ON
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cek 2: Do Not Disturb**
|
|
||||||
```
|
|
||||||
Settings → Sound → Do Not Disturb
|
|
||||||
- Pastikan OFF atau allow app notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cek 3: Battery Optimization**
|
|
||||||
```
|
|
||||||
Settings → Battery → Battery Optimization
|
|
||||||
- Set "Pengering Ikan" to "Don't optimize"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cek 4: Status Berubah**
|
|
||||||
```
|
|
||||||
- Notifikasi hanya muncul saat status berubah
|
|
||||||
- Cek ESP32 mengirim status baru
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Tidak Update?
|
|
||||||
|
|
||||||
**Cek 1: Connection Icons**
|
|
||||||
- WiFi icon hijau? → MQTT OK
|
|
||||||
- Cloud icon hijau? → API OK
|
|
||||||
|
|
||||||
**Cek 2: Backend Status**
|
|
||||||
```
|
|
||||||
Browser: https://web-production-47eb.up.railway.app/
|
|
||||||
Harus return: {"status":"OK",...}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cek 3: ESP32 Status**
|
|
||||||
- ESP32 harus nyala
|
|
||||||
- ESP32 harus connect WiFi
|
|
||||||
- ESP32 harus publish data
|
|
||||||
|
|
||||||
**Cek 4: Polling Timer**
|
|
||||||
- Tunggu 5 detik
|
|
||||||
- Data harus update otomatis
|
|
||||||
- Cek timestamp data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Status Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
DISCONNECTED
|
|
||||||
↓
|
|
||||||
CONNECTING
|
|
||||||
↓
|
|
||||||
READY (🔔 Notif: Pengeringan Dimulai)
|
|
||||||
↓
|
|
||||||
SCANNING (Menunggu ikan)
|
|
||||||
↓
|
|
||||||
BERJALAN (🔔 Notif: Ikan Terdeteksi)
|
|
||||||
↓
|
|
||||||
SELESAI (🔔 Notif: Pengeringan Selesai)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Testing Checklist
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
- [ ] Uninstall APK lama
|
|
||||||
- [ ] Install APK baru
|
|
||||||
- [ ] Izinkan notifikasi
|
|
||||||
- [ ] Buka aplikasi
|
|
||||||
|
|
||||||
### UI
|
|
||||||
- [ ] Hanya 3 device cards (Heater, Fan, Exhaust)
|
|
||||||
- [ ] Tidak ada Lamp
|
|
||||||
- [ ] Device cards tidak bisa diklik
|
|
||||||
- [ ] Tidak ada floating action button
|
|
||||||
- [ ] Connection icons terlihat
|
|
||||||
|
|
||||||
### Auto-Update
|
|
||||||
- [ ] Data update setiap 5 detik
|
|
||||||
- [ ] Tidak perlu pull-to-refresh
|
|
||||||
- [ ] Timestamp berubah
|
|
||||||
- [ ] Status update otomatis
|
|
||||||
|
|
||||||
### Notifikasi
|
|
||||||
- [ ] Notif muncul saat status READY
|
|
||||||
- [ ] Notif muncul saat status BERJALAN
|
|
||||||
- [ ] Notif muncul saat status SELESAI
|
|
||||||
- [ ] Notif muncul di notification bar
|
|
||||||
- [ ] Notif tersimpan di history
|
|
||||||
- [ ] Notif bisa diklik
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
- [ ] Suhu update real-time
|
|
||||||
- [ ] Berat update real-time
|
|
||||||
- [ ] Target terlihat
|
|
||||||
- [ ] Status berubah sesuai ESP32
|
|
||||||
- [ ] Device status (ON/OFF) update
|
|
||||||
- [ ] Timer durasi jalan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Technical Details
|
|
||||||
|
|
||||||
### Auto-Update
|
|
||||||
- **Interval:** 5 detik
|
|
||||||
- **Method:** Polling via API
|
|
||||||
- **Endpoint:** `/api/data/latest`
|
|
||||||
- **Fallback:** MQTT real-time
|
|
||||||
|
|
||||||
### Notifikasi
|
|
||||||
- **Library:** flutter_local_notifications
|
|
||||||
- **Storage:** SharedPreferences
|
|
||||||
- **Trigger:** Status change detection
|
|
||||||
- **Types:** Info, Success, Warning, Error
|
|
||||||
|
|
||||||
### Device Cards
|
|
||||||
- **Count:** 3 (Heater, Fan, Exhaust)
|
|
||||||
- **Layout:** GridView 3 columns
|
|
||||||
- **Aspect Ratio:** 0.9
|
|
||||||
- **Interaction:** Display only (no tap)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Update History
|
|
||||||
|
|
||||||
### Version 1.0.0 - Monitoring Only (1 Juni 2026)
|
|
||||||
- ✅ Hapus Lamp dari tampilan
|
|
||||||
- ✅ Hapus semua kontrol (monitoring only)
|
|
||||||
- ✅ Auto-update data setiap 5 detik
|
|
||||||
- ✅ Notifikasi push untuk status
|
|
||||||
- ✅ Device cards 3 kolom
|
|
||||||
- ✅ Compact UI design
|
|
||||||
- ✅ No interaction (display only)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Tips
|
|
||||||
|
|
||||||
1. **Biarkan Aplikasi Berjalan**
|
|
||||||
- Auto-update bekerja di background
|
|
||||||
- Notifikasi tetap muncul meski app tertutup
|
|
||||||
|
|
||||||
2. **Cek Notifikasi**
|
|
||||||
- Swipe down untuk lihat notification bar
|
|
||||||
- Tap notifikasi untuk buka app
|
|
||||||
|
|
||||||
3. **Monitor Status**
|
|
||||||
- Perhatikan perubahan status
|
|
||||||
- Notifikasi muncul saat status berubah
|
|
||||||
|
|
||||||
4. **Connection Indicators**
|
|
||||||
- WiFi icon = MQTT status
|
|
||||||
- Cloud icon = API status
|
|
||||||
- Hijau = Connected, Merah = Disconnected
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Build Date:** 1 Juni 2026
|
|
||||||
**Version:** 1.0.0 (Monitoring Only)
|
|
||||||
**Status:** ✅ PRODUCTION READY
|
|
||||||
|
|
||||||
🎉 **Aplikasi siap digunakan untuk monitoring!**
|
|
||||||
67
CHANGELOG.md
|
|
@ -1,67 +0,0 @@
|
||||||
# Changelog - Aplikasi Pengering Ikan
|
|
||||||
|
|
||||||
## Version 1.1.0 (Build 2) - 2024-12-XX
|
|
||||||
|
|
||||||
### 🎉 Fitur Baru
|
|
||||||
- ✅ **Status Relay Real-time**: Aplikasi sekarang menampilkan status relay (Heater 1, Heater 2, Fan, Exhaust) secara real-time dari ESP32
|
|
||||||
- ✅ **Exhaust Otomatis**: Exhaust akan otomatis menyala saat suhu mencapai 60°C atau lebih
|
|
||||||
- ✅ **Cooling Down 20 Detik**: Setelah pengeringan selesai, sistem akan menjalankan cooling down selama 20 detik dengan exhaust menyala dan countdown di LCD
|
|
||||||
- ✅ **Auto Reset**: Setelah cooling down, sistem otomatis kembali ke mode "Ready to Dry" untuk batch berikutnya
|
|
||||||
|
|
||||||
### 🔧 Perbaikan Backend
|
|
||||||
- ✅ **Parse Relay Status**: Backend sekarang membaca dan menyimpan status relay1, relay2, relay3, relay4 dari data MQTT ESP32
|
|
||||||
- ✅ **Real-time Update**: Status relay di database dan API diupdate secara real-time
|
|
||||||
|
|
||||||
### 🔧 Perbaikan ESP32
|
|
||||||
- ✅ **Kirim Status Relay**: ESP32 sekarang mengirim status semua relay (relay1-4) dalam format JSON ke MQTT
|
|
||||||
- ✅ **Kontrol Exhaust Otomatis**: Exhaust dikendalikan berdasarkan suhu (ON jika >= 60°C)
|
|
||||||
- ✅ **4 Relay Support**: Mendukung Heater 1 (GPIO 13), Heater 2 (GPIO 12), Fan (GPIO 14), Exhaust (GPIO 27)
|
|
||||||
- ✅ **Load Cell Error Handling**: Penanganan error yang lebih baik untuk load cell dengan timeout
|
|
||||||
- ✅ **LCD Status**: Tampilan LCD yang lebih informatif di setiap tahap proses
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
- ✅ Fixed: Perhitungan berat load cell (dari rata-rata menjadi penjumlahan)
|
|
||||||
- ✅ Fixed: Crash saat inisialisasi HX711 (skip tare di setup, tare saat start pengeringan)
|
|
||||||
- ✅ Fixed: Status relay tidak update di aplikasi Flutter
|
|
||||||
|
|
||||||
### 📊 Format Data MQTT (ESP32 → Backend)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"suhu": 55.5,
|
|
||||||
"berat": 850,
|
|
||||||
"target": 600,
|
|
||||||
"relay1": true, // Heater 1
|
|
||||||
"relay2": true, // Heater 2
|
|
||||||
"relay3": true, // Fan
|
|
||||||
"relay4": false // Exhaust (ON jika suhu >= 60°C)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🎯 Alur Pengeringan Baru
|
|
||||||
1. Ready to Dry → Tekan Button
|
|
||||||
2. Calibrating (Tare Load Cell)
|
|
||||||
3. Pengeringan Berjalan (Heater 1, 2, Fan ON | Exhaust ON jika suhu >= 60°C)
|
|
||||||
4. Target Tercapai → "PENGERINGAN SELESAI" (5 detik)
|
|
||||||
5. Cooling Down dengan Exhaust ON (20 detik dengan countdown)
|
|
||||||
6. Auto Reset → Kembali ke "Ready to Dry"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Version 1.0.0 (Build 1) - 2024-XX-XX
|
|
||||||
|
|
||||||
### 🎉 Rilis Pertama
|
|
||||||
- ✅ Dashboard monitoring suhu dan berat real-time
|
|
||||||
- ✅ Grafik history suhu dan berat
|
|
||||||
- ✅ Kontrol relay (Heater, Fan, Exhaust) via MQTT
|
|
||||||
- ✅ Notifikasi pengeringan selesai
|
|
||||||
- ✅ Koneksi ke backend Railway dengan MySQL database
|
|
||||||
- ✅ Support MQTT broker HiveMQ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔮 Roadmap (Coming Soon)
|
|
||||||
- [ ] Push notification ke smartphone
|
|
||||||
- [ ] Export data ke Excel/CSV
|
|
||||||
- [ ] Multi-user dengan login
|
|
||||||
- [ ] Dashboard web version
|
|
||||||
- [ ] Schedule pengeringan otomatis
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Changelog - Perbaikan Status dan Notifikasi
|
||||||
|
|
||||||
|
## Tanggal: 25 Juni 2026
|
||||||
|
|
||||||
|
### Masalah yang Diperbaiki
|
||||||
|
|
||||||
|
1. **Status Pengeringan Tetap Berjalan Meskipun Alat Terputus**
|
||||||
|
- Sebelumnya: Aplikasi tidak mendeteksi ketika ESP32 terputus saat pengeringan berlangsung
|
||||||
|
- Sekarang: Sistem mendeteksi timeout (10 detik tanpa data) dan memberikan warning
|
||||||
|
|
||||||
|
2. **Notifikasi Terlalu Banyak**
|
||||||
|
- Sebelumnya: Notifikasi muncul untuk setiap perubahan status (dimulai, ikan terdeteksi, berjalan, selesai)
|
||||||
|
- Sekarang: Notifikasi hanya muncul untuk **Pengeringan Selesai**
|
||||||
|
|
||||||
|
### Perubahan Detail
|
||||||
|
|
||||||
|
#### 1. Dashboard Screen (dashboard_screen.dart)
|
||||||
|
|
||||||
|
**Deteksi Timeout:**
|
||||||
|
- Menambahkan variabel `_lastDataReceived` untuk tracking waktu data terakhir
|
||||||
|
- Menambahkan `_timeoutCheckTimer` yang mengecek setiap 2 detik
|
||||||
|
- Jika tidak ada data > 10 detik saat pengeringan:
|
||||||
|
- Menampilkan pesan "Koneksi ke alat terputus"
|
||||||
|
- Relay UI dimatikan
|
||||||
|
- Status pengeringan tetap dipertahankan untuk konsistensi
|
||||||
|
|
||||||
|
**Reset Status:**
|
||||||
|
- Ketika menerima status `READY` atau `CONNECTED`:
|
||||||
|
- Reset `modePengeringan` = false
|
||||||
|
- Reset `pengeringanSelesai` = false
|
||||||
|
- Reset `beratTersimpan` = false
|
||||||
|
|
||||||
|
**Notifikasi:**
|
||||||
|
- Menghapus notifikasi untuk status `START` (pengeringan dimulai)
|
||||||
|
- Menghapus notifikasi untuk status `BERJALAN` (ikan terdeteksi)
|
||||||
|
- **Mempertahankan** notifikasi untuk status `SELESAI` (pengeringan selesai)
|
||||||
|
|
||||||
|
#### 2. Dashboard Screen With API (dashboard_screen_with_api.dart)
|
||||||
|
|
||||||
|
**Notifikasi:**
|
||||||
|
- Menghapus notifikasi untuk status `READY` (pengeringan dimulai)
|
||||||
|
- Menghapus notifikasi untuk status `BERJALAN` (ikan terdeteksi)
|
||||||
|
- **Mempertahankan** notifikasi untuk status `SELESAI` (pengeringan selesai)
|
||||||
|
|
||||||
|
#### 3. Notification Service (notification_service.dart)
|
||||||
|
|
||||||
|
**Method yang Dihapus:**
|
||||||
|
- `notifyPengeringanDimulai()` - pengeringan dimulai
|
||||||
|
- `notifyIkanTerdeteksi(double berat)` - ikan terdeteksi
|
||||||
|
- `notifyPengeringanBerjalan(double berat, double target)` - pengeringan berjalan
|
||||||
|
|
||||||
|
**Method yang Dipertahankan:**
|
||||||
|
- `notifyPengeringanSelesai(double beratAkhir)` - pengeringan selesai ✅
|
||||||
|
- `notifySuhuTinggi(double suhu)` - peringatan suhu tinggi
|
||||||
|
- `notifyKoneksiTerputus()` - koneksi terputus
|
||||||
|
- `notifyKoneksiTersambung()` - koneksi tersambung
|
||||||
|
|
||||||
|
#### 4. Notification Manager (notification_manager.dart)
|
||||||
|
|
||||||
|
**Method yang Dihapus:**
|
||||||
|
- `notifyPengeringanDimulai()` - pengeringan dimulai
|
||||||
|
- `notifyIkanTerdeteksi(double berat)` - ikan terdeteksi
|
||||||
|
- `notifyPengeringanBerjalan(double berat, double target)` - pengeringan berjalan
|
||||||
|
|
||||||
|
**Method yang Dipertahankan:**
|
||||||
|
- `notifyPengeringanSelesai(double beratAkhir)` - pengeringan selesai ✅
|
||||||
|
|
||||||
|
### Cara Kerja Deteksi Timeout
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Timer berjalan setiap 2 detik
|
||||||
|
_timeoutCheckTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||||
|
// Cek apakah sudah > 10 detik tanpa data
|
||||||
|
if (_lastDataReceived != null &&
|
||||||
|
DateTime.now().difference(_lastDataReceived!) > Duration(seconds: 10) &&
|
||||||
|
modePengeringan) {
|
||||||
|
|
||||||
|
// Tampilkan warning
|
||||||
|
lastStatusMessage = 'Koneksi ke alat terputus - Status mungkin tidak akurat';
|
||||||
|
|
||||||
|
// Matikan relay di UI
|
||||||
|
heater = false;
|
||||||
|
fanIn = false;
|
||||||
|
lamp = false;
|
||||||
|
exhaust = false;
|
||||||
|
|
||||||
|
// Status pengeringan tetap dipertahankan
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
**Skenario 1: Alat Terputus Saat Pengeringan**
|
||||||
|
1. Mulai pengeringan
|
||||||
|
2. Cabut koneksi WiFi ESP32 atau matikan alat
|
||||||
|
3. ✅ Setelah 10 detik, aplikasi menampilkan warning
|
||||||
|
4. ✅ Relay di UI otomatis OFF
|
||||||
|
5. ✅ Status pengeringan tetap ditampilkan
|
||||||
|
|
||||||
|
**Skenario 2: Notifikasi**
|
||||||
|
1. Mulai pengeringan
|
||||||
|
2. ❌ TIDAK ada notifikasi "Pengeringan Dimulai"
|
||||||
|
3. Ikan terdeteksi
|
||||||
|
4. ❌ TIDAK ada notifikasi "Ikan Terdeteksi"
|
||||||
|
5. Pengeringan selesai
|
||||||
|
6. ✅ Muncul notifikasi "Pengeringan Selesai"
|
||||||
|
|
||||||
|
### Catatan
|
||||||
|
|
||||||
|
- Timeout 10 detik dipilih karena ESP32 mengirim data setiap 1 detik
|
||||||
|
- 10 detik memberikan buffer yang cukup untuk network latency
|
||||||
|
- Status pengeringan tidak direset otomatis untuk menghindari kehilangan informasi
|
||||||
|
- User harus manual reset dengan tombol RESET setelah koneksi pulih
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
Perubahan ini **tidak mempengaruhi**:
|
||||||
|
- Backend server
|
||||||
|
- ESP32 firmware
|
||||||
|
- MQTT communication
|
||||||
|
- Database structure
|
||||||
|
- API endpoints
|
||||||
|
|
||||||
|
Perubahan **hanya di aplikasi Flutter** untuk:
|
||||||
|
- Deteksi timeout
|
||||||
|
- Filtering notifikasi
|
||||||
114
DOKUMENTASI.md
|
|
@ -1,114 +0,0 @@
|
||||||
# 📚 Dokumentasi Aplikasi Pengering Ikan
|
|
||||||
|
|
||||||
## 📄 File Dokumentasi
|
|
||||||
|
|
||||||
### 1. **README.md** (Dokumentasi Utama)
|
|
||||||
**Isi:**
|
|
||||||
- Overview aplikasi
|
|
||||||
- Arsitektur sistem
|
|
||||||
- Quick start guide
|
|
||||||
- Troubleshooting
|
|
||||||
- Tech stack
|
|
||||||
|
|
||||||
**Untuk:** Developer & user baru
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. **APK_MONITORING_FINAL.md** (Panduan Aplikasi)
|
|
||||||
**Isi:**
|
|
||||||
- Fitur aplikasi lengkap
|
|
||||||
- Cara install & test
|
|
||||||
- Penjelasan UI
|
|
||||||
- Notifikasi system
|
|
||||||
- Testing checklist
|
|
||||||
|
|
||||||
**Untuk:** User yang install APK
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. **PERBAIKAN_APK_FINAL.md** (Changelog)
|
|
||||||
**Isi:**
|
|
||||||
- Masalah yang diperbaiki
|
|
||||||
- Perubahan fitur
|
|
||||||
- Update history
|
|
||||||
- Technical details
|
|
||||||
|
|
||||||
**Untuk:** Developer & maintenance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. **PERBAIKAN_STATUS_MQTT.md** (API Only)
|
|
||||||
**Isi:**
|
|
||||||
- Penjelasan kenapa MQTT dihapus
|
|
||||||
- Cara kerja API polling
|
|
||||||
- Status icon
|
|
||||||
- Troubleshooting koneksi
|
|
||||||
|
|
||||||
**Untuk:** User yang bertanya tentang MQTT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. **backend/API_DOCUMENTATION.md** (API Reference)
|
|
||||||
**Isi:**
|
|
||||||
- Semua endpoint API
|
|
||||||
- Request/response format
|
|
||||||
- Error handling
|
|
||||||
- Testing dengan curl/Postman
|
|
||||||
|
|
||||||
**Untuk:** Developer backend & integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗂️ Struktur Folder
|
|
||||||
|
|
||||||
```
|
|
||||||
aplikasi_novil/
|
|
||||||
├── README.md ← Baca ini dulu!
|
|
||||||
├── APK_MONITORING_FINAL.md ← Panduan install APK
|
|
||||||
├── PERBAIKAN_APK_FINAL.md ← Changelog
|
|
||||||
├── PERBAIKAN_STATUS_MQTT.md ← Penjelasan API only
|
|
||||||
├── DOKUMENTASI.md ← File ini
|
|
||||||
│
|
|
||||||
├── backend/
|
|
||||||
│ ├── API_DOCUMENTATION.md ← API reference
|
|
||||||
│ ├── server.js ← Backend code
|
|
||||||
│ ├── database.js ← Database config
|
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
├── lib/ ← Flutter app code
|
|
||||||
│ ├── screens/ ← UI screens
|
|
||||||
│ ├── services/ ← API & notification
|
|
||||||
│ └── models/ ← Data models
|
|
||||||
│
|
|
||||||
├── esp32_pengering_ikan_railway.ino ← ESP32 code
|
|
||||||
└── build-with-icon.ps1 ← Build script
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Links
|
|
||||||
|
|
||||||
### Untuk User:
|
|
||||||
1. **Install APK** → Baca `APK_MONITORING_FINAL.md`
|
|
||||||
2. **Masalah koneksi** → Baca `PERBAIKAN_STATUS_MQTT.md`
|
|
||||||
3. **Overview** → Baca `README.md`
|
|
||||||
|
|
||||||
### Untuk Developer:
|
|
||||||
1. **Setup project** → Baca `README.md`
|
|
||||||
2. **API integration** → Baca `backend/API_DOCUMENTATION.md`
|
|
||||||
3. **Changelog** → Baca `PERBAIKAN_APK_FINAL.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Catatan
|
|
||||||
|
|
||||||
- Semua file MD lama sudah dihapus (36 files)
|
|
||||||
- Hanya tersisa 5 file penting
|
|
||||||
- Dokumentasi lebih rapi dan terorganisir
|
|
||||||
- Mudah dicari dan dipahami
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 1 Juni 2026
|
|
||||||
**Total Docs:** 5 files
|
|
||||||
**Status:** ✅ Clean & Organized
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
# 📊 Panduan Fitur Grafik Chart - Aplikasi Pengering Ikan
|
||||||
|
|
||||||
|
## 🎯 Fitur yang Ditambahkan
|
||||||
|
|
||||||
|
Aplikasi sekarang memiliki **sistem grafik chart yang lengkap** untuk monitoring data pengeringan ikan secara visual dan detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Komponen Grafik
|
||||||
|
|
||||||
|
### 1. **Dashboard - Grafik Real-time** 📈
|
||||||
|
Tampilan utama dengan 2 grafik real-time yang menampilkan:
|
||||||
|
|
||||||
|
#### **Grafik Suhu**
|
||||||
|
- Menampilkan perubahan suhu secara real-time
|
||||||
|
- Warna: Orange/Red gradient
|
||||||
|
- Range: 20-60°C
|
||||||
|
- Update setiap detik saat menerima data dari ESP32
|
||||||
|
- Menampilkan 20 data terakhir dengan scrolling otomatis
|
||||||
|
|
||||||
|
#### **Grafik Berat**
|
||||||
|
- Menampilkan penurunan berat ikan secara real-time
|
||||||
|
- Warna: Blue/Cyan gradient
|
||||||
|
- Menampilkan garis target berat (hijau putus-putus)
|
||||||
|
- Tooltip menampilkan sisa berat menuju target
|
||||||
|
- Update setiap detik saat menerima data dari ESP32
|
||||||
|
|
||||||
|
**Fitur Grafik Dashboard:**
|
||||||
|
- 🔴 Live indicator - menandakan data real-time
|
||||||
|
- 📍 Dot marker pada titik terakhir
|
||||||
|
- 💧 Gradient fill di bawah garis
|
||||||
|
- 📏 Grid lines untuk kemudahan pembacaan
|
||||||
|
- 🎯 Target line pada grafik berat
|
||||||
|
- 👆 Touch tooltip - tap grafik untuk detail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Chart Screen - History Detail** 📊
|
||||||
|
|
||||||
|
Layar khusus untuk melihat data history dari database dengan detail lengkap.
|
||||||
|
|
||||||
|
#### **Cara Akses:**
|
||||||
|
Klik tab **"Grafik"** di bottom navigation (icon bar_chart)
|
||||||
|
|
||||||
|
#### **Grafik yang Tersedia:**
|
||||||
|
|
||||||
|
**A. Grafik Suhu History**
|
||||||
|
- Menampilkan history suhu dari database
|
||||||
|
- Timeline berupa index data (#1, #2, dst)
|
||||||
|
- Tooltip menampilkan suhu + timestamp
|
||||||
|
- Smooth curve line
|
||||||
|
|
||||||
|
**B. Grafik Berat History**
|
||||||
|
- Menampilkan history berat dari database
|
||||||
|
- Garis target berat horizontal
|
||||||
|
- Tooltip menampilkan berat, target, dan timestamp
|
||||||
|
- Color coding untuk titik di atas/bawah target
|
||||||
|
|
||||||
|
**C. Statistik Card**
|
||||||
|
Menampilkan analisis data:
|
||||||
|
- 📊 Rata-rata Suhu
|
||||||
|
- ⚖️ Rata-rata Berat
|
||||||
|
- 🔺 Max Suhu
|
||||||
|
- 🔻 Min Suhu
|
||||||
|
- 🔺 Max Berat
|
||||||
|
- 🔻 Min Berat
|
||||||
|
|
||||||
|
#### **Filter Data:**
|
||||||
|
Tekan icon filter (⋮) di kanan atas untuk memilih:
|
||||||
|
- 50 Data Terakhir
|
||||||
|
- 100 Data Terakhir
|
||||||
|
- 200 Data Terakhir
|
||||||
|
|
||||||
|
#### **Refresh Data:**
|
||||||
|
- Pull to refresh (tarik ke bawah)
|
||||||
|
- Atau tekan icon refresh di info card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Visual Features
|
||||||
|
|
||||||
|
### **Color Scheme:**
|
||||||
|
- **Suhu:** Orange → Red (panas)
|
||||||
|
- **Berat:** Blue → Cyan (sejuk)
|
||||||
|
- **Target:** Green (tujuan)
|
||||||
|
- **Live:** Orange dot (status real-time)
|
||||||
|
|
||||||
|
### **Gradient Effects:**
|
||||||
|
- Semua grafik menggunakan gradient fill
|
||||||
|
- Memberikan efek depth dan modern
|
||||||
|
- Smooth transitions
|
||||||
|
|
||||||
|
### **Interactive Elements:**
|
||||||
|
- Touch tooltip - tap grafik untuk detail
|
||||||
|
- Smooth animations
|
||||||
|
- Responsive layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Cara Kerja
|
||||||
|
|
||||||
|
### **Dashboard Real-time Chart:**
|
||||||
|
1. Data diterima dari ESP32 via MQTT setiap 1 detik
|
||||||
|
2. Data disimpan ke list `suhuHistory` dan `beratHistory`
|
||||||
|
3. List maksimal 20 data (auto scroll/shift)
|
||||||
|
4. Widget `TemperatureChart` dan `WeightChart` render data
|
||||||
|
5. Chart update otomatis setiap ada data baru
|
||||||
|
|
||||||
|
### **Chart Screen History:**
|
||||||
|
1. Load data dari API endpoint `/api/data/history`
|
||||||
|
2. Parse data menjadi `List<SensorData>`
|
||||||
|
3. Convert ke `List<FlSpot>` untuk grafik
|
||||||
|
4. Hitung statistik (avg, min, max)
|
||||||
|
5. Render dengan `fl_chart` LineChart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 User Experience
|
||||||
|
|
||||||
|
### **Dashboard:**
|
||||||
|
- Scroll ke bawah untuk melihat grafik
|
||||||
|
- Grafik selalu update real-time saat pengeringan berjalan
|
||||||
|
- Compact layout untuk quick glance
|
||||||
|
|
||||||
|
### **Chart Screen:**
|
||||||
|
- Full-screen view untuk analisis detail
|
||||||
|
- Filter untuk berbagai range data
|
||||||
|
- Statistik summary untuk insights
|
||||||
|
- Refresh untuk update data terbaru
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Teknologi
|
||||||
|
|
||||||
|
### **Package:**
|
||||||
|
- `fl_chart: ^0.69.0` - Chart library Flutter terbaik
|
||||||
|
- Smooth animations
|
||||||
|
- Touch interactions
|
||||||
|
- Customizable
|
||||||
|
|
||||||
|
### **Widget Structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── screens/
|
||||||
|
│ ├── dashboard_screen.dart (Main screen dengan real-time charts)
|
||||||
|
│ └── chart_screen.dart (History charts screen)
|
||||||
|
└── widgets/
|
||||||
|
├── temperature_chart.dart (Reusable temperature chart widget)
|
||||||
|
└── weight_chart.dart (Reusable weight chart widget)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Data Flow
|
||||||
|
|
||||||
|
### **Real-time:**
|
||||||
|
```
|
||||||
|
ESP32 → MQTT → MqttService → DashboardScreen
|
||||||
|
↓
|
||||||
|
suhuHistory/beratHistory
|
||||||
|
↓
|
||||||
|
TemperatureChart/WeightChart
|
||||||
|
```
|
||||||
|
|
||||||
|
### **History:**
|
||||||
|
```
|
||||||
|
Backend MySQL → API → ApiService → ChartScreen
|
||||||
|
↓
|
||||||
|
List<SensorData>
|
||||||
|
↓
|
||||||
|
fl_chart LineChart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Tips Penggunaan
|
||||||
|
|
||||||
|
1. **Monitoring Real-time:**
|
||||||
|
- Lihat dashboard untuk monitoring cepat
|
||||||
|
- Grafik akan auto-scroll saat data penuh
|
||||||
|
- Tap grafik untuk detail nilai spesifik
|
||||||
|
|
||||||
|
2. **Analisis History:**
|
||||||
|
- Buka Chart Screen untuk analisis mendalam
|
||||||
|
- Gunakan filter untuk berbagai rentang waktu
|
||||||
|
- Perhatikan statistik untuk insights
|
||||||
|
|
||||||
|
3. **Performance:**
|
||||||
|
- Dashboard limit 20 data untuk performa optimal
|
||||||
|
- Chart Screen bisa load hingga 200 data
|
||||||
|
- Refresh jika data tidak update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Future Improvements
|
||||||
|
|
||||||
|
Beberapa ide untuk pengembangan:
|
||||||
|
|
||||||
|
1. **Export Data:**
|
||||||
|
- Export grafik sebagai image
|
||||||
|
- Export data ke CSV/Excel
|
||||||
|
|
||||||
|
2. **Advanced Analytics:**
|
||||||
|
- Trend analysis
|
||||||
|
- Prediksi waktu selesai
|
||||||
|
- Efisiensi pengeringan
|
||||||
|
|
||||||
|
3. **Comparison:**
|
||||||
|
- Bandingkan multiple sessions
|
||||||
|
- Overlay grafik
|
||||||
|
|
||||||
|
4. **Zoom & Pan:**
|
||||||
|
- Zoom in/out grafik
|
||||||
|
- Pan untuk navigasi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Testing Checklist
|
||||||
|
|
||||||
|
- [x] Grafik suhu tampil real-time
|
||||||
|
- [x] Grafik berat tampil real-time
|
||||||
|
- [x] Target line muncul di grafik berat
|
||||||
|
- [x] Tooltip berfungsi saat tap
|
||||||
|
- [x] Chart screen dapat load history
|
||||||
|
- [x] Filter data berfungsi
|
||||||
|
- [x] Statistik dihitung dengan benar
|
||||||
|
- [x] Refresh data berfungsi
|
||||||
|
- [x] Layout responsive
|
||||||
|
- [x] No memory leaks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Jika ada pertanyaan atau bug:
|
||||||
|
1. Check console log untuk error messages
|
||||||
|
2. Pastikan backend API running
|
||||||
|
3. Pastikan MQTT connection aktif
|
||||||
|
4. Verify data di database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Selamat menggunakan fitur Grafik Chart! 🎉**
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Fix Notifikasi untuk Release Build
|
||||||
|
|
||||||
|
## Masalah
|
||||||
|
Notifikasi tidak muncul pada aplikasi release (APK yang sudah di-install), padahal berfungsi normal saat debug.
|
||||||
|
|
||||||
|
## Penyebab
|
||||||
|
1. **ProGuard/R8 Code Shrinking**: Pada release build, Android secara otomatis menghapus kode yang dianggap tidak digunakan, termasuk komponen notifikasi plugin.
|
||||||
|
2. **Minify Enabled**: Setting minifyEnabled di build.gradle menyebabkan code shrinking aktif.
|
||||||
|
|
||||||
|
## Solusi yang Diterapkan
|
||||||
|
|
||||||
|
### 1. File `proguard-rules.pro` (BARU)
|
||||||
|
File ini berisi rules untuk mencegah ProGuard menghapus kode notifikasi:
|
||||||
|
- Keep Flutter plugin classes
|
||||||
|
- Keep flutter_local_notifications classes
|
||||||
|
- Keep Android notification components
|
||||||
|
- Keep MQTT client classes
|
||||||
|
|
||||||
|
### 2. Update `build.gradle.kts`
|
||||||
|
Menambahkan konfigurasi:
|
||||||
|
```kotlin
|
||||||
|
isMinifyEnabled = false // Disable code shrinking
|
||||||
|
isShrinkResources = false // Disable resource shrinking
|
||||||
|
proguardFiles(...) // ProGuard rules (untuk jika diaktifkan nanti)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update `MainActivity.kt`
|
||||||
|
Menambahkan pembuatan notification channel saat aplikasi start:
|
||||||
|
- Membuat channel "pengering_ikan_channel"
|
||||||
|
- Set importance ke HIGH
|
||||||
|
- Enable vibration dan lights
|
||||||
|
|
||||||
|
## Cara Build Ulang Aplikasi
|
||||||
|
|
||||||
|
### Option 1: Build APK (Tanpa minify - Recommended)
|
||||||
|
```bash
|
||||||
|
flutter clean
|
||||||
|
flutter pub get
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
APK akan ada di: `build/app/outputs/flutter-apk/app-release.apk`
|
||||||
|
|
||||||
|
### Option 2: Build APK (Dengan minify + ProGuard)
|
||||||
|
Jika ingin APK lebih kecil, edit `build.gradle.kts`:
|
||||||
|
```kotlin
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
```
|
||||||
|
Lalu build:
|
||||||
|
```bash
|
||||||
|
flutter clean
|
||||||
|
flutter pub get
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Build App Bundle (untuk Google Play)
|
||||||
|
```bash
|
||||||
|
flutter clean
|
||||||
|
flutter pub get
|
||||||
|
flutter build appbundle --release
|
||||||
|
```
|
||||||
|
AAB akan ada di: `build/app/outputs/bundle/release/app-release.aab`
|
||||||
|
|
||||||
|
## Testing Notifikasi
|
||||||
|
|
||||||
|
### 1. Uninstall aplikasi lama
|
||||||
|
```bash
|
||||||
|
adb uninstall com.example.aplikasi_novil
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install APK baru
|
||||||
|
```bash
|
||||||
|
adb install build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test notifikasi
|
||||||
|
- Buka aplikasi
|
||||||
|
- Pastikan permission notifikasi sudah di-allow (Android 13+)
|
||||||
|
- Jalankan proses pengeringan
|
||||||
|
- Tunggu hingga selesai
|
||||||
|
- Notifikasi harus muncul
|
||||||
|
|
||||||
|
### 4. Check via Logcat
|
||||||
|
```bash
|
||||||
|
adb logcat | grep -i notification
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifikasi Permission
|
||||||
|
|
||||||
|
### Check di Settings Android:
|
||||||
|
1. Buka **Settings > Apps > Pengering Ikan**
|
||||||
|
2. Pilih **Notifications**
|
||||||
|
3. Pastikan notifikasi **ENABLED**
|
||||||
|
4. Check channel "Pengering Ikan" harus ada
|
||||||
|
|
||||||
|
### Check via ADB:
|
||||||
|
```bash
|
||||||
|
adb shell dumpsys notification_listener
|
||||||
|
adb shell cmd notification list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Notifikasi masih tidak muncul?
|
||||||
|
|
||||||
|
1. **Check permission di runtime:**
|
||||||
|
- Aplikasi harus request permission POST_NOTIFICATIONS untuk Android 13+
|
||||||
|
- Sudah di-handle di `NotificationService.initialize()`
|
||||||
|
|
||||||
|
2. **Check notification channel:**
|
||||||
|
- Channel harus dibuat di native (MainActivity.kt) ✅
|
||||||
|
- Channel ID harus sama dengan yang di Dart ✅
|
||||||
|
|
||||||
|
3. **Check battery optimization:**
|
||||||
|
- Buka Settings > Battery > Battery Optimization
|
||||||
|
- Set aplikasi ke "Don't optimize"
|
||||||
|
|
||||||
|
4. **Check Do Not Disturb:**
|
||||||
|
- Pastikan DND mode tidak aktif
|
||||||
|
- Atau allow aplikasi bypass DND
|
||||||
|
|
||||||
|
5. **Test dengan notification sederhana:**
|
||||||
|
Tambahkan button test notifikasi di UI untuk memastikan sistem notifikasi bekerja.
|
||||||
|
|
||||||
|
## Files yang Diubah/Ditambahkan
|
||||||
|
|
||||||
|
### Baru:
|
||||||
|
- ✅ `android/app/proguard-rules.pro`
|
||||||
|
|
||||||
|
### Diubah:
|
||||||
|
- ✅ `android/app/build.gradle.kts`
|
||||||
|
- ✅ `android/app/src/main/kotlin/com/example/aplikasi_novil/MainActivity.kt`
|
||||||
|
|
||||||
|
### Tidak berubah (sudah benar):
|
||||||
|
- ✅ `android/app/src/main/AndroidManifest.xml` (permission sudah lengkap)
|
||||||
|
- ✅ `lib/services/notification_service.dart` (channel ID sudah benar)
|
||||||
|
|
||||||
|
## Catatan Penting
|
||||||
|
|
||||||
|
1. **Setiap kali build release**, pastikan:
|
||||||
|
- `flutter clean` dulu
|
||||||
|
- `flutter pub get`
|
||||||
|
- Uninstall APK lama sebelum install yang baru
|
||||||
|
|
||||||
|
2. **minifyEnabled = false** akan membuat APK lebih besar (~10-20MB lebih besar)
|
||||||
|
- Tapi notifikasi dijamin bekerja
|
||||||
|
- Jika ingin APK lebih kecil, gunakan minify + ProGuard rules
|
||||||
|
|
||||||
|
3. **Testing harus di device fisik**, bukan emulator
|
||||||
|
- Emulator kadang tidak akurat untuk notifikasi
|
||||||
|
|
||||||
|
4. **Versi Android yang didukung:**
|
||||||
|
- Android 5.0+ (API 21+) untuk notifikasi dasar
|
||||||
|
- Android 8.0+ (API 26+) untuk notification channels
|
||||||
|
- Android 13+ (API 33+) perlu permission POST_NOTIFICATIONS
|
||||||
|
|
||||||
|
## Kontak
|
||||||
|
Jika masih ada masalah, check:
|
||||||
|
1. Logcat untuk error messages
|
||||||
|
2. Notification settings di Android
|
||||||
|
3. Battery optimization settings
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
# 🔧 Perbaikan APK - Pengering Ikan (FINAL)
|
|
||||||
|
|
||||||
## Tanggal: 1 Juni 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ MASALAH TERPECAHKAN
|
|
||||||
|
|
||||||
### 1. **Loading Stuck saat Refresh** 🔄
|
|
||||||
- ✅ Tambah timeout 10 detik
|
|
||||||
- ✅ Error handling yang proper
|
|
||||||
- ✅ Loading pasti berhenti
|
|
||||||
|
|
||||||
### 2. **Timer Durasi Stuck** ⏱️
|
|
||||||
- ✅ Timer jalan smooth setiap detik
|
|
||||||
- ✅ Inisialisasi waktu mulai otomatis
|
|
||||||
- ✅ Tidak tergantung status sistem
|
|
||||||
|
|
||||||
### 3. **Data Tidak Update** 📊 ⭐ BARU!
|
|
||||||
- ✅ Ganti ke dashboard dengan API
|
|
||||||
- ✅ Data langsung dari Railway backend
|
|
||||||
- ✅ Real-time update via MQTT
|
|
||||||
- ✅ Fallback ke multiple MQTT brokers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Perubahan Utama
|
|
||||||
|
|
||||||
### Dashboard Baru: API + MQTT Hybrid
|
|
||||||
|
|
||||||
**Sebelumnya:**
|
|
||||||
- Hanya MQTT (tidak reliable)
|
|
||||||
- Data sering tidak update
|
|
||||||
- Tergantung koneksi MQTT saja
|
|
||||||
|
|
||||||
**Sekarang:**
|
|
||||||
- **API Railway** untuk data awal
|
|
||||||
- **MQTT** untuk real-time updates
|
|
||||||
- **Hybrid approach** = lebih reliable!
|
|
||||||
|
|
||||||
### File yang Diubah:
|
|
||||||
|
|
||||||
1. **lib/screens/splash_screen.dart**
|
|
||||||
```dart
|
|
||||||
// Ganti dari dashboard_screen.dart
|
|
||||||
import 'dashboard_screen_with_api.dart';
|
|
||||||
|
|
||||||
// Navigasi ke DashboardScreenWithApi
|
|
||||||
const DashboardScreenWithApi()
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **pubspec.yaml**
|
|
||||||
```yaml
|
|
||||||
# Tambah HTTP package
|
|
||||||
http: ^1.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **lib/services/api_service.dart**
|
|
||||||
- URL: `https://web-production-47eb.up.railway.app`
|
|
||||||
- Endpoint: `/api/data/latest`
|
|
||||||
- Timeout: 10 detik
|
|
||||||
|
|
||||||
4. **lib/services/mqtt_service.dart**
|
|
||||||
- Multiple brokers fallback
|
|
||||||
- Auto reconnect
|
|
||||||
- Better error handling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📡 Cara Kerja Baru
|
|
||||||
|
|
||||||
### Saat Aplikasi Dibuka:
|
|
||||||
1. **Test API Connection** → Cek Railway backend
|
|
||||||
2. **Load Latest Data** → Ambil data terakhir dari database
|
|
||||||
3. **Connect MQTT** → Subscribe untuk real-time updates
|
|
||||||
4. **Listen Streams** → Update UI otomatis
|
|
||||||
|
|
||||||
### Saat Pull-to-Refresh:
|
|
||||||
1. **Fetch dari API** → Data terbaru dari database
|
|
||||||
2. **Update UI** → Tampilkan data baru
|
|
||||||
3. **Max 10 detik** → Timeout jika lambat
|
|
||||||
|
|
||||||
### Real-time Updates:
|
|
||||||
- ESP32 publish → MQTT broker
|
|
||||||
- Backend subscribe → Save ke database
|
|
||||||
- App subscribe → Update UI langsung
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Data Test dari Backend
|
|
||||||
|
|
||||||
**Backend Status:** ✅ AKTIF
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "OK",
|
|
||||||
"message": "Pengering Ikan Backend Server - MQTT Client Enabled",
|
|
||||||
"version": "1.0.1",
|
|
||||||
"uptime": 248399 seconds
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Latest Data:** ✅ ADA DATA
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"suhu": 25.9,
|
|
||||||
"berat": 2183,
|
|
||||||
"target": 628,
|
|
||||||
"relay1": false,
|
|
||||||
"relay2": false,
|
|
||||||
"relay3": false,
|
|
||||||
"relay4": false,
|
|
||||||
"status": "SCANNING",
|
|
||||||
"timestamp": "2026-06-01T02:47:58.176Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 APK Info
|
|
||||||
|
|
||||||
- **Nama:** PengeringIkan.apk
|
|
||||||
- **Lokasi:** Desktop
|
|
||||||
- **Ukuran:** 20.69 MB
|
|
||||||
- **Build:** Release
|
|
||||||
- **Version:** 1.0.0
|
|
||||||
|
|
||||||
### Fitur:
|
|
||||||
- ✅ Data dari Railway API
|
|
||||||
- ✅ Real-time MQTT updates
|
|
||||||
- ✅ Auto refresh
|
|
||||||
- ✅ Timer durasi jalan
|
|
||||||
- ✅ Loading tidak stuck
|
|
||||||
- ✅ Error handling
|
|
||||||
- ✅ Connection indicators (WiFi + Cloud icons)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Cara Install & Test
|
|
||||||
|
|
||||||
### 1. Uninstall APK Lama
|
|
||||||
```
|
|
||||||
Settings → Apps → Pengering Ikan → Uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install APK Baru
|
|
||||||
```
|
|
||||||
File Manager → Desktop → PengeringIkan.apk → Install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Test Koneksi
|
|
||||||
- Buka aplikasi
|
|
||||||
- Lihat icon di kanan atas:
|
|
||||||
- **WiFi icon hijau** = MQTT connected
|
|
||||||
- **Cloud icon hijau** = API connected
|
|
||||||
- **Icon merah** = Disconnected
|
|
||||||
|
|
||||||
### 4. Test Data Update
|
|
||||||
- Pull-to-refresh (swipe down)
|
|
||||||
- Data harus update dalam 10 detik
|
|
||||||
- Cek suhu, berat, status
|
|
||||||
|
|
||||||
### 5. Test Timer
|
|
||||||
- Timer durasi harus jalan setiap detik
|
|
||||||
- Format: HH:MM:SS (00:00:01, 00:00:02, dst)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### Data Tidak Update?
|
|
||||||
|
|
||||||
**Cek 1: Connection Icons**
|
|
||||||
- WiFi icon merah? → MQTT gagal connect
|
|
||||||
- Cloud icon merah? → API gagal connect
|
|
||||||
- Kedua merah? → Cek internet HP
|
|
||||||
|
|
||||||
**Cek 2: Pull-to-Refresh**
|
|
||||||
- Swipe down untuk refresh manual
|
|
||||||
- Loading harus muncul dan berhenti
|
|
||||||
- Data harus berubah
|
|
||||||
|
|
||||||
**Cek 3: Backend Status**
|
|
||||||
- Buka browser di HP
|
|
||||||
- Akses: `https://web-production-47eb.up.railway.app/`
|
|
||||||
- Harus return JSON dengan status OK
|
|
||||||
|
|
||||||
**Cek 4: ESP32 Status**
|
|
||||||
- ESP32 harus nyala dan connect WiFi
|
|
||||||
- ESP32 harus publish data ke MQTT
|
|
||||||
- Cek serial monitor ESP32
|
|
||||||
|
|
||||||
### Loading Stuck?
|
|
||||||
- Max 10 detik, pasti berhenti
|
|
||||||
- Jika lebih dari 10 detik, restart app
|
|
||||||
- Cek koneksi internet
|
|
||||||
|
|
||||||
### Timer Tidak Jalan?
|
|
||||||
- Restart aplikasi
|
|
||||||
- Timer harus jalan sejak app dibuka
|
|
||||||
- Tidak tergantung status pengeringan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Monitoring
|
|
||||||
|
|
||||||
### Indikator Koneksi (Kanan Atas):
|
|
||||||
- 🟢 **WiFi hijau** = MQTT OK
|
|
||||||
- 🔴 **WiFi merah** = MQTT gagal
|
|
||||||
- 🟢 **Cloud hijau** = API OK
|
|
||||||
- 🔴 **Cloud merah** = API gagal
|
|
||||||
|
|
||||||
### Status Sistem:
|
|
||||||
- **CONNECTING** = Sedang connect
|
|
||||||
- **READY** = Siap, tunggu ikan
|
|
||||||
- **SCANNING** = Deteksi berat ikan
|
|
||||||
- **BERJALAN** = Proses pengeringan
|
|
||||||
- **SELESAI** = Pengeringan selesai
|
|
||||||
- **DISCONNECTED** = Tidak terkoneksi
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Checklist Testing
|
|
||||||
|
|
||||||
- [ ] Install APK baru
|
|
||||||
- [ ] Buka aplikasi
|
|
||||||
- [ ] Cek connection icons (hijau semua)
|
|
||||||
- [ ] Cek timer jalan (detik bergerak)
|
|
||||||
- [ ] Cek data (suhu, berat, status)
|
|
||||||
- [ ] Pull-to-refresh (loading berhenti)
|
|
||||||
- [ ] Refresh berkali-kali (tidak stuck)
|
|
||||||
- [ ] Tunggu 1 menit (data auto update via MQTT)
|
|
||||||
- [ ] Test dengan ESP32 aktif
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Technical Details
|
|
||||||
|
|
||||||
### API Endpoints Used:
|
|
||||||
- `GET /` - Health check
|
|
||||||
- `GET /api/data/latest` - Latest sensor data
|
|
||||||
- `GET /api/data/history` - Historical data
|
|
||||||
- `GET /api/status/history` - Status history
|
|
||||||
- `GET /api/stats` - Statistics
|
|
||||||
- `POST /api/control` - Send commands
|
|
||||||
|
|
||||||
### MQTT Topics:
|
|
||||||
- `novil/pengering/data` - Sensor data
|
|
||||||
- `novil/pengering/status` - Status messages
|
|
||||||
- `novil/pengering/control` - Control commands
|
|
||||||
|
|
||||||
### MQTT Brokers (Fallback):
|
|
||||||
1. broker.hivemq.com:1883
|
|
||||||
2. broker.emqx.io:1883
|
|
||||||
3. test.mosquitto.org:1883
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Update History
|
|
||||||
|
|
||||||
### Version 1.0.0 (1 Juni 2026)
|
|
||||||
- ✅ Fix loading stuck
|
|
||||||
- ✅ Fix timer stuck
|
|
||||||
- ✅ **Fix data tidak update (API integration)**
|
|
||||||
- ✅ Add connection indicators
|
|
||||||
- ✅ Add error handling
|
|
||||||
- ✅ Add timeout protection
|
|
||||||
- ✅ Hybrid API + MQTT approach
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Tips
|
|
||||||
|
|
||||||
1. **Koneksi Internet Stabil**
|
|
||||||
- Pastikan HP connect WiFi/data
|
|
||||||
- Backend Railway butuh internet
|
|
||||||
|
|
||||||
2. **ESP32 Harus Aktif**
|
|
||||||
- ESP32 publish data ke MQTT
|
|
||||||
- Backend subscribe dan save ke database
|
|
||||||
- App ambil dari database + MQTT
|
|
||||||
|
|
||||||
3. **Refresh Manual**
|
|
||||||
- Jika data lama, pull-to-refresh
|
|
||||||
- Data langsung dari database
|
|
||||||
|
|
||||||
4. **Monitor Logs**
|
|
||||||
- Jika ada masalah, cek logs di console
|
|
||||||
- Error akan terprint dengan emoji ❌
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Build Date:** 1 Juni 2026
|
|
||||||
**Version:** 1.0.0 (API Integrated)
|
|
||||||
**Status:** ✅ PRODUCTION READY
|
|
||||||
|
|
||||||
🎉 **Semua masalah sudah diperbaiki!**
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
# 🔧 Perbaikan Status MQTT - API Only
|
|
||||||
|
|
||||||
## Masalah yang Diperbaiki
|
|
||||||
|
|
||||||
### ❌ Masalah Sebelumnya:
|
|
||||||
- Status stuck di "Menghubungkan..."
|
|
||||||
- Icon WiFi merah terus (MQTT gagal connect)
|
|
||||||
- Membingungkan user padahal data sudah update
|
|
||||||
- MQTT tidak diperlukan untuk monitoring
|
|
||||||
|
|
||||||
### ✅ Solusi:
|
|
||||||
- **MQTT dihapus** - tidak digunakan lagi
|
|
||||||
- **Icon WiFi dihapus** - tidak membingungkan
|
|
||||||
- **Hanya API** - polling setiap 5 detik
|
|
||||||
- **Status langsung READY** - tidak stuck
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Tampilan Baru
|
|
||||||
|
|
||||||
### AppBar (Kanan Atas):
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Dashboard Pengering Ikan ☁️🟢 │ ← Hanya 1 icon
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sebelumnya:** 📶🔴 ☁️🟢 (2 icon, WiFi merah membingungkan)
|
|
||||||
**Sekarang:** ☁️🟢 (1 icon, jelas API connected)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ☁️ Status Icon
|
|
||||||
|
|
||||||
### Icon Cloud (API Status):
|
|
||||||
- 🟢 **Hijau** = Terhubung ke Railway API
|
|
||||||
- 🔴 **Merah** = Tidak terhubung ke API
|
|
||||||
|
|
||||||
**Artinya:**
|
|
||||||
- Hijau = Data update setiap 5 detik dari database
|
|
||||||
- Merah = Tidak bisa ambil data, cek internet
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Cara Kerja Baru
|
|
||||||
|
|
||||||
### Sistem Sederhana:
|
|
||||||
```
|
|
||||||
ESP32 (Hardware)
|
|
||||||
↓
|
|
||||||
↓ publish ke MQTT
|
|
||||||
↓
|
|
||||||
Backend Railway
|
|
||||||
↓
|
|
||||||
↓ save to database
|
|
||||||
↓
|
|
||||||
Database MySQL
|
|
||||||
↑
|
|
||||||
↑ fetch via API (polling 5 detik)
|
|
||||||
↑
|
|
||||||
Aplikasi Flutter
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Data:
|
|
||||||
1. **Setiap 5 detik** → Fetch data dari API
|
|
||||||
2. **Update UI** → Tampilkan data terbaru
|
|
||||||
3. **Cek status** → Trigger notifikasi jika berubah
|
|
||||||
|
|
||||||
**Tidak ada MQTT di aplikasi!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Status Sistem
|
|
||||||
|
|
||||||
### Saat Buka Aplikasi:
|
|
||||||
```
|
|
||||||
DISCONNECTED (loading...)
|
|
||||||
↓
|
|
||||||
Test API connection
|
|
||||||
↓
|
|
||||||
✅ API Connected
|
|
||||||
↓
|
|
||||||
READY (Terhubung ke server)
|
|
||||||
↓
|
|
||||||
Load data pertama kali
|
|
||||||
↓
|
|
||||||
Mulai polling setiap 5 detik
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status yang Muncul:
|
|
||||||
- **READY** = Siap, menunggu data
|
|
||||||
- **SCANNING** = Mendeteksi ikan
|
|
||||||
- **BERJALAN** = Proses pengeringan
|
|
||||||
- **SELESAI** = Pengeringan selesai
|
|
||||||
- **DISCONNECTED** = API gagal (cek internet)
|
|
||||||
|
|
||||||
**Tidak ada "CONNECTING" atau "Menghubungkan..." lagi!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Keuntungan API Only
|
|
||||||
|
|
||||||
### 1. **Lebih Sederhana**
|
|
||||||
- Tidak perlu MQTT broker
|
|
||||||
- Tidak perlu handle MQTT connection
|
|
||||||
- Tidak ada icon WiFi yang membingungkan
|
|
||||||
|
|
||||||
### 2. **Lebih Reliable**
|
|
||||||
- API Railway stabil
|
|
||||||
- Database sebagai single source of truth
|
|
||||||
- Tidak tergantung MQTT broker publik
|
|
||||||
|
|
||||||
### 3. **Lebih Jelas**
|
|
||||||
- 1 icon = 1 status
|
|
||||||
- Cloud hijau = semua OK
|
|
||||||
- Cloud merah = cek internet
|
|
||||||
|
|
||||||
### 4. **Tetap Real-time**
|
|
||||||
- Update setiap 5 detik
|
|
||||||
- Cukup cepat untuk monitoring
|
|
||||||
- Notifikasi tetap instant
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Cek Status Icon:
|
|
||||||
1. Buka aplikasi
|
|
||||||
2. Lihat icon kanan atas
|
|
||||||
3. **Harus cloud hijau** ☁️🟢
|
|
||||||
4. Jika merah, cek internet HP
|
|
||||||
|
|
||||||
### Cek Data Update:
|
|
||||||
1. Lihat timestamp data
|
|
||||||
2. Tunggu 5 detik
|
|
||||||
3. Data harus berubah
|
|
||||||
4. Status harus update
|
|
||||||
|
|
||||||
### Cek Notifikasi:
|
|
||||||
1. Ubah status di ESP32
|
|
||||||
2. Tunggu max 5 detik
|
|
||||||
3. Notifikasi harus muncul
|
|
||||||
4. Status di app harus berubah
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### Icon Cloud Merah?
|
|
||||||
**Penyebab:**
|
|
||||||
- HP tidak connect internet
|
|
||||||
- Railway backend down
|
|
||||||
- Firewall block HTTPS
|
|
||||||
|
|
||||||
**Solusi:**
|
|
||||||
- Cek WiFi/data HP
|
|
||||||
- Test di browser: https://web-production-47eb.up.railway.app/
|
|
||||||
- Restart aplikasi
|
|
||||||
|
|
||||||
### Data Tidak Update?
|
|
||||||
**Penyebab:**
|
|
||||||
- Icon cloud merah
|
|
||||||
- ESP32 tidak kirim data
|
|
||||||
- Backend tidak save data
|
|
||||||
|
|
||||||
**Solusi:**
|
|
||||||
- Pastikan icon cloud hijau
|
|
||||||
- Cek ESP32 status
|
|
||||||
- Cek backend logs
|
|
||||||
|
|
||||||
### Status Stuck?
|
|
||||||
**Tidak akan terjadi lagi!**
|
|
||||||
- Status langsung READY saat API connected
|
|
||||||
- Tidak ada proses "Menghubungkan..." MQTT
|
|
||||||
- Jika stuck, restart aplikasi
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Technical Details
|
|
||||||
|
|
||||||
### Removed:
|
|
||||||
- ❌ MQTT client connection
|
|
||||||
- ❌ MQTT service instance
|
|
||||||
- ❌ MQTT stream listeners
|
|
||||||
- ❌ WiFi icon indicator
|
|
||||||
- ❌ isConnected variable
|
|
||||||
- ❌ _connectMQTT() function
|
|
||||||
|
|
||||||
### Kept:
|
|
||||||
- ✅ API service
|
|
||||||
- ✅ Polling timer (5 seconds)
|
|
||||||
- ✅ Notification manager
|
|
||||||
- ✅ Cloud icon indicator
|
|
||||||
- ✅ isApiConnected variable
|
|
||||||
- ✅ _loadLatestData() function
|
|
||||||
|
|
||||||
### Added:
|
|
||||||
- ✅ Direct status set to READY
|
|
||||||
- ✅ Cleaner initialization
|
|
||||||
- ✅ Simpler connection flow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 APK Info
|
|
||||||
|
|
||||||
- **Nama:** PengeringIkan.apk
|
|
||||||
- **Lokasi:** Desktop
|
|
||||||
- **Ukuran:** 20.44 MB (lebih kecil!)
|
|
||||||
- **Mode:** API Only (No MQTT)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Kesimpulan
|
|
||||||
|
|
||||||
### Sebelumnya:
|
|
||||||
- 2 icon (WiFi + Cloud)
|
|
||||||
- MQTT gagal connect
|
|
||||||
- Status stuck "Menghubungkan..."
|
|
||||||
- Membingungkan user
|
|
||||||
|
|
||||||
### Sekarang:
|
|
||||||
- 1 icon (Cloud saja)
|
|
||||||
- API polling 5 detik
|
|
||||||
- Status langsung READY
|
|
||||||
- Jelas dan sederhana
|
|
||||||
|
|
||||||
**Data tetap update, notifikasi tetap jalan, monitoring tetap berjalan!** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Build Date:** 1 Juni 2026
|
|
||||||
**Version:** 1.0.0 (API Only)
|
|
||||||
**Status:** ✅ PRODUCTION READY
|
|
||||||
|
|
||||||
🎉 **Tidak ada lagi status "Menghubungkan..." yang stuck!**
|
|
||||||
|
|
@ -18,6 +18,9 @@ android {
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
|
||||||
|
// Suppress obsolete Java version warnings
|
||||||
|
freeCompilerArgs += listOf("-Xlint:-options")
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|
@ -36,6 +39,17 @@ android {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
|
||||||
|
// Disable minify and shrink to prevent notification issues
|
||||||
|
// Set to true if you want smaller APK size, but make sure ProGuard rules are correct
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
|
||||||
|
// ProGuard rules (uncomment if you enable minify)
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Flutter Wrapper
|
||||||
|
-keep class io.flutter.app.** { *; }
|
||||||
|
-keep class io.flutter.plugin.** { *; }
|
||||||
|
-keep class io.flutter.util.** { *; }
|
||||||
|
-keep class io.flutter.view.** { *; }
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Flutter Local Notifications
|
||||||
|
-keep class com.dexterous.** { *; }
|
||||||
|
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||||
|
-dontwarn com.dexterous.flutterlocalnotifications.**
|
||||||
|
|
||||||
|
# AndroidX
|
||||||
|
-keep class androidx.core.app.NotificationCompat { *; }
|
||||||
|
-keep class androidx.core.app.NotificationCompat$** { *; }
|
||||||
|
-keep class androidx.core.app.NotificationManagerCompat { *; }
|
||||||
|
|
||||||
|
# Keep notification channels (Android 8.0+)
|
||||||
|
-keep class android.app.NotificationChannel { *; }
|
||||||
|
-keep class android.app.NotificationManager { *; }
|
||||||
|
|
||||||
|
# MQTT Client
|
||||||
|
-keep class org.eclipse.paho.** { *; }
|
||||||
|
-keep class org.eclipse.paho.client.mqttv3.** { *; }
|
||||||
|
-dontwarn org.eclipse.paho.**
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
-keep class kotlin.** { *; }
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
-dontwarn kotlin.**
|
||||||
|
-keepclassmembers class **$WhenMappings {
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
-keepclassmembers class kotlin.Metadata {
|
||||||
|
public <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
|
||||||
|
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||||
|
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||||
|
|
||||||
|
# With R8 full mode generic signatures are stripped for classes that are not
|
||||||
|
# kept. Suspend functions are wrapped in continuations where the type argument
|
||||||
|
# is used.
|
||||||
|
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||||
|
|
||||||
|
# Keep native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep setters in Views so that animations can still work.
|
||||||
|
-keepclassmembers public class * extends android.view.View {
|
||||||
|
void set*(***);
|
||||||
|
*** get*();
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep classes with @Keep annotation
|
||||||
|
-keep @androidx.annotation.Keep class * {*;}
|
||||||
|
-keepclasseswithmembers class * {
|
||||||
|
@androidx.annotation.Keep <methods>;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class * {
|
||||||
|
@androidx.annotation.Keep <fields>;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class * {
|
||||||
|
@androidx.annotation.Keep <init>(...);
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,22 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Permission untuk notifikasi Android 13+ -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
|
<!-- Permission untuk vibration notifikasi -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<!-- Permission untuk receive boot completed (agar app bisa restart service) -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
|
||||||
|
<!-- Permission untuk wake lock (agar notifikasi tetap jalan di background) -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="aplikasi_novil"
|
android:label="Pengering Ikan"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -15,7 +27,9 @@
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
|
@ -29,6 +43,22 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Receiver untuk boot completed -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Receiver untuk scheduled notifications -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
package com.example.aplikasi_novil
|
package com.example.aplikasi_novil
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Create notification channel for Android 8.0+
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
// Channel untuk notifikasi pengeringan selesai
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
"pengering_ikan_channel",
|
||||||
|
"Notifikasi Pengering Ikan",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Notifikasi untuk status pengeringan ikan"
|
||||||
|
enableVibration(true)
|
||||||
|
enableLights(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 963 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1976D2</color>
|
<color name="ic_launcher_background">#2196F3</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -14,6 +14,18 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
subprojects {
|
subprojects {
|
||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
|
||||||
|
// Force all subprojects to use Java 17
|
||||||
|
afterEvaluate {
|
||||||
|
if (project.hasProperty("android")) {
|
||||||
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subprojects {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,16 @@
|
||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Suppress obsolete Java warnings
|
||||||
|
android.suppressUnsupportedCompileSdk=34
|
||||||
|
org.gradle.warning.mode=none
|
||||||
|
|
||||||
|
# Force Java 17 for all modules
|
||||||
|
# org.gradle.java.home=
|
||||||
|
kotlin.jvm.target.validation.mode=warning
|
||||||
|
|
||||||
|
# Enable R8 full mode (optional, for better optimization)
|
||||||
|
android.enableR8.fullMode=false
|
||||||
|
|
||||||
|
# Suppress Kotlin compiler warnings
|
||||||
|
kotlin.compiler.suppressWarnings=false
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Cara Push Backend ke GitHub
|
||||||
|
|
||||||
|
## 📌 **Cara 1: Menggunakan Script PowerShell (RECOMMENDED)**
|
||||||
|
|
||||||
|
### Push dengan pesan custom:
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
.\push.ps1 "Fix bug di notifikasi"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push dengan pesan default:
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
.\push.ps1
|
||||||
|
```
|
||||||
|
(Akan menggunakan pesan default: "Update backend")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 **Cara 2: Menggunakan Git Alias**
|
||||||
|
|
||||||
|
### Setup (Hanya sekali):
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
git config --local alias.quick '!f() { git add -A && git commit -m "$1" && git push origin main; }; f'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Penggunaan:
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
git quick "Fix bug di notifikasi"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 **Cara 3: Manual (Standard Git)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
git add -A
|
||||||
|
git commit -m "Pesan commit Anda"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 **Repository Backend**
|
||||||
|
- **GitHub**: https://github.com/novil04/backend_aplikasi_novil
|
||||||
|
- **Railway**: Auto-deploy setelah push (2-5 menit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ **Catatan Penting**
|
||||||
|
|
||||||
|
1. **Selalu bekerja di folder backend**
|
||||||
|
```powershell
|
||||||
|
cd c:\Users\abilh\aplikasi_novil\backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Cek status sebelum push**
|
||||||
|
```powershell
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lihat history commit**
|
||||||
|
```powershell
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Jangan lupa file .env**
|
||||||
|
- File `.env` TIDAK akan ter-push (sudah di .gitignore)
|
||||||
|
- Atur variabel environment langsung di Railway dashboard
|
||||||
|
|
||||||
|
5. **Cek Railway setelah push**
|
||||||
|
- Buka Railway dashboard
|
||||||
|
- Tunggu build & deploy selesai
|
||||||
|
- Cek logs jika ada error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **Quick Reference**
|
||||||
|
|
||||||
|
| Tujuan | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Push cepat | `.\push.ps1 "pesan"` |
|
||||||
|
| Cek perubahan | `git status` |
|
||||||
|
| Lihat log | `git log --oneline -5` |
|
||||||
|
| Batal commit terakhir | `git reset --soft HEAD~1` |
|
||||||
|
| Pull update terbaru | `git pull origin main` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 **Troubleshooting**
|
||||||
|
|
||||||
|
### Error: "Push rejected"
|
||||||
|
```powershell
|
||||||
|
# Pull dulu, lalu push lagi
|
||||||
|
git pull origin main
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "Permission denied"
|
||||||
|
```powershell
|
||||||
|
# Pastikan sudah login GitHub
|
||||||
|
# Atau gunakan Personal Access Token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script tidak jalan
|
||||||
|
```powershell
|
||||||
|
# Enable execution policy (run as Administrator)
|
||||||
|
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Script untuk push perubahan backend ke GitHub
|
||||||
|
# Usage: .\push.ps1 "commit message"
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Message = "Update backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "🔄 Pushing backend to GitHub..." -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Check if there are changes
|
||||||
|
$status = git status --porcelain
|
||||||
|
if ([string]::IsNullOrEmpty($status)) {
|
||||||
|
Write-Host "✅ No changes to commit" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show changes
|
||||||
|
Write-Host "📝 Changes detected:" -ForegroundColor Yellow
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📦 Adding all changes..." -ForegroundColor Cyan
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
Write-Host "💾 Committing with message: '$Message'" -ForegroundColor Cyan
|
||||||
|
git commit -m $Message
|
||||||
|
|
||||||
|
Write-Host "🚀 Pushing to GitHub (backend_aplikasi_novil)..." -ForegroundColor Cyan
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Successfully pushed to GitHub!" -ForegroundColor Green
|
||||||
|
Write-Host "🔗 Repository: https://github.com/novil04/backend_aplikasi_novil" -ForegroundColor Cyan
|
||||||
|
Write-Host "⏳ Railway will auto-deploy in 2-5 minutes" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "❌ Failed to push to GitHub" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
@ -55,8 +55,8 @@ let latestData = {
|
||||||
// Last data received timestamp
|
// Last data received timestamp
|
||||||
let lastDataTimestamp = Date.now();
|
let lastDataTimestamp = Date.now();
|
||||||
|
|
||||||
// Connection timeout (30 seconds)
|
// Connection timeout (5 minutes = 300 seconds)
|
||||||
const CONNECTION_TIMEOUT = 30000;
|
const CONNECTION_TIMEOUT = 300000;
|
||||||
|
|
||||||
// Check connection status periodically
|
// Check connection status periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Android Adaptive Icon Generator</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1976D2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.icon-preview {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
input[type="file"] {
|
||||||
|
display: block;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #1976D2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: block;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #1565C0;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
background: #E3F2FD;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.safe-zone {
|
||||||
|
position: absolute;
|
||||||
|
border: 3px dashed #FF5722;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🎨 Android Adaptive Icon Generator</h1>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<strong>📱 Android Adaptive Icon Safe Zone:</strong><br>
|
||||||
|
Your icon will be displayed in different shapes (circle, square, squircle).<br>
|
||||||
|
Important content must stay within the <span style="color: #FF5722;">red circle</span> (66% area).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" id="imageInput" accept="image/*">
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<div class="icon-preview">
|
||||||
|
<h3>Original</h3>
|
||||||
|
<canvas id="originalCanvas" width="300" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="icon-preview">
|
||||||
|
<h3>Adaptive Icon (66% size)</h3>
|
||||||
|
<div style="position: relative; display: inline-block;">
|
||||||
|
<canvas id="adaptiveCanvas" width="300" height="300"></canvas>
|
||||||
|
<div class="safe-zone" style="width: 198px; height: 198px; top: 51px; left: 51px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="downloadIcon()">⬇️ Download Fixed Icon (1024x1024)</button>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<strong>Next steps after download:</strong><br>
|
||||||
|
1. Replace <code>assets/images/app_icon.png</code> with downloaded file<br>
|
||||||
|
2. Run: <code>flutter pub run flutter_launcher_icons</code><br>
|
||||||
|
3. Build: <code>flutter build apk --release</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const originalCanvas = document.getElementById('originalCanvas');
|
||||||
|
const adaptiveCanvas = document.getElementById('adaptiveCanvas');
|
||||||
|
const originalCtx = originalCanvas.getContext('2d');
|
||||||
|
const adaptiveCtx = adaptiveCanvas.getContext('2d');
|
||||||
|
let currentImage = null;
|
||||||
|
|
||||||
|
document.getElementById('imageInput').addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(event) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
currentImage = img;
|
||||||
|
drawIcons();
|
||||||
|
};
|
||||||
|
img.src = event.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawIcons() {
|
||||||
|
if (!currentImage) return;
|
||||||
|
|
||||||
|
// Clear canvases
|
||||||
|
originalCtx.clearRect(0, 0, 300, 300);
|
||||||
|
adaptiveCtx.clearRect(0, 0, 300, 300);
|
||||||
|
|
||||||
|
// Draw original
|
||||||
|
originalCtx.drawImage(currentImage, 0, 0, 300, 300);
|
||||||
|
|
||||||
|
// Draw adaptive with background
|
||||||
|
adaptiveCtx.fillStyle = '#1976D2';
|
||||||
|
adaptiveCtx.fillRect(0, 0, 300, 300);
|
||||||
|
|
||||||
|
// Draw icon at 66% size (centered)
|
||||||
|
const scaledSize = 300 * 0.66;
|
||||||
|
const offset = (300 - scaledSize) / 2;
|
||||||
|
adaptiveCtx.drawImage(currentImage, offset, offset, scaledSize, scaledSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadIcon() {
|
||||||
|
if (!currentImage) {
|
||||||
|
alert('Please load an image first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create high-res canvas for export (1024x1024)
|
||||||
|
const exportCanvas = document.createElement('canvas');
|
||||||
|
exportCanvas.width = 1024;
|
||||||
|
exportCanvas.height = 1024;
|
||||||
|
const ctx = exportCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Fill with transparent or background
|
||||||
|
ctx.fillStyle = 'transparent';
|
||||||
|
ctx.fillRect(0, 0, 1024, 1024);
|
||||||
|
|
||||||
|
// Draw icon at 66% size (safe zone)
|
||||||
|
const scaledSize = 1024 * 0.66;
|
||||||
|
const offset = (1024 - scaledSize) / 2;
|
||||||
|
ctx.drawImage(currentImage, offset, offset, scaledSize, scaledSize);
|
||||||
|
|
||||||
|
// Download
|
||||||
|
exportCanvas.toBlob(function(blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'app_icon_fixed.png';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
|
|
@ -761,15 +761,36 @@ void loop() {
|
||||||
// =====================================================
|
// =====================================================
|
||||||
float berat1 = 0;
|
float berat1 = 0;
|
||||||
float berat2 = 0;
|
float berat2 = 0;
|
||||||
|
bool scale1Valid = false;
|
||||||
|
bool scale2Valid = false;
|
||||||
|
|
||||||
// Cek dan baca scale 1 (style kode test)
|
// Cek dan baca scale 1 (style kode test)
|
||||||
if (scale1.is_ready()) {
|
if (scale1.is_ready()) {
|
||||||
berat1 = scale1.get_units(5);
|
berat1 = scale1.get_units(5);
|
||||||
|
scale1Valid = true;
|
||||||
|
} else {
|
||||||
|
Serial.println("WARNING: Scale 1 not ready!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cek dan baca scale 2 (style kode test)
|
// Cek dan baca scale 2 (style kode test)
|
||||||
if (scale2.is_ready()) {
|
if (scale2.is_ready()) {
|
||||||
berat2 = scale2.get_units(5);
|
berat2 = scale2.get_units(5);
|
||||||
|
scale2Valid = true;
|
||||||
|
} else {
|
||||||
|
Serial.println("WARNING: Scale 2 not ready!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika kedua scale tidak ready, skip loop ini
|
||||||
|
if (!scale1Valid && !scale2Valid) {
|
||||||
|
Serial.println("ERROR: Kedua load cell tidak ready!");
|
||||||
|
lcd.clear();
|
||||||
|
lcd.setCursor(0, 0);
|
||||||
|
lcd.print("Scale Error!");
|
||||||
|
lcd.setCursor(0, 1);
|
||||||
|
lcd.print("Check wiring");
|
||||||
|
client.publish(topicStatus, "LOAD CELL ERROR");
|
||||||
|
delay(2000);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
@ -806,11 +827,24 @@ void loop() {
|
||||||
berat_trigger = berat_daging + sisa_air;
|
berat_trigger = berat_daging + sisa_air;
|
||||||
beratTersimpan = true;
|
beratTersimpan = true;
|
||||||
|
|
||||||
|
Serial.println("==================================");
|
||||||
Serial.println("BERAT AWAL TERSIMPAN");
|
Serial.println("BERAT AWAL TERSIMPAN");
|
||||||
Serial.print("Berat Awal : ");
|
Serial.print("Berat Awal : ");
|
||||||
Serial.println(berat_awal);
|
Serial.print(berat_awal, 2);
|
||||||
Serial.print("Target : ");
|
Serial.println(" g");
|
||||||
Serial.println(berat_trigger);
|
Serial.print("Berat Daging : ");
|
||||||
|
Serial.print(berat_daging, 2);
|
||||||
|
Serial.println(" g (30%)");
|
||||||
|
Serial.print("Berat Air Awal : ");
|
||||||
|
Serial.print(berat_air_awal, 2);
|
||||||
|
Serial.println(" g (70%)");
|
||||||
|
Serial.print("Sisa Air Target : ");
|
||||||
|
Serial.print(sisa_air, 2);
|
||||||
|
Serial.println(" g (40% dari air)");
|
||||||
|
Serial.print("Target Akhir : ");
|
||||||
|
Serial.print(berat_trigger, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
Serial.println("==================================");
|
||||||
|
|
||||||
String msg = "PENGERINGAN BERJALAN - Awal:";
|
String msg = "PENGERINGAN BERJALAN - Awal:";
|
||||||
msg += String(berat_awal, 0);
|
msg += String(berat_awal, 0);
|
||||||
|
|
@ -824,70 +858,117 @@ void loop() {
|
||||||
// TARGET TERCAPAI → SELESAI
|
// TARGET TERCAPAI → SELESAI
|
||||||
// =====================================================
|
// =====================================================
|
||||||
if (beratTersimpan && berat <= berat_trigger) {
|
if (beratTersimpan && berat <= berat_trigger) {
|
||||||
modePengeringan = false;
|
|
||||||
pengeringanSelesai = true;
|
|
||||||
|
|
||||||
// Matikan heater 1, 2 dan fan, nyalakan exhaust untuk cooling
|
static unsigned long targetReachedTime = 0;
|
||||||
digitalWrite(RELAY_HEATER1, HIGH);
|
static bool targetReachedFlag = false;
|
||||||
digitalWrite(RELAY_HEATER2, HIGH);
|
|
||||||
digitalWrite(RELAY_FAN, HIGH);
|
|
||||||
digitalWrite(RELAY_EXHAUST, LOW); // EXHAUST ON
|
|
||||||
|
|
||||||
Serial.println("PENGERINGAN SELESAI");
|
// Pertama kali target tercapai
|
||||||
client.publish(topicStatus, "PENGERINGAN SELESAI");
|
if (!targetReachedFlag) {
|
||||||
|
targetReachedTime = millis();
|
||||||
// Kirim data dengan status COMPLETED untuk trigger notifikasi
|
targetReachedFlag = true;
|
||||||
kirimStatusRelay(suhu, berat);
|
Serial.println("==================================");
|
||||||
|
Serial.println("TARGET TERCAPAI - Verifikasi 10 detik...");
|
||||||
// Tampilkan selesai selama 5 detik
|
Serial.print("Berat Sekarang: ");
|
||||||
lcd.clear();
|
Serial.print(berat, 2);
|
||||||
lcd.setCursor(0, 0);
|
Serial.println(" g");
|
||||||
lcd.print("PENGERINGAN");
|
Serial.print("Target: ");
|
||||||
lcd.setCursor(0, 1);
|
Serial.print(berat_trigger, 2);
|
||||||
lcd.print("SELESAI");
|
Serial.println(" g");
|
||||||
delay(5000);
|
Serial.println("==================================");
|
||||||
|
client.publish(topicStatus, "Verifikasi target...");
|
||||||
// Exhaust jalan 20 detik untuk buang panas
|
|
||||||
lcd.clear();
|
|
||||||
lcd.setCursor(0, 0);
|
|
||||||
lcd.print("Cooling down...");
|
|
||||||
lcd.setCursor(0, 1);
|
|
||||||
lcd.print("20 seconds");
|
|
||||||
|
|
||||||
// Countdown 20 detik
|
|
||||||
for (int i = 20; i > 0; i--) {
|
|
||||||
lcd.setCursor(0, 1);
|
|
||||||
lcd.print(" ");
|
|
||||||
lcd.setCursor(0, 1);
|
|
||||||
lcd.print(String(i) + " seconds left");
|
|
||||||
delay(1000);
|
|
||||||
|
|
||||||
// Tetap kirim MQTT status relay saat cooling
|
|
||||||
kirimStatusRelay(suhu, berat);
|
|
||||||
client.loop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matikan exhaust dan kembali ke ready
|
// Tunggu 10 detik untuk memastikan berat stabil di bawah target
|
||||||
digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF
|
if (millis() - targetReachedTime >= 10000) {
|
||||||
|
// Setelah 10 detik masih di bawah target, baru selesai
|
||||||
// Reset variabel
|
modePengeringan = false;
|
||||||
pengeringanSelesai = false;
|
pengeringanSelesai = true;
|
||||||
beratTersimpan = false;
|
targetReachedFlag = false; // Reset flag
|
||||||
berat_awal = 0;
|
|
||||||
berat_trigger = 0;
|
// Matikan heater 1, 2 dan fan, nyalakan exhaust untuk cooling
|
||||||
waktuMulaiBerat = 0;
|
digitalWrite(RELAY_HEATER1, HIGH);
|
||||||
|
digitalWrite(RELAY_HEATER2, HIGH);
|
||||||
lcd.clear();
|
digitalWrite(RELAY_FAN, HIGH);
|
||||||
tampilReady();
|
digitalWrite(RELAY_EXHAUST, LOW); // EXHAUST ON
|
||||||
client.publish(topicStatus, "PENGERINGAN SIAP");
|
|
||||||
|
Serial.println("==================================");
|
||||||
// Kirim status relay setelah reset
|
Serial.println("PENGERINGAN SELESAI");
|
||||||
kirimStatusRelay(suhu, berat);
|
Serial.print("Berat Awal : ");
|
||||||
|
Serial.print(berat_awal, 2);
|
||||||
Serial.println("Sistem kembali ke READY");
|
Serial.println(" g");
|
||||||
|
Serial.print("Berat Akhir : ");
|
||||||
delay(500);
|
Serial.print(berat, 2);
|
||||||
return;
|
Serial.println(" g");
|
||||||
|
Serial.print("Target : ");
|
||||||
|
Serial.print(berat_trigger, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
Serial.print("Susut : ");
|
||||||
|
Serial.print(berat_awal - berat, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
Serial.print("Persen Susut: ");
|
||||||
|
Serial.print(((berat_awal - berat) / berat_awal) * 100, 1);
|
||||||
|
Serial.println(" %");
|
||||||
|
Serial.println("==================================");
|
||||||
|
|
||||||
|
client.publish(topicStatus, "PENGERINGAN SELESAI");
|
||||||
|
|
||||||
|
// Kirim data dengan status COMPLETED untuk trigger notifikasi
|
||||||
|
kirimStatusRelay(suhu, berat);
|
||||||
|
|
||||||
|
// Tampilkan selesai selama 5 detik
|
||||||
|
lcd.clear();
|
||||||
|
lcd.setCursor(0, 0);
|
||||||
|
lcd.print("PENGERINGAN");
|
||||||
|
lcd.setCursor(0, 1);
|
||||||
|
lcd.print("SELESAI");
|
||||||
|
delay(5000);
|
||||||
|
|
||||||
|
// Exhaust jalan 20 detik untuk buang panas
|
||||||
|
lcd.clear();
|
||||||
|
lcd.setCursor(0, 0);
|
||||||
|
lcd.print("Cooling down...");
|
||||||
|
lcd.setCursor(0, 1);
|
||||||
|
lcd.print("20 seconds");
|
||||||
|
|
||||||
|
// Countdown 20 detik
|
||||||
|
for (int i = 20; i > 0; i--) {
|
||||||
|
lcd.setCursor(0, 1);
|
||||||
|
lcd.print(" ");
|
||||||
|
lcd.setCursor(0, 1);
|
||||||
|
lcd.print(String(i) + " seconds left");
|
||||||
|
delay(1000);
|
||||||
|
|
||||||
|
// Tetap kirim MQTT status relay saat cooling
|
||||||
|
kirimStatusRelay(suhu, berat);
|
||||||
|
client.loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matikan exhaust dan kembali ke ready
|
||||||
|
digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF
|
||||||
|
|
||||||
|
// Reset variabel
|
||||||
|
pengeringanSelesai = false;
|
||||||
|
beratTersimpan = false;
|
||||||
|
berat_awal = 0;
|
||||||
|
berat_trigger = 0;
|
||||||
|
waktuMulaiBerat = 0;
|
||||||
|
|
||||||
|
lcd.clear();
|
||||||
|
tampilReady();
|
||||||
|
client.publish(topicStatus, "PENGERINGAN SIAP");
|
||||||
|
|
||||||
|
// Kirim status relay setelah reset
|
||||||
|
kirimStatusRelay(suhu, berat);
|
||||||
|
|
||||||
|
Serial.println("Sistem kembali ke READY");
|
||||||
|
|
||||||
|
delay(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Berat naik lagi atau di atas target, reset flag verifikasi
|
||||||
|
static bool targetReachedFlag = false;
|
||||||
|
targetReachedFlag = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
@ -895,24 +976,47 @@ void loop() {
|
||||||
// Relay HANYA ON setelah berat_awal & target tersimpan
|
// Relay HANYA ON setelah berat_awal & target tersimpan
|
||||||
// =====================================================
|
// =====================================================
|
||||||
if (beratTersimpan) {
|
if (beratTersimpan) {
|
||||||
|
// Debug: Print status berat setiap 5 detik
|
||||||
|
static unsigned long lastDebug = 0;
|
||||||
|
if (millis() - lastDebug > 5000) {
|
||||||
|
Serial.println("--- STATUS PENGERINGAN ---");
|
||||||
|
Serial.print("Berat Sekarang : ");
|
||||||
|
Serial.print(berat, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
Serial.print("Target : ");
|
||||||
|
Serial.print(berat_trigger, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
Serial.print("Selisih : ");
|
||||||
|
Serial.print(berat - berat_trigger, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
Serial.print("Kondisi : ");
|
||||||
|
if (berat <= berat_trigger) {
|
||||||
|
Serial.println("TARGET TERCAPAI ✓");
|
||||||
|
} else {
|
||||||
|
Serial.println("MASIH MENGERINGKAN...");
|
||||||
|
}
|
||||||
|
Serial.println("-------------------------");
|
||||||
|
lastDebug = millis();
|
||||||
|
}
|
||||||
|
|
||||||
// Berat sudah tersimpan, mulai pengeringan
|
// Berat sudah tersimpan, mulai pengeringan
|
||||||
|
|
||||||
// FAN selalu ON saat pengeringan
|
// FAN selalu ON saat pengeringan
|
||||||
digitalWrite(RELAY_FAN, LOW); // FAN ON
|
digitalWrite(RELAY_FAN, LOW); // FAN ON
|
||||||
|
|
||||||
// Kontrol HEATER dan EXHAUST berdasarkan suhu
|
// Kontrol HEATER dan EXHAUST berdasarkan suhu
|
||||||
if (suhu >= 60.0) {
|
if (suhu >= 55.0) {
|
||||||
// Suhu sudah tinggi (≥60°C)
|
// Suhu sudah tinggi (≥55°C)
|
||||||
digitalWrite(RELAY_HEATER1, HIGH); // HEATER 1 OFF
|
digitalWrite(RELAY_HEATER1, HIGH); // HEATER 1 OFF
|
||||||
digitalWrite(RELAY_HEATER2, HIGH); // HEATER 2 OFF
|
digitalWrite(RELAY_HEATER2, HIGH); // HEATER 2 OFF
|
||||||
digitalWrite(RELAY_EXHAUST, LOW); // EXHAUST ON untuk buang panas
|
digitalWrite(RELAY_EXHAUST, LOW); // EXHAUST ON untuk buang panas
|
||||||
Serial.println("Suhu >= 60C: HEATER OFF, EXHAUST ON");
|
// Serial.println("Suhu >= 55C: HEATER OFF, EXHAUST ON");
|
||||||
} else {
|
} else {
|
||||||
// Suhu masih rendah (<60°C)
|
// Suhu masih rendah (<55°C)
|
||||||
digitalWrite(RELAY_HEATER1, LOW); // HEATER 1 ON
|
digitalWrite(RELAY_HEATER1, LOW); // HEATER 1 ON
|
||||||
digitalWrite(RELAY_HEATER2, LOW); // HEATER 2 ON
|
digitalWrite(RELAY_HEATER2, LOW); // HEATER 2 ON
|
||||||
digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF
|
digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF
|
||||||
Serial.println("Suhu < 60C: HEATER ON, EXHAUST OFF");
|
// Serial.println("Suhu < 55C: HEATER ON, EXHAUST OFF");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Masih scanning berat, relay tetap OFF
|
// Masih scanning berat, relay tetap OFF
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix App Icon - Add padding to prevent cropping on Android
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import os
|
||||||
|
|
||||||
|
def create_icon_with_padding(input_path, output_path, padding_percent=15):
|
||||||
|
"""
|
||||||
|
Create icon with padding to fit Android adaptive icon safe zone
|
||||||
|
|
||||||
|
Android adaptive icons use a circular mask, so we need to ensure
|
||||||
|
the important content fits within a circle of ~80% of the icon size
|
||||||
|
"""
|
||||||
|
print(f"📷 Loading icon: {input_path}")
|
||||||
|
|
||||||
|
# Open original image
|
||||||
|
img = Image.open(input_path).convert('RGBA')
|
||||||
|
original_size = img.size[0]
|
||||||
|
|
||||||
|
print(f" Original size: {original_size}x{original_size}")
|
||||||
|
|
||||||
|
# Calculate new size with padding
|
||||||
|
padding = int(original_size * (padding_percent / 100))
|
||||||
|
new_size = original_size + (padding * 2)
|
||||||
|
|
||||||
|
print(f" Adding padding: {padding}px on each side")
|
||||||
|
print(f" New size: {new_size}x{new_size}")
|
||||||
|
|
||||||
|
# Create new image with transparent background
|
||||||
|
new_img = Image.new('RGBA', (new_size, new_size), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
# Paste original image in center
|
||||||
|
new_img.paste(img, (padding, padding), img)
|
||||||
|
|
||||||
|
# Save result
|
||||||
|
new_img.save(output_path, 'PNG')
|
||||||
|
print(f"✅ Icon saved: {output_path}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def create_icon_with_background(input_path, output_path, bg_color='#1976D2', padding_percent=15):
|
||||||
|
"""
|
||||||
|
Create icon with colored background (better for adaptive icons)
|
||||||
|
"""
|
||||||
|
print(f"📷 Loading icon: {input_path}")
|
||||||
|
|
||||||
|
# Open original image
|
||||||
|
img = Image.open(input_path).convert('RGBA')
|
||||||
|
original_size = img.size[0]
|
||||||
|
|
||||||
|
print(f" Original size: {original_size}x{original_size}")
|
||||||
|
|
||||||
|
# Calculate new size with padding
|
||||||
|
padding = int(original_size * (padding_percent / 100))
|
||||||
|
new_size = original_size + (padding * 2)
|
||||||
|
|
||||||
|
print(f" Adding padding: {padding}px on each side")
|
||||||
|
print(f" New size: {new_size}x{new_size}")
|
||||||
|
print(f" Background color: {bg_color}")
|
||||||
|
|
||||||
|
# Create new image with colored background
|
||||||
|
new_img = Image.new('RGBA', (new_size, new_size), bg_color)
|
||||||
|
|
||||||
|
# Paste original image in center
|
||||||
|
new_img.paste(img, (padding, padding), img)
|
||||||
|
|
||||||
|
# Save result
|
||||||
|
new_img.save(output_path, 'PNG')
|
||||||
|
print(f"✅ Icon saved: {output_path}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("🔧 Android Adaptive Icon Fixer")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
input_icon = 'assets/images/app_icon.png'
|
||||||
|
|
||||||
|
if not os.path.exists(input_icon):
|
||||||
|
print(f"❌ Error: Icon not found at {input_icon}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Backup original
|
||||||
|
backup_path = 'assets/images/app_icon_backup.png'
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
Image.open(input_icon).save(backup_path)
|
||||||
|
print(f"💾 Backup saved: {backup_path}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("Choose icon style:")
|
||||||
|
print("1. Transparent background with padding (default)")
|
||||||
|
print("2. Blue background with padding (recommended for Android)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
choice = input("Enter choice (1 or 2, default=2): ").strip() or "2"
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
# Transparent with padding
|
||||||
|
create_icon_with_padding(
|
||||||
|
input_icon,
|
||||||
|
input_icon,
|
||||||
|
padding_percent=20
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Blue background with padding
|
||||||
|
create_icon_with_background(
|
||||||
|
input_icon,
|
||||||
|
input_icon,
|
||||||
|
bg_color='#1976D2', # Material Blue
|
||||||
|
padding_percent=15
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 50)
|
||||||
|
print("✅ Icon fixed successfully!")
|
||||||
|
print()
|
||||||
|
print("Next steps:")
|
||||||
|
print("1. Run: flutter pub run flutter_launcher_icons")
|
||||||
|
print("2. Build APK: flutter build apk --release")
|
||||||
|
print("3. Check the new icon on your device")
|
||||||
|
print()
|
||||||
|
print("If icon still looks wrong, try running this script again")
|
||||||
|
print("and choose the other option.")
|
||||||
|
After Width: | Height: | Size: 90 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
r
|
||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 439 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 822 B After Width: | Height: | Size: 763 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 532 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 822 B After Width: | Height: | Size: 763 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1012 B After Width: | Height: | Size: 929 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
|
@ -1,7 +1,15 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'screens/splash_screen.dart';
|
import 'screens/splash_screen.dart';
|
||||||
|
import 'services/notification_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
|
// Pastikan Flutter binding sudah diinisialisasi
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Inisialisasi notification service
|
||||||
|
final notificationService = NotificationService();
|
||||||
|
await notificationService.initialize();
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,740 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
import '../models/sensor_data.dart';
|
||||||
|
|
||||||
|
class ChartScreen extends StatefulWidget {
|
||||||
|
const ChartScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChartScreen> createState() => _ChartScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChartScreenState extends State<ChartScreen> {
|
||||||
|
final ApiService _apiService = ApiService();
|
||||||
|
List<SensorData> historyData = [];
|
||||||
|
bool isLoading = true;
|
||||||
|
String selectedPeriod = '50'; // 50, 100, 200
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadHistoryData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistoryData() async {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await _apiService.getDataHistory(limit: int.parse(selectedPeriod));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
historyData = data;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading history: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FlSpot> _getSuhuSpots() {
|
||||||
|
return historyData.asMap().entries.map((entry) {
|
||||||
|
return FlSpot(entry.key.toDouble(), entry.value.suhu);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FlSpot> _getBeratSpots() {
|
||||||
|
return historyData.asMap().entries.map((entry) {
|
||||||
|
return FlSpot(entry.key.toDouble(), entry.value.berat);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Grafik History'),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.filter_list),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
selectedPeriod = value;
|
||||||
|
_loadHistoryData();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(value: '50', child: Text('50 Data Terakhir')),
|
||||||
|
const PopupMenuItem(value: '100', child: Text('100 Data Terakhir')),
|
||||||
|
const PopupMenuItem(value: '200', child: Text('200 Data Terakhir')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: historyData.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Belum ada data history',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadHistoryData,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Info card
|
||||||
|
_buildInfoCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Temperature chart
|
||||||
|
_buildTemperatureChart(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Weight chart
|
||||||
|
_buildWeightChart(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.blue.shade400, Colors.blue.shade600],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline, color: Colors.white, size: 32),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'History Data',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$selectedPeriod data terakhir dari database',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _loadHistoryData,
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTemperatureChart() {
|
||||||
|
final suhuSpots = _getSuhuSpots();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.thermostat, color: Colors.orange, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Grafik Suhu',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: 10,
|
||||||
|
getDrawingHorizontalLine: (value) => FlLine(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
strokeWidth: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
interval: suhuSpots.length > 20 ? 20 : 10,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value.toInt() >= 0 && value.toInt() < historyData.length) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'#${value.toInt()}',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
interval: 10,
|
||||||
|
reservedSize: 40,
|
||||||
|
getTitlesWidget: (value, meta) => Text(
|
||||||
|
'${value.toInt()}°C',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(
|
||||||
|
show: true,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: Colors.grey.shade300),
|
||||||
|
left: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
minX: 0,
|
||||||
|
maxX: suhuSpots.isNotEmpty ? suhuSpots.length.toDouble() - 1 : 10,
|
||||||
|
minY: 20,
|
||||||
|
maxY: 60,
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: suhuSpots,
|
||||||
|
isCurved: true,
|
||||||
|
color: Colors.orange,
|
||||||
|
barWidth: 3,
|
||||||
|
dotData: const FlDotData(show: false),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.orange.withOpacity(0.3),
|
||||||
|
Colors.orange.withOpacity(0.1),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipColor: (touchedSpot) => Colors.black87,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
getTooltipItems: (spots) {
|
||||||
|
return spots.map((spot) {
|
||||||
|
final index = spot.x.toInt();
|
||||||
|
if (index >= 0 && index < historyData.length) {
|
||||||
|
final data = historyData[index];
|
||||||
|
return LineTooltipItem(
|
||||||
|
'${spot.y.toStringAsFixed(1)}°C\n',
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: data.timestamp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWeightChart() {
|
||||||
|
final beratSpots = _getBeratSpots();
|
||||||
|
final avgTarget = historyData.isNotEmpty
|
||||||
|
? historyData.map((e) => e.target).reduce((a, b) => a + b) / historyData.length
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
// Filter data yang valid untuk ditampilkan (ganti nilai 0 dengan nilai sebelumnya atau null)
|
||||||
|
List<FlSpot> displaySpots = [];
|
||||||
|
double? lastValidValue;
|
||||||
|
|
||||||
|
for (var spot in beratSpots) {
|
||||||
|
if (spot.y > 10) {
|
||||||
|
// Data valid, gunakan nilai asli
|
||||||
|
displaySpots.add(spot);
|
||||||
|
lastValidValue = spot.y;
|
||||||
|
} else if (lastValidValue != null) {
|
||||||
|
// Data invalid (0), gunakan nilai terakhir yang valid
|
||||||
|
displaySpots.add(FlSpot(spot.x, lastValidValue));
|
||||||
|
}
|
||||||
|
// Jika lastValidValue null dan spot.y invalid, skip point ini
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika masih kosong setelah filter, gunakan data asli
|
||||||
|
if (displaySpots.isEmpty) {
|
||||||
|
displaySpots = beratSpots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hitung minY dan maxY yang aman untuk menghindari overflow
|
||||||
|
double minY = 0;
|
||||||
|
double maxY = 200;
|
||||||
|
|
||||||
|
if (displaySpots.isNotEmpty) {
|
||||||
|
double dataMin = displaySpots.map((e) => e.y).reduce((a, b) => a < b ? a : b);
|
||||||
|
double dataMax = displaySpots.map((e) => e.y).reduce((a, b) => a > b ? a : b);
|
||||||
|
|
||||||
|
// Sertakan target dalam perhitungan
|
||||||
|
if (avgTarget > 0) {
|
||||||
|
dataMin = dataMin < avgTarget ? dataMin : avgTarget;
|
||||||
|
dataMax = dataMax > avgTarget ? dataMax : avgTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambah padding 25%
|
||||||
|
double range = dataMax - dataMin;
|
||||||
|
double padding = range > 0 ? range * 0.25 : 50;
|
||||||
|
|
||||||
|
minY = (dataMin - padding).clamp(0, double.infinity);
|
||||||
|
maxY = dataMax + padding;
|
||||||
|
|
||||||
|
// Pastikan range minimal 100
|
||||||
|
if (maxY - minY < 100) {
|
||||||
|
double center = (maxY + minY) / 2;
|
||||||
|
minY = (center - 50).clamp(0, double.infinity);
|
||||||
|
maxY = center + 50;
|
||||||
|
}
|
||||||
|
} else if (avgTarget > 0) {
|
||||||
|
minY = 0;
|
||||||
|
maxY = (avgTarget * 1.5).clamp(100, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.scale, color: Colors.blue, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Grafik Berat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: 50,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
if ((value - avgTarget).abs() < 10) {
|
||||||
|
return FlLine(
|
||||||
|
color: Colors.green.shade300,
|
||||||
|
strokeWidth: 2,
|
||||||
|
dashArray: [5, 5],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FlLine(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
interval: beratSpots.length > 20 ? 20 : 10,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value.toInt() >= 0 && value.toInt() < historyData.length) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'#${value.toInt()}',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
interval: 50,
|
||||||
|
reservedSize: 45,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final isTarget = (value - avgTarget).abs() < 10;
|
||||||
|
return Text(
|
||||||
|
'${value.toInt()}g',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: isTarget ? Colors.green : Colors.grey,
|
||||||
|
fontWeight: isTarget ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(
|
||||||
|
show: true,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: Colors.grey.shade300),
|
||||||
|
left: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
minX: 0,
|
||||||
|
maxX: beratSpots.isNotEmpty ? beratSpots.length.toDouble() - 1 : 10,
|
||||||
|
minY: minY,
|
||||||
|
maxY: maxY,
|
||||||
|
extraLinesData: ExtraLinesData(
|
||||||
|
horizontalLines: [
|
||||||
|
if (avgTarget > 0)
|
||||||
|
HorizontalLine(
|
||||||
|
y: avgTarget,
|
||||||
|
color: Colors.green.shade400,
|
||||||
|
strokeWidth: 2,
|
||||||
|
dashArray: [8, 4],
|
||||||
|
label: HorizontalLineLabel(
|
||||||
|
show: true,
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
labelResolver: (line) => 'Target',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: displaySpots,
|
||||||
|
isCurved: true,
|
||||||
|
color: Colors.blue,
|
||||||
|
barWidth: 3,
|
||||||
|
dotData: const FlDotData(show: false),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.blue.withOpacity(0.3),
|
||||||
|
Colors.blue.withOpacity(0.1),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipColor: (touchedSpot) => Colors.black87,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
getTooltipItems: (spots) {
|
||||||
|
return spots.map((spot) {
|
||||||
|
final index = spot.x.toInt();
|
||||||
|
if (index >= 0 && index < historyData.length) {
|
||||||
|
final data = historyData[index];
|
||||||
|
return LineTooltipItem(
|
||||||
|
'${spot.y.toStringAsFixed(0)}g\n',
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: 'Target: ${data.target.toStringAsFixed(0)}g\n',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: data.timestamp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatisticsCard() {
|
||||||
|
if (historyData.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final avgSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a + b) / historyData.length;
|
||||||
|
final maxSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a > b ? a : b);
|
||||||
|
final minSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a < b ? a : b);
|
||||||
|
|
||||||
|
final avgBerat = historyData.map((e) => e.berat).reduce((a, b) => a + b) / historyData.length;
|
||||||
|
final maxBerat = historyData.map((e) => e.berat).reduce((a, b) => a > b ? a : b);
|
||||||
|
final minBerat = historyData.map((e) => e.berat).reduce((a, b) => a < b ? a : b);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Statistik',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.thermostat,
|
||||||
|
iconColor: Colors.orange,
|
||||||
|
label: 'Rata-rata Suhu',
|
||||||
|
value: '${avgSuhu.toStringAsFixed(1)}°C',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.scale,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
label: 'Rata-rata Berat',
|
||||||
|
value: '${avgBerat.toStringAsFixed(0)}g',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.arrow_upward,
|
||||||
|
iconColor: Colors.red,
|
||||||
|
label: 'Max Suhu',
|
||||||
|
value: '${maxSuhu.toStringAsFixed(1)}°C',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.arrow_downward,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
label: 'Min Suhu',
|
||||||
|
value: '${minSuhu.toStringAsFixed(1)}°C',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.arrow_upward,
|
||||||
|
iconColor: Colors.red,
|
||||||
|
label: 'Max Berat',
|
||||||
|
value: '${maxBerat.toStringAsFixed(0)}g',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.arrow_downward,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
label: 'Min Berat',
|
||||||
|
value: '${minBerat.toStringAsFixed(0)}g',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatItem({
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: iconColor, size: 24),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,14 @@
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../services/notification_manager.dart';
|
import '../services/notification_manager.dart';
|
||||||
import '../models/sensor_data.dart';
|
import '../models/sensor_data.dart';
|
||||||
|
import '../widgets/temperature_chart.dart';
|
||||||
|
import '../widgets/weight_chart.dart';
|
||||||
|
import 'chart_screen.dart';
|
||||||
|
import 'notification_screen.dart';
|
||||||
|
|
||||||
class DashboardScreenWithApi extends StatefulWidget {
|
class DashboardScreenWithApi extends StatefulWidget {
|
||||||
const DashboardScreenWithApi({super.key});
|
const DashboardScreenWithApi({super.key});
|
||||||
|
|
@ -23,6 +28,11 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
||||||
double berat = 0.0;
|
double berat = 0.0;
|
||||||
double target = 0.0;
|
double target = 0.0;
|
||||||
|
|
||||||
|
// Data historis untuk grafik
|
||||||
|
List<FlSpot> suhuHistory = [];
|
||||||
|
List<FlSpot> beratHistory = [];
|
||||||
|
double chartTimeCounter = 0;
|
||||||
|
|
||||||
// Status relay
|
// Status relay
|
||||||
bool heater = false;
|
bool heater = false;
|
||||||
bool fanIn = false;
|
bool fanIn = false;
|
||||||
|
|
@ -38,10 +48,14 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
||||||
|
|
||||||
// Timer untuk auto-update
|
// Timer untuk auto-update
|
||||||
Timer? _pollingTimer;
|
Timer? _pollingTimer;
|
||||||
|
|
||||||
|
// Current navigation index
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_initializeChartData(); // Load initial chart data
|
||||||
_initializeNotifications();
|
_initializeNotifications();
|
||||||
_initializeConnections();
|
_initializeConnections();
|
||||||
_startPolling();
|
_startPolling();
|
||||||
|
|
@ -52,6 +66,53 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
||||||
await _notificationManager.initialize();
|
await _notificationManager.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize chart data
|
||||||
|
Future<void> _initializeChartData() async {
|
||||||
|
// Init dengan dummy data dulu
|
||||||
|
_initializeDummyChartData();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load 20 data terakhir dari API untuk grafik
|
||||||
|
final historyData = await _apiService.getDataHistory(limit: 20);
|
||||||
|
|
||||||
|
if (historyData.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
suhuHistory.clear();
|
||||||
|
beratHistory.clear();
|
||||||
|
|
||||||
|
for (int i = 0; i < historyData.length; i++) {
|
||||||
|
final data = historyData[i];
|
||||||
|
suhuHistory.add(FlSpot(i.toDouble(), data.suhu));
|
||||||
|
beratHistory.add(FlSpot(i.toDouble(), data.berat));
|
||||||
|
}
|
||||||
|
|
||||||
|
chartTimeCounter = historyData.length.toDouble();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error loading chart data: $e');
|
||||||
|
// Tetap pakai dummy data jika error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeDummyChartData() {
|
||||||
|
suhuHistory.clear();
|
||||||
|
beratHistory.clear();
|
||||||
|
|
||||||
|
double beratAwal = 450.0;
|
||||||
|
double suhuBase = 28.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
double suhuVal = suhuBase + (i * 0.3) + (i % 2 == 0 ? 0.2 : -0.1);
|
||||||
|
suhuHistory.add(FlSpot(i.toDouble(), suhuVal));
|
||||||
|
|
||||||
|
double beratVal = beratAwal - (i * 2.5);
|
||||||
|
beratHistory.add(FlSpot(i.toDouble(), beratVal));
|
||||||
|
}
|
||||||
|
|
||||||
|
chartTimeCounter = 15;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-update data setiap 5 detik
|
// Auto-update data setiap 5 detik
|
||||||
void _startPolling() {
|
void _startPolling() {
|
||||||
_pollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
_pollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||||
|
|
@ -98,18 +159,26 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
||||||
fanIn = data.relay2;
|
fanIn = data.relay2;
|
||||||
exhaust = data.relay4;
|
exhaust = data.relay4;
|
||||||
|
|
||||||
|
// Update chart data
|
||||||
|
if (suhuHistory.length > 20) {
|
||||||
|
suhuHistory.removeAt(0);
|
||||||
|
beratHistory.removeAt(0);
|
||||||
|
// Reindex
|
||||||
|
for (int i = 0; i < suhuHistory.length; i++) {
|
||||||
|
suhuHistory[i] = FlSpot(i.toDouble(), suhuHistory[i].y);
|
||||||
|
beratHistory[i] = FlSpot(i.toDouble(), beratHistory[i].y);
|
||||||
|
}
|
||||||
|
chartTimeCounter = suhuHistory.length.toDouble();
|
||||||
|
}
|
||||||
|
suhuHistory.add(FlSpot(chartTimeCounter, suhu));
|
||||||
|
beratHistory.add(FlSpot(chartTimeCounter, berat));
|
||||||
|
chartTimeCounter++;
|
||||||
|
|
||||||
// Update status dan trigger notifikasi
|
// Update status dan trigger notifikasi
|
||||||
String newStatus = data.status;
|
String newStatus = data.status;
|
||||||
|
|
||||||
// Notifikasi hanya untuk pengeringan selesai
|
// Notifikasi hanya untuk pengeringan selesai
|
||||||
if (newStatus != _previousStatus) {
|
if (newStatus != _previousStatus) {
|
||||||
// HAPUS notifikasi untuk status lain
|
|
||||||
// if (newStatus == 'READY' && _previousStatus != 'READY') {
|
|
||||||
// _notificationManager.notifyPengeringanDimulai();
|
|
||||||
// } else if (newStatus == 'BERJALAN' && _previousStatus != 'BERJALAN') {
|
|
||||||
// _notificationManager.notifyIkanTerdeteksi(berat);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// HANYA notifikasi pengeringan selesai
|
// HANYA notifikasi pengeringan selesai
|
||||||
if (newStatus == 'SELESAI' && _previousStatus != 'SELESAI') {
|
if (newStatus == 'SELESAI' && _previousStatus != 'SELESAI') {
|
||||||
print('🔔 Dashboard: Status changed to SELESAI');
|
print('🔔 Dashboard: Status changed to SELESAI');
|
||||||
|
|
@ -169,8 +238,14 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// List of screens
|
||||||
|
final List<Widget> screens = [
|
||||||
|
_buildDashboardContent(),
|
||||||
|
const NotificationScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: _currentIndex == 0 ? AppBar(
|
||||||
title: const Text('Dashboard Pengering Ikan'),
|
title: const Text('Dashboard Pengering Ikan'),
|
||||||
actions: [
|
actions: [
|
||||||
// API status only
|
// API status only
|
||||||
|
|
@ -183,95 +258,155 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
) : null,
|
||||||
body: RefreshIndicator(
|
body: screens[_currentIndex],
|
||||||
onRefresh: _loadLatestData,
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
child: SingleChildScrollView(
|
type: BottomNavigationBarType.fixed,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
backgroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.all(16),
|
selectedItemColor: Colors.blue,
|
||||||
child: Column(
|
unselectedItemColor: Colors.grey,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
currentIndex: _currentIndex,
|
||||||
children: [
|
onTap: (index) {
|
||||||
// Status Card
|
if (index == 2) {
|
||||||
_buildStatusCard(),
|
// Navigate to chart screen
|
||||||
const SizedBox(height: 16),
|
Navigator.push(
|
||||||
|
context,
|
||||||
// Metrics
|
MaterialPageRoute(builder: (context) => const ChartScreen()),
|
||||||
Row(
|
);
|
||||||
children: [
|
} else {
|
||||||
Expanded(
|
setState(() {
|
||||||
child: _buildMetricCard(
|
_currentIndex = index;
|
||||||
icon: Icons.thermostat,
|
});
|
||||||
iconColor: Colors.orange,
|
}
|
||||||
label: 'Suhu',
|
},
|
||||||
value: suhu.toStringAsFixed(1),
|
items: const [
|
||||||
unit: '°C',
|
BottomNavigationBarItem(
|
||||||
),
|
icon: Icon(Icons.home),
|
||||||
),
|
label: 'Dashboard',
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildMetricCard(
|
|
||||||
icon: Icons.scale,
|
|
||||||
iconColor: Colors.blue,
|
|
||||||
label: 'Berat',
|
|
||||||
value: berat.toStringAsFixed(0),
|
|
||||||
unit: 'g',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Target
|
|
||||||
_buildMetricCard(
|
|
||||||
icon: Icons.flag,
|
|
||||||
iconColor: Colors.green,
|
|
||||||
label: 'Target Berat',
|
|
||||||
value: target.toStringAsFixed(0),
|
|
||||||
unit: 'g',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
const Text(
|
|
||||||
'Perangkat',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
GridView.count(
|
|
||||||
crossAxisCount: 3,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
childAspectRatio: 0.9,
|
|
||||||
children: [
|
|
||||||
_buildDeviceCard(
|
|
||||||
icon: Icons.whatshot,
|
|
||||||
iconColor: Colors.deepOrange,
|
|
||||||
label: 'Heater',
|
|
||||||
status: heater,
|
|
||||||
),
|
|
||||||
_buildDeviceCard(
|
|
||||||
icon: Icons.air,
|
|
||||||
iconColor: Colors.blue,
|
|
||||||
label: 'Fan',
|
|
||||||
status: fanIn,
|
|
||||||
),
|
|
||||||
_buildDeviceCard(
|
|
||||||
icon: Icons.wind_power,
|
|
||||||
iconColor: Colors.teal,
|
|
||||||
label: 'Exhaust',
|
|
||||||
status: exhaust,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.notifications),
|
||||||
|
label: 'Notifikasi',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.bar_chart),
|
||||||
|
label: 'Grafik',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDashboardContent() {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadLatestData,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Status Card
|
||||||
|
_buildStatusCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
icon: Icons.thermostat,
|
||||||
|
iconColor: Colors.orange,
|
||||||
|
label: 'Suhu',
|
||||||
|
value: suhu.toStringAsFixed(1),
|
||||||
|
unit: '°C',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
icon: Icons.scale,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
label: 'Berat',
|
||||||
|
value: berat.toStringAsFixed(0),
|
||||||
|
unit: 'g',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Target
|
||||||
|
_buildMetricCard(
|
||||||
|
icon: Icons.flag,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
label: 'Target Berat',
|
||||||
|
value: target.toStringAsFixed(0),
|
||||||
|
unit: 'g',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ======= GRAFIK CHARTS =======
|
||||||
|
// Temperature Chart Widget
|
||||||
|
TemperatureChart(
|
||||||
|
dataPoints: suhuHistory,
|
||||||
|
title: 'Suhu Real-time',
|
||||||
|
lineColor: Colors.orange,
|
||||||
|
gradientStartColor: Colors.orange,
|
||||||
|
gradientEndColor: Colors.red,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Weight Chart Widget
|
||||||
|
WeightChart(
|
||||||
|
dataPoints: beratHistory,
|
||||||
|
targetWeight: target,
|
||||||
|
title: 'Berat Real-time',
|
||||||
|
lineColor: Colors.blue,
|
||||||
|
gradientStartColor: Colors.blue,
|
||||||
|
gradientEndColor: Colors.cyan,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
const Text(
|
||||||
|
'Perangkat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 0.9,
|
||||||
|
children: [
|
||||||
|
_buildDeviceCard(
|
||||||
|
icon: Icons.whatshot,
|
||||||
|
iconColor: Colors.deepOrange,
|
||||||
|
label: 'Heater',
|
||||||
|
status: heater,
|
||||||
|
),
|
||||||
|
_buildDeviceCard(
|
||||||
|
icon: Icons.air,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
label: 'Fan',
|
||||||
|
status: fanIn,
|
||||||
|
),
|
||||||
|
_buildDeviceCard(
|
||||||
|
icon: Icons.wind_power,
|
||||||
|
iconColor: Colors.teal,
|
||||||
|
label: 'Exhaust',
|
||||||
|
status: exhaust,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ class ApiService {
|
||||||
factory ApiService() => _instance;
|
factory ApiService() => _instance;
|
||||||
ApiService._internal();
|
ApiService._internal();
|
||||||
|
|
||||||
// Base URL Railway
|
// Base URL Railway (Updated account)
|
||||||
static const String baseUrl = 'https://web-production-47eb.up.railway.app';
|
static const String baseUrl = 'https://backendaplikasinovil-production.up.railway.app';
|
||||||
|
|
||||||
// Timeout
|
// Timeout
|
||||||
static const Duration timeout = Duration(seconds: 10);
|
static const Duration timeout = Duration(seconds: 10);
|
||||||
|
|
|
||||||
|
|
@ -115,38 +115,23 @@ class NotificationManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification helpers for specific events
|
// Notification helper untuk pengeringan selesai
|
||||||
Future<void> notifyPengeringanDimulai() async {
|
|
||||||
await addNotification(
|
|
||||||
title: 'Pengeringan Dimulai',
|
|
||||||
message: 'Proses pengeringan ikan telah dimulai',
|
|
||||||
type: NotificationType.info,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> notifyIkanTerdeteksi(double berat) async {
|
|
||||||
await addNotification(
|
|
||||||
title: 'Ikan Terdeteksi',
|
|
||||||
message: 'Berat ikan: ${berat.toStringAsFixed(0)} gram',
|
|
||||||
type: NotificationType.info,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> notifyPengeringanBerjalan(double berat, double target) async {
|
|
||||||
await addNotification(
|
|
||||||
title: 'Pengeringan Berjalan',
|
|
||||||
message: 'Berat: ${berat.toStringAsFixed(0)}g | Target: ${target.toStringAsFixed(0)}g',
|
|
||||||
type: NotificationType.info,
|
|
||||||
showPush: false, // Don't show push for regular updates
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> notifyPengeringanSelesai(double beratAkhir) async {
|
Future<void> notifyPengeringanSelesai(double beratAkhir) async {
|
||||||
|
print('🔔 Calling notifyPengeringanSelesai with berat: $beratAkhir');
|
||||||
|
|
||||||
|
// Panggil notification service langsung
|
||||||
|
final notificationService = NotificationService();
|
||||||
|
await notificationService.notifyPengeringanSelesai(beratAkhir);
|
||||||
|
|
||||||
|
// Simpan juga ke notification manager
|
||||||
await addNotification(
|
await addNotification(
|
||||||
title: 'Pengeringan Selesai',
|
title: 'Pengeringan Selesai',
|
||||||
message: 'Target tercapai! Berat akhir: ${beratAkhir.toStringAsFixed(0)} gram',
|
message: 'Target tercapai! Berat akhir: ${beratAkhir.toStringAsFixed(0)} gram',
|
||||||
type: NotificationType.success,
|
type: NotificationType.success,
|
||||||
|
showPush: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
print('✅ notifyPengeringanSelesai completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> notifySuhuTinggi(double suhu) async {
|
Future<void> notifySuhuTinggi(double suhu) async {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
|
||||||
|
class TemperatureChart extends StatelessWidget {
|
||||||
|
final List<FlSpot> dataPoints;
|
||||||
|
final String title;
|
||||||
|
final Color lineColor;
|
||||||
|
final Color gradientStartColor;
|
||||||
|
final Color gradientEndColor;
|
||||||
|
final double minY;
|
||||||
|
final double maxY;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
const TemperatureChart({
|
||||||
|
super.key,
|
||||||
|
required this.dataPoints,
|
||||||
|
this.title = 'Suhu Real-time',
|
||||||
|
this.lineColor = Colors.orange,
|
||||||
|
this.gradientStartColor = Colors.orange,
|
||||||
|
this.gradientEndColor = Colors.red,
|
||||||
|
this.minY = 20,
|
||||||
|
this.maxY = 60,
|
||||||
|
this.unit = '°C',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: lineColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.thermostat, color: lineColor, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
dataPoints.isNotEmpty
|
||||||
|
? '${dataPoints.last.y.toStringAsFixed(1)} $unit'
|
||||||
|
: '-- $unit',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: lineColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.fiber_manual_record,
|
||||||
|
color: lineColor,
|
||||||
|
size: 8,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'LIVE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 200, // Naik dari 180 jadi 200
|
||||||
|
child: dataPoints.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'Menunggu data...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, right: 8),
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: 10,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
interval: 5,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'${value.toInt()}s',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
interval: 10,
|
||||||
|
reservedSize: 45,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Text(
|
||||||
|
'${value.toInt()}$unit',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(
|
||||||
|
show: true,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: Colors.grey.shade300),
|
||||||
|
left: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
minX: dataPoints.isNotEmpty
|
||||||
|
? dataPoints.first.x
|
||||||
|
: 0,
|
||||||
|
maxX: dataPoints.isNotEmpty
|
||||||
|
? dataPoints.last.x
|
||||||
|
: 10,
|
||||||
|
minY: minY,
|
||||||
|
maxY: maxY,
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: dataPoints,
|
||||||
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.3,
|
||||||
|
color: lineColor,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: true,
|
||||||
|
getDotPainter: (spot, percent, barData, index) {
|
||||||
|
// Hanya tampilkan dot di titik terakhir
|
||||||
|
if (index == dataPoints.length - 1) {
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 5,
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 3,
|
||||||
|
strokeColor: lineColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
gradientStartColor.withOpacity(0.3),
|
||||||
|
gradientEndColor.withOpacity(0.1),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipColor: (touchedSpot) => Colors.black87,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
fitInsideHorizontally: true,
|
||||||
|
fitInsideVertically: true,
|
||||||
|
tooltipPadding: const EdgeInsets.all(8),
|
||||||
|
tooltipMargin: 8,
|
||||||
|
getTooltipItems: (List<LineBarSpot> touchedSpots) {
|
||||||
|
return touchedSpots.map((LineBarSpot touchedSpot) {
|
||||||
|
return LineTooltipItem(
|
||||||
|
'${touchedSpot.y.toStringAsFixed(1)} $unit',
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
handleBuiltInTouches: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
|
||||||
|
class WeightChart extends StatelessWidget {
|
||||||
|
final List<FlSpot> dataPoints;
|
||||||
|
final double targetWeight;
|
||||||
|
final String title;
|
||||||
|
final Color lineColor;
|
||||||
|
final Color gradientStartColor;
|
||||||
|
final Color gradientEndColor;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
const WeightChart({
|
||||||
|
super.key,
|
||||||
|
required this.dataPoints,
|
||||||
|
required this.targetWeight,
|
||||||
|
this.title = 'Berat Real-time',
|
||||||
|
this.lineColor = Colors.blue,
|
||||||
|
this.gradientStartColor = Colors.blue,
|
||||||
|
this.gradientEndColor = Colors.cyan,
|
||||||
|
this.unit = 'g',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Filter data yang valid untuk ditampilkan (ganti nilai 0 dengan nilai sebelumnya)
|
||||||
|
List<FlSpot> displayPoints = [];
|
||||||
|
double? lastValidValue;
|
||||||
|
|
||||||
|
for (var spot in dataPoints) {
|
||||||
|
if (spot.y > 10) {
|
||||||
|
// Data valid, gunakan nilai asli
|
||||||
|
displayPoints.add(spot);
|
||||||
|
lastValidValue = spot.y;
|
||||||
|
} else if (lastValidValue != null) {
|
||||||
|
// Data invalid (0), gunakan nilai terakhir yang valid
|
||||||
|
displayPoints.add(FlSpot(spot.x, lastValidValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika masih kosong setelah filter, gunakan data asli
|
||||||
|
if (displayPoints.isEmpty) {
|
||||||
|
displayPoints = dataPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hitung min dan max Y berdasarkan data yang akan ditampilkan
|
||||||
|
double minY = 0;
|
||||||
|
double maxY = 200;
|
||||||
|
|
||||||
|
if (displayPoints.isNotEmpty) {
|
||||||
|
// Cari nilai min dan max dari data yang akan ditampilkan
|
||||||
|
double dataMin = displayPoints.map((e) => e.y).reduce((a, b) => a < b ? a : b);
|
||||||
|
double dataMax = displayPoints.map((e) => e.y).reduce((a, b) => a > b ? a : b);
|
||||||
|
|
||||||
|
// Jika ada target, sertakan dalam perhitungan
|
||||||
|
if (targetWeight > 0) {
|
||||||
|
dataMin = dataMin < targetWeight ? dataMin : targetWeight;
|
||||||
|
dataMax = dataMax > targetWeight ? dataMax : targetWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pastikan minY tidak negatif dan ada padding yang cukup
|
||||||
|
double range = dataMax - dataMin;
|
||||||
|
double padding = range > 0 ? range * 0.25 : 50; // 25% padding atau minimal 50
|
||||||
|
|
||||||
|
minY = (dataMin - padding).clamp(0, double.infinity);
|
||||||
|
maxY = dataMax + padding;
|
||||||
|
|
||||||
|
// Pastikan ada range minimal 100
|
||||||
|
if (maxY - minY < 100) {
|
||||||
|
double center = (maxY + minY) / 2;
|
||||||
|
minY = (center - 50).clamp(0, double.infinity);
|
||||||
|
maxY = center + 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
print('📊 Weight Chart: min=$minY, max=$maxY, dataMin=$dataMin, dataMax=$dataMax, displayPoints=${displayPoints.length}/${dataPoints.length}');
|
||||||
|
|
||||||
|
} else if (targetWeight > 0) {
|
||||||
|
// Kalau belum ada data, gunakan target
|
||||||
|
minY = 0;
|
||||||
|
maxY = (targetWeight * 1.5).clamp(100, double.infinity);
|
||||||
|
} else {
|
||||||
|
// Fallback default
|
||||||
|
minY = 0;
|
||||||
|
maxY = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: lineColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.scale, color: lineColor, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
dataPoints.isNotEmpty
|
||||||
|
? '${dataPoints.last.y.toStringAsFixed(0)} $unit'
|
||||||
|
: '-- $unit',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.arrow_forward, size: 12, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${targetWeight.toStringAsFixed(0)} $unit',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: lineColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.fiber_manual_record,
|
||||||
|
color: lineColor,
|
||||||
|
size: 8,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'LIVE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 200, // Naik dari 180 jadi 200 biar lebih lapang
|
||||||
|
child: dataPoints.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'Menunggu data...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, right: 8), // Tambah padding kiri kanan
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: 50,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
// Garis target lebih tebal
|
||||||
|
if ((value - targetWeight).abs() < 5) {
|
||||||
|
return FlLine(
|
||||||
|
color: Colors.green.shade300,
|
||||||
|
strokeWidth: 2,
|
||||||
|
dashArray: [5, 5],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FlLine(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
interval: 5,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'${value.toInt()}s',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
interval: 50,
|
||||||
|
reservedSize: 50, // Turun ke 50, karena masalahnya bukan disini
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
// Tandai target dengan warna hijau
|
||||||
|
final isTarget = (value - targetWeight).abs() < 5;
|
||||||
|
return Text(
|
||||||
|
'${value.toInt()}$unit',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: isTarget ? Colors.green : Colors.grey,
|
||||||
|
fontWeight: isTarget ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(
|
||||||
|
show: true,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: Colors.grey.shade300),
|
||||||
|
left: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
minX: displayPoints.isNotEmpty
|
||||||
|
? displayPoints.first.x
|
||||||
|
: 0,
|
||||||
|
maxX: displayPoints.isNotEmpty
|
||||||
|
? displayPoints.last.x
|
||||||
|
: 10,
|
||||||
|
minY: minY,
|
||||||
|
maxY: maxY,
|
||||||
|
extraLinesData: ExtraLinesData(
|
||||||
|
horizontalLines: [
|
||||||
|
HorizontalLine(
|
||||||
|
y: targetWeight,
|
||||||
|
color: Colors.green.shade400,
|
||||||
|
strokeWidth: 2,
|
||||||
|
dashArray: [8, 4],
|
||||||
|
label: HorizontalLineLabel(
|
||||||
|
show: true,
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
padding: const EdgeInsets.only(right: 5, bottom: 5),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
labelResolver: (line) => 'Target',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: displayPoints,
|
||||||
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.3,
|
||||||
|
color: lineColor,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: true,
|
||||||
|
getDotPainter: (spot, percent, barData, index) {
|
||||||
|
// Hanya tampilkan dot di titik terakhir
|
||||||
|
if (index == displayPoints.length - 1) {
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 5,
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 3,
|
||||||
|
strokeColor: lineColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
gradientStartColor.withOpacity(0.3),
|
||||||
|
gradientEndColor.withOpacity(0.1),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipColor: (touchedSpot) => Colors.black87,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
fitInsideHorizontally: true,
|
||||||
|
fitInsideVertically: true,
|
||||||
|
tooltipPadding: const EdgeInsets.all(8),
|
||||||
|
tooltipMargin: 8,
|
||||||
|
getTooltipItems: (List<LineBarSpot> touchedSpots) {
|
||||||
|
return touchedSpots.map((LineBarSpot touchedSpot) {
|
||||||
|
final remaining = touchedSpot.y - targetWeight;
|
||||||
|
return LineTooltipItem(
|
||||||
|
'${touchedSpot.y.toStringAsFixed(0)} $unit\n',
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: remaining > 0
|
||||||
|
? 'Sisa ${remaining.toStringAsFixed(0)} $unit'
|
||||||
|
: 'Target tercapai!',
|
||||||
|
style: TextStyle(
|
||||||
|
color: remaining > 0
|
||||||
|
? Colors.orange.shade200
|
||||||
|
: Colors.green.shade200,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
handleBuiltInTouches: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
[phases.setup]
|
||||||
|
nixPkgs = ['nodejs-18_x']
|
||||||
|
|
||||||
|
[phases.build]
|
||||||
|
cmds = ['cd backend && npm install']
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = 'cd backend && node server.js'
|
||||||
20
pubspec.yaml
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.1.0+2
|
version: 1.2.0+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.5.1 <4.0.0'
|
sdk: '>=3.5.1 <4.0.0'
|
||||||
|
|
@ -117,13 +117,25 @@ flutter:
|
||||||
# Flutter Launcher Icons
|
# Flutter Launcher Icons
|
||||||
# Generates app icons for Android and iOS
|
# Generates app icons for Android and iOS
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: "launcher_icon"
|
||||||
ios: true
|
ios: true
|
||||||
image_path: "assets/images/app_icon.png"
|
image_path: "assets/images/app_icon.png"
|
||||||
# Adaptive icon for Android 8.0+
|
min_sdk_android: 21
|
||||||
adaptive_icon_background: "#1976D2" # Blue color matching splash screen
|
|
||||||
|
# Adaptive icon for Android 8.0+ (responsive di semua device)
|
||||||
|
adaptive_icon_background: "#2196F3" # Material Blue 500
|
||||||
adaptive_icon_foreground: "assets/images/app_icon.png"
|
adaptive_icon_foreground: "assets/images/app_icon.png"
|
||||||
|
|
||||||
|
# iOS specific
|
||||||
|
remove_alpha_ios: true
|
||||||
|
|
||||||
|
# Web (jika diperlukan)
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/images/app_icon.png"
|
||||||
|
background_color: "#2196F3"
|
||||||
|
theme_color: "#2196F3"
|
||||||
|
|
||||||
# Flutter Native Splash
|
# Flutter Native Splash
|
||||||
# DISABLED - Menggunakan custom splash screen saja
|
# DISABLED - Menggunakan custom splash screen saja
|
||||||
# flutter_native_splash:
|
# flutter_native_splash:
|
||||||
|
|
|
||||||
BIN
web/favicon.png
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 341 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.2 KiB |
|
|
@ -3,8 +3,8 @@
|
||||||
"short_name": "aplikasi_novil",
|
"short_name": "aplikasi_novil",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#2196F3",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#2196F3",
|
||||||
"description": "A new Flutter project.",
|
"description": "A new Flutter project.",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
|
|
@ -32,4 +32,4 @@
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||