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 {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
|
||||
// Suppress obsolete Java version warnings
|
||||
freeCompilerArgs += listOf("-Xlint:-options")
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
|
|
@ -36,6 +39,17 @@ android {
|
|||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
|
||||
// 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.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
|
||||
android:label="aplikasi_novil"
|
||||
android:label="Pengering Ikan"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
@ -15,7 +27,9 @@
|
|||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
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
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
|
|
@ -29,6 +43,22 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,32 @@
|
|||
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
|
||||
|
||||
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"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1976D2</color>
|
||||
<color name="ic_launcher_background">#2196F3</color>
|
||||
</resources>
|
||||
|
|
@ -14,6 +14,18 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
|||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
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 {
|
||||
project.evaluationDependsOn(":app")
|
||||
|
|
|
|||
|
|
@ -1,2 +1,16 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
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
|
||||
let lastDataTimestamp = Date.now();
|
||||
|
||||
// Connection timeout (30 seconds)
|
||||
const CONNECTION_TIMEOUT = 30000;
|
||||
// Connection timeout (5 minutes = 300 seconds)
|
||||
const CONNECTION_TIMEOUT = 300000;
|
||||
|
||||
// Check connection status periodically
|
||||
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 berat2 = 0;
|
||||
bool scale1Valid = false;
|
||||
bool scale2Valid = false;
|
||||
|
||||
// Cek dan baca scale 1 (style kode test)
|
||||
if (scale1.is_ready()) {
|
||||
berat1 = scale1.get_units(5);
|
||||
scale1Valid = true;
|
||||
} else {
|
||||
Serial.println("WARNING: Scale 1 not ready!");
|
||||
}
|
||||
|
||||
// Cek dan baca scale 2 (style kode test)
|
||||
if (scale2.is_ready()) {
|
||||
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;
|
||||
beratTersimpan = true;
|
||||
|
||||
Serial.println("==================================");
|
||||
Serial.println("BERAT AWAL TERSIMPAN");
|
||||
Serial.print("Berat Awal : ");
|
||||
Serial.println(berat_awal);
|
||||
Serial.print("Target : ");
|
||||
Serial.println(berat_trigger);
|
||||
Serial.print("Berat Awal : ");
|
||||
Serial.print(berat_awal, 2);
|
||||
Serial.println(" g");
|
||||
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:";
|
||||
msg += String(berat_awal, 0);
|
||||
|
|
@ -824,70 +858,117 @@ void loop() {
|
|||
// TARGET TERCAPAI → SELESAI
|
||||
// =====================================================
|
||||
if (beratTersimpan && berat <= berat_trigger) {
|
||||
modePengeringan = false;
|
||||
pengeringanSelesai = true;
|
||||
|
||||
// Matikan heater 1, 2 dan fan, nyalakan exhaust untuk cooling
|
||||
digitalWrite(RELAY_HEATER1, HIGH);
|
||||
digitalWrite(RELAY_HEATER2, HIGH);
|
||||
digitalWrite(RELAY_FAN, HIGH);
|
||||
digitalWrite(RELAY_EXHAUST, LOW); // EXHAUST ON
|
||||
static unsigned long targetReachedTime = 0;
|
||||
static bool targetReachedFlag = false;
|
||||
|
||||
Serial.println("PENGERINGAN SELESAI");
|
||||
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();
|
||||
// Pertama kali target tercapai
|
||||
if (!targetReachedFlag) {
|
||||
targetReachedTime = millis();
|
||||
targetReachedFlag = true;
|
||||
Serial.println("==================================");
|
||||
Serial.println("TARGET TERCAPAI - Verifikasi 10 detik...");
|
||||
Serial.print("Berat Sekarang: ");
|
||||
Serial.print(berat, 2);
|
||||
Serial.println(" g");
|
||||
Serial.print("Target: ");
|
||||
Serial.print(berat_trigger, 2);
|
||||
Serial.println(" g");
|
||||
Serial.println("==================================");
|
||||
client.publish(topicStatus, "Verifikasi target...");
|
||||
}
|
||||
|
||||
// Matikan exhaust dan kembali ke ready
|
||||
digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF
|
||||
// Tunggu 10 detik untuk memastikan berat stabil di bawah target
|
||||
if (millis() - targetReachedTime >= 10000) {
|
||||
// Setelah 10 detik masih di bawah target, baru selesai
|
||||
modePengeringan = false;
|
||||
pengeringanSelesai = true;
|
||||
targetReachedFlag = false; // Reset flag
|
||||
|
||||
// Reset variabel
|
||||
pengeringanSelesai = false;
|
||||
beratTersimpan = false;
|
||||
berat_awal = 0;
|
||||
berat_trigger = 0;
|
||||
waktuMulaiBerat = 0;
|
||||
// Matikan heater 1, 2 dan fan, nyalakan exhaust untuk cooling
|
||||
digitalWrite(RELAY_HEATER1, HIGH);
|
||||
digitalWrite(RELAY_HEATER2, HIGH);
|
||||
digitalWrite(RELAY_FAN, HIGH);
|
||||
digitalWrite(RELAY_EXHAUST, LOW); // EXHAUST ON
|
||||
|
||||
lcd.clear();
|
||||
tampilReady();
|
||||
client.publish(topicStatus, "PENGERINGAN SIAP");
|
||||
Serial.println("==================================");
|
||||
Serial.println("PENGERINGAN SELESAI");
|
||||
Serial.print("Berat Awal : ");
|
||||
Serial.print(berat_awal, 2);
|
||||
Serial.println(" g");
|
||||
Serial.print("Berat Akhir : ");
|
||||
Serial.print(berat, 2);
|
||||
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("==================================");
|
||||
|
||||
// Kirim status relay setelah reset
|
||||
kirimStatusRelay(suhu, berat);
|
||||
client.publish(topicStatus, "PENGERINGAN SELESAI");
|
||||
|
||||
Serial.println("Sistem kembali ke READY");
|
||||
// Kirim data dengan status COMPLETED untuk trigger notifikasi
|
||||
kirimStatusRelay(suhu, berat);
|
||||
|
||||
delay(500);
|
||||
return;
|
||||
// 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
|
||||
// =====================================================
|
||||
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
|
||||
|
||||
// FAN selalu ON saat pengeringan
|
||||
digitalWrite(RELAY_FAN, LOW); // FAN ON
|
||||
|
||||
// Kontrol HEATER dan EXHAUST berdasarkan suhu
|
||||
if (suhu >= 60.0) {
|
||||
// Suhu sudah tinggi (≥60°C)
|
||||
if (suhu >= 55.0) {
|
||||
// Suhu sudah tinggi (≥55°C)
|
||||
digitalWrite(RELAY_HEATER1, HIGH); // HEATER 1 OFF
|
||||
digitalWrite(RELAY_HEATER2, HIGH); // HEATER 2 OFF
|
||||
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 {
|
||||
// Suhu masih rendah (<60°C)
|
||||
// Suhu masih rendah (<55°C)
|
||||
digitalWrite(RELAY_HEATER1, LOW); // HEATER 1 ON
|
||||
digitalWrite(RELAY_HEATER2, LOW); // HEATER 2 ON
|
||||
digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF
|
||||
Serial.println("Suhu < 60C: HEATER ON, EXHAUST OFF");
|
||||
// Serial.println("Suhu < 55C: HEATER ON, EXHAUST OFF");
|
||||
}
|
||||
} else {
|
||||
// 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 'screens/splash_screen.dart';
|
||||
import 'services/notification_service.dart';
|
||||
|
||||
void main() async {
|
||||
// Pastikan Flutter binding sudah diinisialisasi
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Inisialisasi notification service
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
|
||||
void main() {
|
||||
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 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/notification_manager.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 {
|
||||
const DashboardScreenWithApi({super.key});
|
||||
|
|
@ -23,6 +28,11 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
|||
double berat = 0.0;
|
||||
double target = 0.0;
|
||||
|
||||
// Data historis untuk grafik
|
||||
List<FlSpot> suhuHistory = [];
|
||||
List<FlSpot> beratHistory = [];
|
||||
double chartTimeCounter = 0;
|
||||
|
||||
// Status relay
|
||||
bool heater = false;
|
||||
bool fanIn = false;
|
||||
|
|
@ -39,9 +49,13 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
|||
// Timer untuk auto-update
|
||||
Timer? _pollingTimer;
|
||||
|
||||
// Current navigation index
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeChartData(); // Load initial chart data
|
||||
_initializeNotifications();
|
||||
_initializeConnections();
|
||||
_startPolling();
|
||||
|
|
@ -52,6 +66,53 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
|||
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
|
||||
void _startPolling() {
|
||||
_pollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||
|
|
@ -98,18 +159,26 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
|||
fanIn = data.relay2;
|
||||
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
|
||||
String newStatus = data.status;
|
||||
|
||||
// Notifikasi hanya untuk pengeringan selesai
|
||||
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
|
||||
if (newStatus == 'SELESAI' && _previousStatus != 'SELESAI') {
|
||||
print('🔔 Dashboard: Status changed to SELESAI');
|
||||
|
|
@ -169,8 +238,14 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// List of screens
|
||||
final List<Widget> screens = [
|
||||
_buildDashboardContent(),
|
||||
const NotificationScreen(),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
appBar: _currentIndex == 0 ? AppBar(
|
||||
title: const Text('Dashboard Pengering Ikan'),
|
||||
actions: [
|
||||
// API status only
|
||||
|
|
@ -183,95 +258,155 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
|
|||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: 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),
|
||||
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
) : null,
|
||||
body: screens[_currentIndex],
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.white,
|
||||
selectedItemColor: Colors.blue,
|
||||
unselectedItemColor: Colors.grey,
|
||||
currentIndex: _currentIndex,
|
||||
onTap: (index) {
|
||||
if (index == 2) {
|
||||
// Navigate to chart screen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChartScreen()),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
}
|
||||
},
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
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;
|
||||
ApiService._internal();
|
||||
|
||||
// Base URL Railway
|
||||
static const String baseUrl = 'https://web-production-47eb.up.railway.app';
|
||||
// Base URL Railway (Updated account)
|
||||
static const String baseUrl = 'https://backendaplikasinovil-production.up.railway.app';
|
||||
|
||||
// Timeout
|
||||
static const Duration timeout = Duration(seconds: 10);
|
||||
|
|
|
|||
|
|
@ -115,38 +115,23 @@ class NotificationManager extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
// Notification helpers for specific events
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// Notification helper untuk pengeringan selesai
|
||||
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(
|
||||
title: 'Pengeringan Selesai',
|
||||
message: 'Target tercapai! Berat akhir: ${beratAkhir.toStringAsFixed(0)} gram',
|
||||
type: NotificationType.success,
|
||||
showPush: true,
|
||||
);
|
||||
|
||||
print('✅ notifyPengeringanSelesai completed');
|
||||
}
|
||||
|
||||
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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.1.0+2
|
||||
version: 1.2.0+3
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.1 <4.0.0'
|
||||
|
|
@ -117,13 +117,25 @@ flutter:
|
|||
# Flutter Launcher Icons
|
||||
# Generates app icons for Android and iOS
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
android: "launcher_icon"
|
||||
ios: true
|
||||
image_path: "assets/images/app_icon.png"
|
||||
# Adaptive icon for Android 8.0+
|
||||
adaptive_icon_background: "#1976D2" # Blue color matching splash screen
|
||||
min_sdk_android: 21
|
||||
|
||||
# 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"
|
||||
|
||||
# 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
|
||||
# DISABLED - Menggunakan custom splash screen saja
|
||||
# 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",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"background_color": "#2196F3",
|
||||
"theme_color": "#2196F3",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
|
|
|
|||