diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/APK_MONITORING_FINAL.md b/APK_MONITORING_FINAL.md deleted file mode 100644 index 147d2e2..0000000 --- a/APK_MONITORING_FINAL.md +++ /dev/null @@ -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!** diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7b4c627..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -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 diff --git a/CHANGELOG_NOTIFIKASI.md b/CHANGELOG_NOTIFIKASI.md new file mode 100644 index 0000000..06a4b6f --- /dev/null +++ b/CHANGELOG_NOTIFIKASI.md @@ -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 diff --git a/DOKUMENTASI.md b/DOKUMENTASI.md deleted file mode 100644 index 038bf60..0000000 --- a/DOKUMENTASI.md +++ /dev/null @@ -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 diff --git a/GRAFIK_CHART_GUIDE.md b/GRAFIK_CHART_GUIDE.md new file mode 100644 index 0000000..f4830b5 --- /dev/null +++ b/GRAFIK_CHART_GUIDE.md @@ -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` +3. Convert ke `List` 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 + โ†“ + 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! ๐ŸŽ‰** diff --git a/NOTIFICATION_FIX_README.md b/NOTIFICATION_FIX_README.md new file mode 100644 index 0000000..142c6b2 --- /dev/null +++ b/NOTIFICATION_FIX_README.md @@ -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 diff --git a/PERBAIKAN_APK_FINAL.md b/PERBAIKAN_APK_FINAL.md deleted file mode 100644 index 3dbe280..0000000 --- a/PERBAIKAN_APK_FINAL.md +++ /dev/null @@ -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!** diff --git a/PERBAIKAN_STATUS_MQTT.md b/PERBAIKAN_STATUS_MQTT.md deleted file mode 100644 index 2e89fad..0000000 --- a/PERBAIKAN_STATUS_MQTT.md +++ /dev/null @@ -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!** diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 24f5c95..0912867 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" + ) } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..d8ac655 --- /dev/null +++ b/android/app/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 { + ; +} +-keepclassmembers class kotlin.Metadata { + public ; +} + +# 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 ; +} + +# 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 ; +} +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; +} +-keepclasseswithmembers class * { + @androidx.annotation.Keep (...); +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b06d57b..dc34d55 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,10 +3,22 @@ + + + + + + + + + + + + + android:icon="@mipmap/launcher_icon"> + android:windowSoftInputMode="adjustResize" + android:showWhenLocked="true" + android:turnScreenOn="true"> + + + + + + + + + + = 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) + } + } +} + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml new file mode 100644 index 0000000..5f349f7 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..116f99e Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..c2224dc Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..33e846e Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..c104023 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..d738d10 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 2ff3651..0d40556 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,4 @@ - #1976D2 + #2196F3 \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..0fc51b8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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("android") { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + } + } } subprojects { project.evaluationDependsOn(":app") diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..7942ce8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 diff --git a/backend/CARA_PUSH.md b/backend/CARA_PUSH.md new file mode 100644 index 0000000..80bed03 --- /dev/null +++ b/backend/CARA_PUSH.md @@ -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 +``` diff --git a/backend/push.ps1 b/backend/push.ps1 new file mode 100644 index 0000000..70a5adb --- /dev/null +++ b/backend/push.ps1 @@ -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 +} diff --git a/backend/server.js b/backend/server.js index 895f2e5..00b2101 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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(() => { diff --git a/create_adaptive_icon.html b/create_adaptive_icon.html new file mode 100644 index 0000000..cbfcb32 --- /dev/null +++ b/create_adaptive_icon.html @@ -0,0 +1,185 @@ + + + + + + Android Adaptive Icon Generator + + + +
+

๐ŸŽจ Android Adaptive Icon Generator

+ +
+ ๐Ÿ“ฑ Android Adaptive Icon Safe Zone:
+ Your icon will be displayed in different shapes (circle, square, squircle).
+ Important content must stay within the red circle (66% area). +
+ + + +
+
+

Original

+ +
+
+

Adaptive Icon (66% size)

+
+ +
+
+
+
+ + + +
+ Next steps after download:
+ 1. Replace assets/images/app_icon.png with downloaded file
+ 2. Run: flutter pub run flutter_launcher_icons
+ 3. Build: flutter build apk --release +
+
+ + + + diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -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: diff --git a/esp32_pengering_ikan_railway.ino b/esp32_pengering_ikan_railway.ino index 16204b1..03e036a 100644 --- a/esp32_pengering_ikan_railway.ino +++ b/esp32_pengering_ikan_railway.ino @@ -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 - - // 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; + // 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 + + // 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 + + 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("=================================="); + + client.publish(topicStatus, "PENGERINGAN SELESAI"); + + // Kirim data dengan status COMPLETED untuk trigger notifikasi + kirimStatusRelay(suhu, berat); + + // Tampilkan selesai selama 5 detik + lcd.clear(); + lcd.setCursor(0, 0); + lcd.print("PENGERINGAN"); + lcd.setCursor(0, 1); + lcd.print("SELESAI"); + delay(5000); + + // Exhaust jalan 20 detik untuk buang panas + lcd.clear(); + lcd.setCursor(0, 0); + lcd.print("Cooling down..."); + lcd.setCursor(0, 1); + lcd.print("20 seconds"); + + // Countdown 20 detik + for (int i = 20; i > 0; i--) { + lcd.setCursor(0, 1); + lcd.print(" "); + lcd.setCursor(0, 1); + lcd.print(String(i) + " seconds left"); + delay(1000); + + // Tetap kirim MQTT status relay saat cooling + kirimStatusRelay(suhu, berat); + client.loop(); + } + + // Matikan exhaust dan kembali ke ready + digitalWrite(RELAY_EXHAUST, HIGH); // EXHAUST OFF + + // Reset variabel + pengeringanSelesai = false; + beratTersimpan = false; + berat_awal = 0; + berat_trigger = 0; + waktuMulaiBerat = 0; + + lcd.clear(); + tampilReady(); + client.publish(topicStatus, "PENGERINGAN SIAP"); + + // Kirim status relay setelah reset + kirimStatusRelay(suhu, berat); + + Serial.println("Sistem kembali ke READY"); + + delay(500); + return; + } + } else { + // Berat naik lagi atau di atas target, reset flag verifikasi + static bool targetReachedFlag = false; + targetReachedFlag = false; } // ===================================================== @@ -895,24 +976,47 @@ void loop() { // Relay HANYA ON setelah berat_awal & target tersimpan // ===================================================== 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 diff --git a/fix_icon.py b/fix_icon.py new file mode 100644 index 0000000..67d412e --- /dev/null +++ b/fix_icon.py @@ -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.") diff --git a/flutter_02.png b/flutter_02.png new file mode 100644 index 0000000..3eea1ad Binary files /dev/null and b/flutter_02.png differ diff --git a/flutter_command.txt b/flutter_command.txt new file mode 100644 index 0000000..4286f42 --- /dev/null +++ b/flutter_command.txt @@ -0,0 +1 @@ +r diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 6ea8b85..0227548 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 1839710..3e870cb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 742e296..798f171 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 4973cb0..77b4a37 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index e5eb90f..52c7908 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index f8347e3..44a321d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 0747683..dacbc07 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 742e296..798f171 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 098599e..aaa01a8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index edc3506..afac9e1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 1a8f8e7..41ad28d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 4838a3e..4df6ddb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index a6601a9..ef63720 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index c12d6cc..853ac2e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index edc3506..afac9e1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 595597f..84c10c3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 116f99e..07c8fb9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index c104023..9a822db 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index e5d0101..e552fb2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index bdfce70..cd56d53 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index d99c1a0..5fa214f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/main.dart b/lib/main.dart index dd74e3d..154640c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,15 @@ import 'package:flutter/material.dart'; import 'screens/splash_screen.dart'; +import 'services/notification_service.dart'; -void main() { +void main() async { + // Pastikan Flutter binding sudah diinisialisasi + WidgetsFlutterBinding.ensureInitialized(); + + // Inisialisasi notification service + final notificationService = NotificationService(); + await notificationService.initialize(); + runApp(const MyApp()); } diff --git a/lib/screens/chart_screen.dart b/lib/screens/chart_screen.dart new file mode 100644 index 0000000..3a137b3 --- /dev/null +++ b/lib/screens/chart_screen.dart @@ -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 createState() => _ChartScreenState(); +} + +class _ChartScreenState extends State { + final ApiService _apiService = ApiService(); + List historyData = []; + bool isLoading = true; + String selectedPeriod = '50'; // 50, 100, 200 + + @override + void initState() { + super.initState(); + _loadHistoryData(); + } + + Future _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 _getSuhuSpots() { + return historyData.asMap().entries.map((entry) { + return FlSpot(entry.key.toDouble(), entry.value.suhu); + }).toList(); + } + + List _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( + 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 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart deleted file mode 100644 index b57ae93..0000000 --- a/lib/screens/dashboard_screen.dart +++ /dev/null @@ -1,1636 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mqtt_client/mqtt_client.dart'; -import 'package:fl_chart/fl_chart.dart'; -import 'package:intl/intl.dart'; -import '../services/mqtt_service.dart'; -import '../models/sensor_data.dart'; -import '../services/notification_manager.dart'; -import 'notification_screen.dart'; - -class DashboardScreen extends StatefulWidget { - const DashboardScreen({super.key}); - - @override - DashboardScreenState createState() => DashboardScreenState(); -} - -class DashboardScreenState extends State { - final MqttService _mqttService = MqttService(); - final NotificationManager _notificationManager = NotificationManager(); - - // Data dari sensor - double suhu = 35.2; - double berat = 420.0; - double target = 280.0; - - // Data historis untuk grafik - List suhuHistory = []; - List beratHistory = []; - double chartTimeCounter = 0; - - // Status relay - bool heater = false; - bool fanIn = false; - bool lamp = false; - bool exhaust = false; - - // Status sistem - String statusSistem = 'DISCONNECTED'; - bool isConnected = false; - String lastStatusMessage = 'Tekan tombol untuk memulai pengeringan'; - - // Waktu mulai dan durasi - DateTime? waktuMulai; - Duration durasi = Duration.zero; - - // Current navigation index - int _currentIndex = 0; - - // Status pengeringan - bool modePengeringan = false; - bool pengeringanSelesai = false; - bool beratTersimpan = false; - - // Track previous status untuk notifikasi - String _previousStatus = ''; - bool _previousBeratTersimpan = false; - - @override - void initState() { - super.initState(); - waktuMulai = DateTime.now(); // Inisialisasi waktu mulai - _initializeChartData(); - _initializeNotifications(); - _connectMQTT(); - _startDurationTimer(); - } - - void _initializeNotifications() async { - await _notificationManager.initialize(); - } - - void _initializeChartData() { - // Initialize dengan data dummy untuk demo - for (int i = 0; i < 10; i++) { - suhuHistory.add(FlSpot(i.toDouble(), 35.0 + (i % 3) * 0.5)); - beratHistory.add(FlSpot(i.toDouble(), 450.0 - i * 3.0)); - } - chartTimeCounter = 10; - } - - void _startDurationTimer() { - if (!mounted) return; - - Future.delayed(const Duration(seconds: 1), () { - if (!mounted) return; - - // Update durasi jika ada waktu mulai - if (waktuMulai != null) { - if (mounted) { - setState(() { - durasi = DateTime.now().difference(waktuMulai!); - }); - } - } - - // Lanjutkan timer - _startDurationTimer(); - }); - } - - // Connect ke MQTT broker - Future _connectMQTT() async { - if (!mounted) return; - - setState(() { - statusSistem = 'CONNECTING'; - }); - - try { - // Tambahkan timeout untuk mencegah stuck - final connected = await _mqttService.connectWithRetry() - .timeout( - const Duration(seconds: 10), - onTimeout: () { - print('โฑ๏ธ MQTT connection timeout'); - return false; - }, - ); - - if (!mounted) return; - - if (connected) { - setState(() { - isConnected = true; - // Jangan set status BERJALAN otomatis, tunggu data dari ESP32 - if (statusSistem == 'DISCONNECTED' || statusSistem == 'CONNECTING') { - statusSistem = 'READY'; - lastStatusMessage = 'Tekan tombol untuk memulai pengeringan'; - } - }); - - // Listen untuk data dari ESP32 - _mqttService.dataStream.listen((data) { - if (mounted) { - setState(() { - try { - final sensorData = SensorData.fromJson(data); - suhu = sensorData.suhu; - berat = sensorData.berat; - target = sensorData.target; - heater = sensorData.relay1; - fanIn = sensorData.relay2; - lamp = sensorData.relay3; - exhaust = sensorData.relay4; - - // Update status sistem berdasarkan data dari ESP32 - String newStatus = sensorData.status; - - if (newStatus == 'START') { - statusSistem = 'READY'; - modePengeringan = true; - pengeringanSelesai = false; - beratTersimpan = false; - lastStatusMessage = 'Menunggu deteksi ikan...'; - if (waktuMulai == null) { - waktuMulai = DateTime.now(); - } - - // Notifikasi pengeringan dimulai - if (_previousStatus != 'START') { - _notificationManager.notifyPengeringanDimulai(); - } - } else if (newStatus == 'BERJALAN') { - statusSistem = 'BERJALAN'; - modePengeringan = true; - pengeringanSelesai = false; - if (!beratTersimpan && target > 0) { - beratTersimpan = true; - lastStatusMessage = 'Proses pengeringan sedang berlangsung'; - - // Notifikasi ikan terdeteksi (hanya sekali) - if (!_previousBeratTersimpan) { - _notificationManager.notifyIkanTerdeteksi(berat); - } - } else if (!beratTersimpan) { - lastStatusMessage = 'Menunggu deteksi ikan...'; - } else { - lastStatusMessage = 'Proses pengeringan sedang berlangsung'; - } - } else if (newStatus == 'SELESAI') { - statusSistem = 'SELESAI'; - pengeringanSelesai = true; - lastStatusMessage = 'Pengeringan selesai, tekan tombol untuk reset'; - - // Notifikasi pengeringan selesai - if (_previousStatus != 'SELESAI') { - _notificationManager.notifyPengeringanSelesai(berat); - } - } else { - statusSistem = newStatus; - } - - // Update previous status - _previousStatus = newStatus; - _previousBeratTersimpan = beratTersimpan; - - // 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++; - - print('๐Ÿ“Š Data updated: $sensorData'); - } catch (e) { - print('โŒ Error parsing sensor data: $e'); - } - }); - } - }); - - // Listen untuk status messages - _mqttService.statusStream.listen((status) { - if (mounted) { - setState(() { - lastStatusMessage = status; - }); - } - }); - - // Listen untuk connection status - _mqttService.connectionStream.listen((state) { - if (mounted) { - setState(() { - isConnected = state == MqttConnectionState.connected; - }); - } - }); - } else { - if (mounted) { - setState(() { - isConnected = false; - statusSistem = 'DISCONNECTED'; - lastStatusMessage = 'Gagal terhubung ke server'; - }); - } - } - } catch (e) { - print('โŒ Error in _connectMQTT: $e'); - if (mounted) { - setState(() { - isConnected = false; - statusSistem = 'DISCONNECTED'; - lastStatusMessage = 'Error: ${e.toString()}'; - }); - } - } - } - - @override - void dispose() { - super.dispose(); - } - - String _formatDuration(Duration duration) { - String twoDigits(int n) => n.toString().padLeft(2, '0'); - String hours = twoDigits(duration.inHours); - String minutes = twoDigits(duration.inMinutes.remainder(60)); - String seconds = twoDigits(duration.inSeconds.remainder(60)); - return "$hours:$minutes:$seconds"; - } - - Widget _buildStatusCard() { - // Tentukan warna dan icon berdasarkan status - Color statusColor; - IconData statusIcon; - - if (statusSistem == 'SELESAI') { - statusColor = Colors.green; - statusIcon = Icons.check_circle; - } else if (statusSistem == 'BERJALAN') { - statusColor = Colors.blue; - statusIcon = Icons.play_circle; - } else if (statusSistem == 'READY') { - statusColor = Colors.orange; - statusIcon = Icons.hourglass_empty; - } else { - statusColor = Colors.grey; - statusIcon = Icons.info; - } - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Row( - children: [ - // Icon status - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon( - statusIcon, - color: statusColor, - size: 24, - ), - ), - const SizedBox(width: 10), - // Status text - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Status Pengeringan', - style: TextStyle( - fontSize: 11, - color: Colors.grey, - ), - ), - const SizedBox(height: 2), - Text( - statusSistem, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: statusColor, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - Container( - width: 5, - height: 5, - decoration: BoxDecoration( - color: statusColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 5), - Expanded( - child: Text( - lastStatusMessage, - style: const TextStyle( - fontSize: 9, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ), - ], - ), - const SizedBox(height: 10), - // Waktu dan durasi dalam row terpisah - Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Waktu Mulai', - style: TextStyle( - fontSize: 9, - color: Colors.grey, - ), - ), - const SizedBox(height: 3), - Text( - waktuMulai != null - ? DateFormat('HH:mm:ss').format(waktuMulai!) - : '--:--:--', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Durasi', - style: TextStyle( - fontSize: 9, - color: Colors.grey, - ), - ), - const SizedBox(height: 3), - Row( - children: [ - Icon(Icons.timer, size: 13, color: statusColor), - const SizedBox(width: 3), - Expanded( - child: Text( - _formatDuration(durasi), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: statusColor, - ), - maxLines: 1, - ), - ), - ], - ), - ], - ), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildMetricCard({ - required IconData icon, - required Color iconColor, - required String label, - required String value, - required String unit, - required String status, - required List chartData, - required Color chartColor, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Icon(icon, color: iconColor, size: 16), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: Text( - value, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 3), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - unit, - style: const TextStyle( - fontSize: 11, - color: Colors.grey, - ), - ), - ), - ], - ), - const SizedBox(height: 2), - Text( - status, - style: const TextStyle( - fontSize: 9, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - } - - Widget _buildProgressCard() { - // Hitung progress: dari berat awal ke target - // Progress = (berat awal - berat sekarang) / (berat awal - target) * 100 - // Asumsi: berat awal = berat sekarang + (berat sekarang - target) jika berat > target - double progress = 0; - if (target > 0 && berat > target) { - // Berat masih di atas target, hitung progress - double beratAwal = berat + (berat - target); // estimasi berat awal - progress = ((beratAwal - berat) / (beratAwal - target) * 100).clamp(0, 100); - } else if (target > 0 && berat <= target) { - // Sudah mencapai atau melewati target - progress = 100; - } - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Progres', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - const SizedBox(height: 10), - Center( - child: SizedBox( - width: 75, - height: 75, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: 75, - height: 75, - child: CircularProgressIndicator( - value: progress / 100, - strokeWidth: 6, - backgroundColor: Colors.grey.shade200, - valueColor: const AlwaysStoppedAnimation(Colors.green), - ), - ), - Text( - '${progress.toInt()}%', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - const Center( - child: Text( - 'Menuju Target', - style: TextStyle( - fontSize: 9, - color: Colors.grey, - ), - ), - ), - ], - ), - ); - } - - Widget _buildProgressBar() { - double sisaBerat = berat - target; - if (sisaBerat < 0) sisaBerat = 0; - - // Hitung progress: semakin mendekati target, semakin tinggi progress - double progressValue = 0; - if (target > 0 && berat > target) { - // Asumsi berat awal adalah 2x target atau berat saat ini + sisa - double beratAwal = berat + sisaBerat; - progressValue = ((beratAwal - berat) / (beratAwal - target)).clamp(0.0, 1.0); - } else if (target > 0 && berat <= target) { - progressValue = 1.0; // Sudah mencapai target - } - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - 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(6), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.water_drop, color: Colors.blue, size: 16), - ), - const SizedBox(width: 6), - const Text( - 'Progress Pengeringan', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - 'Sisa ${sisaBerat.toInt()} gram menuju target', - style: const TextStyle( - fontSize: 10, - color: Colors.grey, - ), - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: progressValue, - minHeight: 10, - backgroundColor: Colors.grey.shade200, - valueColor: const AlwaysStoppedAnimation(Colors.green), - ), - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - '${berat.toInt()} g', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), - const SizedBox(width: 3), - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(3), - ), - child: const Icon(Icons.flag, size: 8, color: Colors.grey), - ), - ], - ), - const Text( - 'Berat Saat Ini', - style: TextStyle( - fontSize: 8, - color: Colors.grey, - ), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${target.toInt()} g', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), - const Text( - 'Target Berat', - style: TextStyle( - fontSize: 8, - color: Colors.grey, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildDeviceCard({ - required IconData icon, - required Color iconColor, - required String label, - required String description, - required bool status, - }) { - 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( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: iconColor, size: 24), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 18), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'info', - child: Text('Info'), - ), - ], - ), - ], - ), - const SizedBox(height: 10), - Text( - label, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - description, - style: const TextStyle( - fontSize: 10, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: status ? iconColor.withOpacity(0.1) : Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - status ? 'ON' : 'OFF', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: status ? iconColor : Colors.grey, - ), - ), - ), - const SizedBox(height: 8), - // Toggle switch - Transform.scale( - scale: 0.75, - child: Switch( - value: status, - onChanged: null, // Disabled karena mode monitoring - activeColor: iconColor, - ), - ), - ], - ), - ); - } - - Widget _buildCombinedHeaterFanCard() { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Flexible( - child: Text( - 'PTC Heater & Fan In', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 16), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'info', - child: Text('Info'), - ), - ], - ), - ], - ), - const SizedBox(height: 10), - - // PTC Heater - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.deepOrange.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.whatshot, color: Colors.deepOrange, size: 18), - ), - const SizedBox(width: 8), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'PTC Heater', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - 'Pemanas', - style: TextStyle( - fontSize: 8, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: heater ? Colors.deepOrange.withOpacity(0.1) : Colors.grey.shade100, - borderRadius: BorderRadius.circular(5), - ), - child: Text( - heater ? 'ON' : 'OFF', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: heater ? Colors.deepOrange : Colors.grey, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - - // Divider - Divider(color: Colors.grey.shade200, height: 1), - const SizedBox(height: 10), - - // Fan In - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.air, color: Colors.blue, size: 18), - ), - const SizedBox(width: 8), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Fan In', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - 'Kipas Masuk', - style: TextStyle( - fontSize: 8, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: fanIn ? Colors.blue.withOpacity(0.1) : Colors.grey.shade100, - borderRadius: BorderRadius.circular(5), - ), - child: Text( - fanIn ? 'ON' : 'OFF', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: fanIn ? Colors.blue : Colors.grey, - ), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildExhaustCard() { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Flexible( - child: Text( - 'Exhaust Fan', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 16), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'info', - child: Text('Info'), - ), - ], - ), - ], - ), - const SizedBox(height: 10), - - // Exhaust - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.teal.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.wind_power, color: Colors.teal, size: 18), - ), - const SizedBox(width: 8), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Exhaust', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - 'Kipas Keluar', - style: TextStyle( - fontSize: 8, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: exhaust ? Colors.teal.withOpacity(0.1) : Colors.grey.shade100, - borderRadius: BorderRadius.circular(5), - ), - child: Text( - exhaust ? 'ON' : 'OFF', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: exhaust ? Colors.teal : Colors.grey, - ), - ), - ), - ], - ), - ], - ), - ); - } - - - Widget _buildRealtimeChart() { - 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.purple.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.show_chart, color: Colors.purple, size: 20), - ), - const SizedBox(width: 8), - const Text( - 'Grafik Real-time', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 20), - // Suhu Chart - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - const Text( - 'Grafik Suhu (ยฐC)', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 12), - SizedBox( - height: 120, - child: LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: 5, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.shade200, - strokeWidth: 1, - ); - }, - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 35, - interval: 5, - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: const TextStyle(fontSize: 10, color: Colors.grey), - ); - }, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 25, - interval: 2, - getTitlesWidget: (value, meta) { - int minutes = (value.toInt() * 6) % 60; - int hours = 10 + ((value.toInt() * 6) ~/ 60); - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 9, color: Colors.grey), - ), - ); - }, - ), - ), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - minY: 20, - maxY: 45, - lineBarsData: [ - LineChartBarData( - spots: suhuHistory, - isCurved: true, - color: Colors.red, - barWidth: 2.5, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, barData, index) { - if (index == suhuHistory.length - 1) { - return FlDotCirclePainter( - radius: 5, - color: Colors.white, - strokeWidth: 2.5, - strokeColor: Colors.red, - ); - } - return FlDotCirclePainter( - radius: 0, - color: Colors.transparent, - ); - }, - ), - belowBarData: BarAreaData( - show: true, - color: Colors.red.withOpacity(0.1), - ), - ), - ], - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - return LineTooltipItem( - '${spot.y.toStringAsFixed(1)}ยฐC', - const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ); - }).toList(); - }, - ), - ), - ), - ), - ), - const SizedBox(height: 24), - // Berat Chart - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - const Text( - 'Grafik Berat (gram)', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 12), - SizedBox( - height: 120, - child: LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: 50, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.shade200, - strokeWidth: 1, - ); - }, - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 35, - interval: 100, - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: const TextStyle(fontSize: 10, color: Colors.grey), - ); - }, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 25, - interval: 2, - getTitlesWidget: (value, meta) { - int minutes = (value.toInt() * 6) % 60; - int hours = 10 + ((value.toInt() * 6) ~/ 60); - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 9, color: Colors.grey), - ), - ); - }, - ), - ), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - minY: 0, - maxY: 600, - lineBarsData: [ - LineChartBarData( - spots: beratHistory, - isCurved: true, - color: Colors.blue, - barWidth: 2.5, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, barData, index) { - if (index == beratHistory.length - 1) { - return FlDotCirclePainter( - radius: 5, - color: Colors.white, - strokeWidth: 2.5, - strokeColor: Colors.blue, - ); - } - return FlDotCirclePainter( - radius: 0, - color: Colors.transparent, - ); - }, - ), - belowBarData: BarAreaData( - show: true, - color: Colors.blue.withOpacity(0.1), - ), - ), - ], - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - return LineTooltipItem( - '${spot.y.toStringAsFixed(0)}g', - const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ); - }).toList(); - }, - ), - ), - ), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - // List of screens - final List screens = [ - _buildDashboardContent(), - const NotificationScreen(), - ]; - - return Scaffold( - backgroundColor: Colors.grey.shade50, - appBar: _currentIndex == 0 ? AppBar( - backgroundColor: Colors.white, - elevation: 0, - title: const Text( - 'Dashboard', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 16), - child: Row( - children: [ - Icon( - Icons.wifi, - color: isConnected ? Colors.green : Colors.grey, - size: 18, - ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: isConnected ? Colors.green : Colors.grey, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - isConnected ? 'ONLINE' : 'OFFLINE', - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(right: 12), - child: Center( - child: Text( - DateFormat('HH:mm:ss').format(DateTime.now()), - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ) : null, - body: screens[_currentIndex], - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - selectedItemColor: Colors.blue, - unselectedItemColor: Colors.grey, - currentIndex: _currentIndex, - onTap: (index) { - setState(() { - _currentIndex = index; - }); - }, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.notifications), - label: 'Notifikasi', - ), - ], - ), - ); - } - - Widget _buildDashboardContent() { - return RefreshIndicator( - onRefresh: () async { - try { - await _connectMQTT(); - } catch (e) { - print('โŒ Error during refresh: $e'); - // Tetap selesaikan refresh meskipun error - } - }, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status Card - _buildStatusCard(), - const SizedBox(height: 12), - - // Metrics Row - Row( - children: [ - Expanded( - child: _buildMetricCard( - icon: Icons.thermostat, - iconColor: Colors.red, - label: 'Suhu', - value: suhu.toStringAsFixed(1), - unit: 'ยฐC', - status: 'Normal', - chartData: suhuHistory, - chartColor: Colors.red, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _buildMetricCard( - icon: Icons.scale, - iconColor: Colors.blue, - label: 'Berat Ikan', - value: berat.toStringAsFixed(0), - unit: 'gram', - status: 'Berat Saat Ini', - chartData: beratHistory, - chartColor: Colors.blue, - ), - ), - ], - ), - const SizedBox(height: 10), - - // Target and Progress Row - Row( - children: [ - Expanded( - child: _buildMetricCard( - icon: Icons.flag, - iconColor: Colors.purple, - label: 'Target Berat', - value: target.toStringAsFixed(0), - unit: 'gram', - status: 'Berat Target', - chartData: const [], - chartColor: Colors.purple, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _buildProgressCard(), - ), - ], - ), - const SizedBox(height: 12), - - // Progress Bar - _buildProgressBar(), - const SizedBox(height: 12), - - // Device Cards Grid - Row( - children: [ - Expanded( - child: _buildCombinedHeaterFanCard(), - ), - const SizedBox(width: 10), - Expanded( - child: _buildExhaustCard(), - ), - ], - ), - const SizedBox(height: 12), - ], - ), - ), - ); - } -} diff --git a/lib/screens/dashboard_screen_with_api.dart b/lib/screens/dashboard_screen_with_api.dart index eb2a23f..2ed38fb 100644 --- a/lib/screens/dashboard_screen_with_api.dart +++ b/lib/screens/dashboard_screen_with_api.dart @@ -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 { double berat = 0.0; double target = 0.0; + // Data historis untuk grafik + List suhuHistory = []; + List beratHistory = []; + double chartTimeCounter = 0; + // Status relay bool heater = false; bool fanIn = false; @@ -38,10 +48,14 @@ class _DashboardScreenWithApiState extends State { // 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 { await _notificationManager.initialize(); } + // Initialize chart data + Future _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 { 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 { @override Widget build(BuildContext context) { + // List of screens + final List 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 { ), ), ], - ), - 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, + ), + ], + ), + ], ), ), ); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 9ef5895..af5dd95 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -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); diff --git a/lib/services/notification_manager.dart b/lib/services/notification_manager.dart index fd64f6b..e649290 100644 --- a/lib/services/notification_manager.dart +++ b/lib/services/notification_manager.dart @@ -115,38 +115,23 @@ class NotificationManager extends ChangeNotifier { } } - // Notification helpers for specific events - Future notifyPengeringanDimulai() async { - await addNotification( - title: 'Pengeringan Dimulai', - message: 'Proses pengeringan ikan telah dimulai', - type: NotificationType.info, - ); - } - - Future notifyIkanTerdeteksi(double berat) async { - await addNotification( - title: 'Ikan Terdeteksi', - message: 'Berat ikan: ${berat.toStringAsFixed(0)} gram', - type: NotificationType.info, - ); - } - - Future 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 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 notifySuhuTinggi(double suhu) async { diff --git a/lib/widgets/temperature_chart.dart b/lib/widgets/temperature_chart.dart new file mode 100644 index 0000000..8a4d525 --- /dev/null +++ b/lib/widgets/temperature_chart.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +class TemperatureChart extends StatelessWidget { + final List 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 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, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/weight_chart.dart b/lib/widgets/weight_chart.dart new file mode 100644 index 0000000..f96dcf6 --- /dev/null +++ b/lib/widgets/weight_chart.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +class WeightChart extends StatelessWidget { + final List 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 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 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, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..9e834cf --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,8 @@ +[phases.setup] +nixPkgs = ['nodejs-18_x'] + +[phases.build] +cmds = ['cd backend && npm install'] + +[start] +cmd = 'cd backend && node server.js' diff --git a/pubspec.yaml b/pubspec.yaml index 5eacffe..2a77e1a 100644 --- a/pubspec.yaml +++ b/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: diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..1ad5b66 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..d738d10 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..ff8b133 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..d738d10 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..ff8b133 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/manifest.json b/web/manifest.json index a77dcd8..986f4e6 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -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, @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file