first commit

This commit is contained in:
afanfahreza 2026-08-05 15:31:12 +07:00
parent 18114de707
commit 6fc5d105a5
68 changed files with 3028 additions and 2938 deletions

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

View File

@ -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!**

View File

@ -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

127
CHANGELOG_NOTIFIKASI.md Normal file
View File

@ -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

View File

@ -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

244
GRAFIK_CHART_GUIDE.md Normal file
View File

@ -0,0 +1,244 @@
# 📊 Panduan Fitur Grafik Chart - Aplikasi Pengering Ikan
## 🎯 Fitur yang Ditambahkan
Aplikasi sekarang memiliki **sistem grafik chart yang lengkap** untuk monitoring data pengeringan ikan secara visual dan detail.
---
## ✨ Komponen Grafik
### 1. **Dashboard - Grafik Real-time** 📈
Tampilan utama dengan 2 grafik real-time yang menampilkan:
#### **Grafik Suhu**
- Menampilkan perubahan suhu secara real-time
- Warna: Orange/Red gradient
- Range: 20-60°C
- Update setiap detik saat menerima data dari ESP32
- Menampilkan 20 data terakhir dengan scrolling otomatis
#### **Grafik Berat**
- Menampilkan penurunan berat ikan secara real-time
- Warna: Blue/Cyan gradient
- Menampilkan garis target berat (hijau putus-putus)
- Tooltip menampilkan sisa berat menuju target
- Update setiap detik saat menerima data dari ESP32
**Fitur Grafik Dashboard:**
- 🔴 Live indicator - menandakan data real-time
- 📍 Dot marker pada titik terakhir
- 💧 Gradient fill di bawah garis
- 📏 Grid lines untuk kemudahan pembacaan
- 🎯 Target line pada grafik berat
- 👆 Touch tooltip - tap grafik untuk detail
---
### 2. **Chart Screen - History Detail** 📊
Layar khusus untuk melihat data history dari database dengan detail lengkap.
#### **Cara Akses:**
Klik tab **"Grafik"** di bottom navigation (icon bar_chart)
#### **Grafik yang Tersedia:**
**A. Grafik Suhu History**
- Menampilkan history suhu dari database
- Timeline berupa index data (#1, #2, dst)
- Tooltip menampilkan suhu + timestamp
- Smooth curve line
**B. Grafik Berat History**
- Menampilkan history berat dari database
- Garis target berat horizontal
- Tooltip menampilkan berat, target, dan timestamp
- Color coding untuk titik di atas/bawah target
**C. Statistik Card**
Menampilkan analisis data:
- 📊 Rata-rata Suhu
- ⚖️ Rata-rata Berat
- 🔺 Max Suhu
- 🔻 Min Suhu
- 🔺 Max Berat
- 🔻 Min Berat
#### **Filter Data:**
Tekan icon filter (⋮) di kanan atas untuk memilih:
- 50 Data Terakhir
- 100 Data Terakhir
- 200 Data Terakhir
#### **Refresh Data:**
- Pull to refresh (tarik ke bawah)
- Atau tekan icon refresh di info card
---
## 🎨 Visual Features
### **Color Scheme:**
- **Suhu:** Orange → Red (panas)
- **Berat:** Blue → Cyan (sejuk)
- **Target:** Green (tujuan)
- **Live:** Orange dot (status real-time)
### **Gradient Effects:**
- Semua grafik menggunakan gradient fill
- Memberikan efek depth dan modern
- Smooth transitions
### **Interactive Elements:**
- Touch tooltip - tap grafik untuk detail
- Smooth animations
- Responsive layout
---
## 🔧 Cara Kerja
### **Dashboard Real-time Chart:**
1. Data diterima dari ESP32 via MQTT setiap 1 detik
2. Data disimpan ke list `suhuHistory` dan `beratHistory`
3. List maksimal 20 data (auto scroll/shift)
4. Widget `TemperatureChart` dan `WeightChart` render data
5. Chart update otomatis setiap ada data baru
### **Chart Screen History:**
1. Load data dari API endpoint `/api/data/history`
2. Parse data menjadi `List<SensorData>`
3. Convert ke `List<FlSpot>` untuk grafik
4. Hitung statistik (avg, min, max)
5. Render dengan `fl_chart` LineChart
---
## 📱 User Experience
### **Dashboard:**
- Scroll ke bawah untuk melihat grafik
- Grafik selalu update real-time saat pengeringan berjalan
- Compact layout untuk quick glance
### **Chart Screen:**
- Full-screen view untuk analisis detail
- Filter untuk berbagai range data
- Statistik summary untuk insights
- Refresh untuk update data terbaru
---
## 🛠️ Teknologi
### **Package:**
- `fl_chart: ^0.69.0` - Chart library Flutter terbaik
- Smooth animations
- Touch interactions
- Customizable
### **Widget Structure:**
```
lib/
├── screens/
│ ├── dashboard_screen.dart (Main screen dengan real-time charts)
│ └── chart_screen.dart (History charts screen)
└── widgets/
├── temperature_chart.dart (Reusable temperature chart widget)
└── weight_chart.dart (Reusable weight chart widget)
```
---
## 📊 Data Flow
### **Real-time:**
```
ESP32 → MQTT → MqttService → DashboardScreen
suhuHistory/beratHistory
TemperatureChart/WeightChart
```
### **History:**
```
Backend MySQL → API → ApiService → ChartScreen
List<SensorData>
fl_chart LineChart
```
---
## 🎯 Tips Penggunaan
1. **Monitoring Real-time:**
- Lihat dashboard untuk monitoring cepat
- Grafik akan auto-scroll saat data penuh
- Tap grafik untuk detail nilai spesifik
2. **Analisis History:**
- Buka Chart Screen untuk analisis mendalam
- Gunakan filter untuk berbagai rentang waktu
- Perhatikan statistik untuk insights
3. **Performance:**
- Dashboard limit 20 data untuk performa optimal
- Chart Screen bisa load hingga 200 data
- Refresh jika data tidak update
---
## 🚀 Future Improvements
Beberapa ide untuk pengembangan:
1. **Export Data:**
- Export grafik sebagai image
- Export data ke CSV/Excel
2. **Advanced Analytics:**
- Trend analysis
- Prediksi waktu selesai
- Efisiensi pengeringan
3. **Comparison:**
- Bandingkan multiple sessions
- Overlay grafik
4. **Zoom & Pan:**
- Zoom in/out grafik
- Pan untuk navigasi
---
## ✅ Testing Checklist
- [x] Grafik suhu tampil real-time
- [x] Grafik berat tampil real-time
- [x] Target line muncul di grafik berat
- [x] Tooltip berfungsi saat tap
- [x] Chart screen dapat load history
- [x] Filter data berfungsi
- [x] Statistik dihitung dengan benar
- [x] Refresh data berfungsi
- [x] Layout responsive
- [x] No memory leaks
---
## 📞 Support
Jika ada pertanyaan atau bug:
1. Check console log untuk error messages
2. Pastikan backend API running
3. Pastikan MQTT connection aktif
4. Verify data di database
---
**Selamat menggunakan fitur Grafik Chart! 🎉**

161
NOTIFICATION_FIX_README.md Normal file
View File

@ -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

View File

@ -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!**

View File

@ -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!**

View File

@ -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"
)
}
}
}

69
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,69 @@
# Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Flutter Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
-dontwarn com.dexterous.flutterlocalnotifications.**
# AndroidX
-keep class androidx.core.app.NotificationCompat { *; }
-keep class androidx.core.app.NotificationCompat$** { *; }
-keep class androidx.core.app.NotificationManagerCompat { *; }
# Keep notification channels (Android 8.0+)
-keep class android.app.NotificationChannel { *; }
-keep class android.app.NotificationManager { *; }
# MQTT Client
-keep class org.eclipse.paho.** { *; }
-keep class org.eclipse.paho.client.mqttv3.** { *; }
-dontwarn org.eclipse.paho.**
# Kotlin
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
-keepclassmembers class **$WhenMappings {
<fields>;
}
-keepclassmembers class kotlin.Metadata {
public <methods>;
}
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep setters in Views so that animations can still work.
-keepclassmembers public class * extends android.view.View {
void set*(***);
*** get*();
}
# Keep classes with @Keep annotation
-keep @androidx.annotation.Keep class * {*;}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <methods>;
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <fields>;
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <init>(...);
}

View File

@ -3,10 +3,22 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Permission untuk notifikasi Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Permission untuk vibration notifikasi -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Permission untuk receive boot completed (agar app bisa restart service) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Permission untuk wake lock (agar notifikasi tetap jalan di background) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="aplikasi_novil"
android:label="Pengering Ikan"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"
@ -15,7 +27,9 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:showWhenLocked="true"
android:turnScreenOn="true">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@ -29,6 +43,22 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Receiver untuk boot completed -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
</intent-filter>
</receiver>
<!-- Receiver untuk scheduled notifications -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -1,5 +1,32 @@
package com.example.aplikasi_novil
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create notification channel for Android 8.0+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Channel untuk notifikasi pengeringan selesai
val channel = NotificationChannel(
"pengering_ikan_channel",
"Notifikasi Pengering Ikan",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifikasi untuk status pengeringan ikan"
enableVibration(true)
enableLights(true)
}
notificationManager.createNotificationChannel(channel)
}
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1976D2</color>
<color name="ic_launcher_background">#2196F3</color>
</resources>

View File

@ -14,6 +14,18 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
// Force all subprojects to use Java 17
afterEvaluate {
if (project.hasProperty("android")) {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")

View File

@ -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

112
backend/CARA_PUSH.md Normal file
View File

@ -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
```

42
backend/push.ps1 Normal file
View File

@ -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
}

View File

@ -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(() => {

185
create_adaptive_icon.html Normal file
View File

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Android Adaptive Icon Generator</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #1976D2;
text-align: center;
}
.preview {
display: flex;
justify-content: space-around;
margin: 30px 0;
}
.icon-preview {
text-align: center;
}
canvas {
border: 2px solid #ddd;
border-radius: 10px;
margin: 10px;
}
input[type="file"] {
display: block;
margin: 20px auto;
padding: 10px;
}
button {
background: #1976D2;
color: white;
border: none;
padding: 12px 30px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
display: block;
margin: 20px auto;
}
button:hover {
background: #1565C0;
}
.info {
background: #E3F2FD;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.safe-zone {
position: absolute;
border: 3px dashed #FF5722;
border-radius: 50%;
pointer-events: none;
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 Android Adaptive Icon Generator</h1>
<div class="info">
<strong>📱 Android Adaptive Icon Safe Zone:</strong><br>
Your icon will be displayed in different shapes (circle, square, squircle).<br>
Important content must stay within the <span style="color: #FF5722;">red circle</span> (66% area).
</div>
<input type="file" id="imageInput" accept="image/*">
<div class="preview">
<div class="icon-preview">
<h3>Original</h3>
<canvas id="originalCanvas" width="300" height="300"></canvas>
</div>
<div class="icon-preview">
<h3>Adaptive Icon (66% size)</h3>
<div style="position: relative; display: inline-block;">
<canvas id="adaptiveCanvas" width="300" height="300"></canvas>
<div class="safe-zone" style="width: 198px; height: 198px; top: 51px; left: 51px;"></div>
</div>
</div>
</div>
<button onclick="downloadIcon()">⬇️ Download Fixed Icon (1024x1024)</button>
<div class="info">
<strong>Next steps after download:</strong><br>
1. Replace <code>assets/images/app_icon.png</code> with downloaded file<br>
2. Run: <code>flutter pub run flutter_launcher_icons</code><br>
3. Build: <code>flutter build apk --release</code>
</div>
</div>
<script>
const originalCanvas = document.getElementById('originalCanvas');
const adaptiveCanvas = document.getElementById('adaptiveCanvas');
const originalCtx = originalCanvas.getContext('2d');
const adaptiveCtx = adaptiveCanvas.getContext('2d');
let currentImage = null;
document.getElementById('imageInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(event) {
const img = new Image();
img.onload = function() {
currentImage = img;
drawIcons();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
}
});
function drawIcons() {
if (!currentImage) return;
// Clear canvases
originalCtx.clearRect(0, 0, 300, 300);
adaptiveCtx.clearRect(0, 0, 300, 300);
// Draw original
originalCtx.drawImage(currentImage, 0, 0, 300, 300);
// Draw adaptive with background
adaptiveCtx.fillStyle = '#1976D2';
adaptiveCtx.fillRect(0, 0, 300, 300);
// Draw icon at 66% size (centered)
const scaledSize = 300 * 0.66;
const offset = (300 - scaledSize) / 2;
adaptiveCtx.drawImage(currentImage, offset, offset, scaledSize, scaledSize);
}
function downloadIcon() {
if (!currentImage) {
alert('Please load an image first!');
return;
}
// Create high-res canvas for export (1024x1024)
const exportCanvas = document.createElement('canvas');
exportCanvas.width = 1024;
exportCanvas.height = 1024;
const ctx = exportCanvas.getContext('2d');
// Fill with transparent or background
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, 1024, 1024);
// Draw icon at 66% size (safe zone)
const scaledSize = 1024 * 0.66;
const offset = (1024 - scaledSize) / 2;
ctx.drawImage(currentImage, offset, offset, scaledSize, scaledSize);
// Download
exportCanvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'app_icon_fixed.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
}
</script>
</body>
</html>

3
devtools_options.yaml Normal file
View File

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

View File

@ -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

131
fix_icon.py Normal file
View File

@ -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.")

BIN
flutter_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

1
flutter_command.txt Normal file
View File

@ -0,0 +1 @@
r

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 B

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -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());
}

View File

@ -0,0 +1,740 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../services/api_service.dart';
import '../models/sensor_data.dart';
class ChartScreen extends StatefulWidget {
const ChartScreen({super.key});
@override
State<ChartScreen> createState() => _ChartScreenState();
}
class _ChartScreenState extends State<ChartScreen> {
final ApiService _apiService = ApiService();
List<SensorData> historyData = [];
bool isLoading = true;
String selectedPeriod = '50'; // 50, 100, 200
@override
void initState() {
super.initState();
_loadHistoryData();
}
Future<void> _loadHistoryData() async {
setState(() {
isLoading = true;
});
try {
final data = await _apiService.getDataHistory(limit: int.parse(selectedPeriod));
if (mounted) {
setState(() {
historyData = data;
isLoading = false;
});
}
} catch (e) {
print('Error loading history: $e');
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
List<FlSpot> _getSuhuSpots() {
return historyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.suhu);
}).toList();
}
List<FlSpot> _getBeratSpots() {
return historyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.berat);
}).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Grafik History'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.filter_list),
onSelected: (value) {
setState(() {
selectedPeriod = value;
_loadHistoryData();
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: '50', child: Text('50 Data Terakhir')),
const PopupMenuItem(value: '100', child: Text('100 Data Terakhir')),
const PopupMenuItem(value: '200', child: Text('200 Data Terakhir')),
],
),
],
),
body: isLoading
? const Center(child: CircularProgressIndicator())
: historyData.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Belum ada data history',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadHistoryData,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Info card
_buildInfoCard(),
const SizedBox(height: 16),
// Temperature chart
_buildTemperatureChart(),
const SizedBox(height: 16),
// Weight chart
_buildWeightChart(),
],
),
),
);
}
Widget _buildInfoCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade400, Colors.blue.shade600],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.white, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'History Data',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'$selectedPeriod data terakhir dari database',
style: const TextStyle(
fontSize: 12,
color: Colors.white70,
),
),
],
),
),
IconButton(
onPressed: _loadHistoryData,
icon: const Icon(Icons.refresh, color: Colors.white),
),
],
),
);
}
Widget _buildTemperatureChart() {
final suhuSpots = _getSuhuSpots();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.thermostat, color: Colors.orange, size: 24),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Grafik Suhu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 10,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: suhuSpots.length > 20 ? 20 : 10,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < historyData.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'#${value.toInt()}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 10,
reservedSize: 40,
getTitlesWidget: (value, meta) => Text(
'${value.toInt()}°C',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
left: BorderSide(color: Colors.grey.shade300),
),
),
minX: 0,
maxX: suhuSpots.isNotEmpty ? suhuSpots.length.toDouble() - 1 : 10,
minY: 20,
maxY: 60,
lineBarsData: [
LineChartBarData(
spots: suhuSpots,
isCurved: true,
color: Colors.orange,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.orange.withOpacity(0.3),
Colors.orange.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) => Colors.black87,
tooltipRoundedRadius: 8,
getTooltipItems: (spots) {
return spots.map((spot) {
final index = spot.x.toInt();
if (index >= 0 && index < historyData.length) {
final data = historyData[index];
return LineTooltipItem(
'${spot.y.toStringAsFixed(1)}°C\n',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: data.timestamp,
style: const TextStyle(
fontSize: 10,
color: Colors.white70,
),
),
],
);
}
return null;
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _buildWeightChart() {
final beratSpots = _getBeratSpots();
final avgTarget = historyData.isNotEmpty
? historyData.map((e) => e.target).reduce((a, b) => a + b) / historyData.length
: 0.0;
// Filter data yang valid untuk ditampilkan (ganti nilai 0 dengan nilai sebelumnya atau null)
List<FlSpot> displaySpots = [];
double? lastValidValue;
for (var spot in beratSpots) {
if (spot.y > 10) {
// Data valid, gunakan nilai asli
displaySpots.add(spot);
lastValidValue = spot.y;
} else if (lastValidValue != null) {
// Data invalid (0), gunakan nilai terakhir yang valid
displaySpots.add(FlSpot(spot.x, lastValidValue));
}
// Jika lastValidValue null dan spot.y invalid, skip point ini
}
// Jika masih kosong setelah filter, gunakan data asli
if (displaySpots.isEmpty) {
displaySpots = beratSpots;
}
// Hitung minY dan maxY yang aman untuk menghindari overflow
double minY = 0;
double maxY = 200;
if (displaySpots.isNotEmpty) {
double dataMin = displaySpots.map((e) => e.y).reduce((a, b) => a < b ? a : b);
double dataMax = displaySpots.map((e) => e.y).reduce((a, b) => a > b ? a : b);
// Sertakan target dalam perhitungan
if (avgTarget > 0) {
dataMin = dataMin < avgTarget ? dataMin : avgTarget;
dataMax = dataMax > avgTarget ? dataMax : avgTarget;
}
// Tambah padding 25%
double range = dataMax - dataMin;
double padding = range > 0 ? range * 0.25 : 50;
minY = (dataMin - padding).clamp(0, double.infinity);
maxY = dataMax + padding;
// Pastikan range minimal 100
if (maxY - minY < 100) {
double center = (maxY + minY) / 2;
minY = (center - 50).clamp(0, double.infinity);
maxY = center + 50;
}
} else if (avgTarget > 0) {
minY = 0;
maxY = (avgTarget * 1.5).clamp(100, double.infinity);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.scale, color: Colors.blue, size: 24),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Grafik Berat',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 50,
getDrawingHorizontalLine: (value) {
if ((value - avgTarget).abs() < 10) {
return FlLine(
color: Colors.green.shade300,
strokeWidth: 2,
dashArray: [5, 5],
);
}
return FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: beratSpots.length > 20 ? 20 : 10,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < historyData.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'#${value.toInt()}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 45,
getTitlesWidget: (value, meta) {
final isTarget = (value - avgTarget).abs() < 10;
return Text(
'${value.toInt()}g',
style: TextStyle(
fontSize: 10,
color: isTarget ? Colors.green : Colors.grey,
fontWeight: isTarget ? FontWeight.bold : FontWeight.normal,
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
left: BorderSide(color: Colors.grey.shade300),
),
),
minX: 0,
maxX: beratSpots.isNotEmpty ? beratSpots.length.toDouble() - 1 : 10,
minY: minY,
maxY: maxY,
extraLinesData: ExtraLinesData(
horizontalLines: [
if (avgTarget > 0)
HorizontalLine(
y: avgTarget,
color: Colors.green.shade400,
strokeWidth: 2,
dashArray: [8, 4],
label: HorizontalLineLabel(
show: true,
alignment: Alignment.topRight,
style: TextStyle(
color: Colors.green.shade700,
fontSize: 10,
fontWeight: FontWeight.bold,
),
labelResolver: (line) => 'Target',
),
),
],
),
lineBarsData: [
LineChartBarData(
spots: displaySpots,
isCurved: true,
color: Colors.blue,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withOpacity(0.3),
Colors.blue.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) => Colors.black87,
tooltipRoundedRadius: 8,
getTooltipItems: (spots) {
return spots.map((spot) {
final index = spot.x.toInt();
if (index >= 0 && index < historyData.length) {
final data = historyData[index];
return LineTooltipItem(
'${spot.y.toStringAsFixed(0)}g\n',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: 'Target: ${data.target.toStringAsFixed(0)}g\n',
style: const TextStyle(
fontSize: 10,
color: Colors.green,
),
),
TextSpan(
text: data.timestamp,
style: const TextStyle(
fontSize: 10,
color: Colors.white70,
),
),
],
);
}
return null;
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _buildStatisticsCard() {
if (historyData.isEmpty) return const SizedBox.shrink();
final avgSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a + b) / historyData.length;
final maxSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a > b ? a : b);
final minSuhu = historyData.map((e) => e.suhu).reduce((a, b) => a < b ? a : b);
final avgBerat = historyData.map((e) => e.berat).reduce((a, b) => a + b) / historyData.length;
final maxBerat = historyData.map((e) => e.berat).reduce((a, b) => a > b ? a : b);
final minBerat = historyData.map((e) => e.berat).reduce((a, b) => a < b ? a : b);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistik',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.thermostat,
iconColor: Colors.orange,
label: 'Rata-rata Suhu',
value: '${avgSuhu.toStringAsFixed(1)}°C',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.scale,
iconColor: Colors.blue,
label: 'Rata-rata Berat',
value: '${avgBerat.toStringAsFixed(0)}g',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.arrow_upward,
iconColor: Colors.red,
label: 'Max Suhu',
value: '${maxSuhu.toStringAsFixed(1)}°C',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.arrow_downward,
iconColor: Colors.green,
label: 'Min Suhu',
value: '${minSuhu.toStringAsFixed(1)}°C',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.arrow_upward,
iconColor: Colors.red,
label: 'Max Berat',
value: '${maxBerat.toStringAsFixed(0)}g',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.arrow_downward,
iconColor: Colors.green,
label: 'Min Berat',
value: '${minBerat.toStringAsFixed(0)}g',
),
),
],
),
],
),
);
}
Widget _buildStatItem({
required IconData icon,
required Color iconColor,
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(icon, color: iconColor, size: 24),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../services/api_service.dart';
import '../services/notification_manager.dart';
import '../models/sensor_data.dart';
import '../widgets/temperature_chart.dart';
import '../widgets/weight_chart.dart';
import 'chart_screen.dart';
import 'notification_screen.dart';
class DashboardScreenWithApi extends StatefulWidget {
const DashboardScreenWithApi({super.key});
@ -23,6 +28,11 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
double berat = 0.0;
double target = 0.0;
// Data historis untuk grafik
List<FlSpot> suhuHistory = [];
List<FlSpot> beratHistory = [];
double chartTimeCounter = 0;
// Status relay
bool heater = false;
bool fanIn = false;
@ -38,10 +48,14 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
// Timer untuk auto-update
Timer? _pollingTimer;
// Current navigation index
int _currentIndex = 0;
@override
void initState() {
super.initState();
_initializeChartData(); // Load initial chart data
_initializeNotifications();
_initializeConnections();
_startPolling();
@ -52,6 +66,53 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
await _notificationManager.initialize();
}
// Initialize chart data
Future<void> _initializeChartData() async {
// Init dengan dummy data dulu
_initializeDummyChartData();
try {
// Load 20 data terakhir dari API untuk grafik
final historyData = await _apiService.getDataHistory(limit: 20);
if (historyData.isNotEmpty && mounted) {
setState(() {
suhuHistory.clear();
beratHistory.clear();
for (int i = 0; i < historyData.length; i++) {
final data = historyData[i];
suhuHistory.add(FlSpot(i.toDouble(), data.suhu));
beratHistory.add(FlSpot(i.toDouble(), data.berat));
}
chartTimeCounter = historyData.length.toDouble();
});
}
} catch (e) {
print('❌ Error loading chart data: $e');
// Tetap pakai dummy data jika error
}
}
void _initializeDummyChartData() {
suhuHistory.clear();
beratHistory.clear();
double beratAwal = 450.0;
double suhuBase = 28.0;
for (int i = 0; i < 15; i++) {
double suhuVal = suhuBase + (i * 0.3) + (i % 2 == 0 ? 0.2 : -0.1);
suhuHistory.add(FlSpot(i.toDouble(), suhuVal));
double beratVal = beratAwal - (i * 2.5);
beratHistory.add(FlSpot(i.toDouble(), beratVal));
}
chartTimeCounter = 15;
}
// Auto-update data setiap 5 detik
void _startPolling() {
_pollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
@ -98,18 +159,26 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
fanIn = data.relay2;
exhaust = data.relay4;
// Update chart data
if (suhuHistory.length > 20) {
suhuHistory.removeAt(0);
beratHistory.removeAt(0);
// Reindex
for (int i = 0; i < suhuHistory.length; i++) {
suhuHistory[i] = FlSpot(i.toDouble(), suhuHistory[i].y);
beratHistory[i] = FlSpot(i.toDouble(), beratHistory[i].y);
}
chartTimeCounter = suhuHistory.length.toDouble();
}
suhuHistory.add(FlSpot(chartTimeCounter, suhu));
beratHistory.add(FlSpot(chartTimeCounter, berat));
chartTimeCounter++;
// Update status dan trigger notifikasi
String newStatus = data.status;
// Notifikasi hanya untuk pengeringan selesai
if (newStatus != _previousStatus) {
// HAPUS notifikasi untuk status lain
// if (newStatus == 'READY' && _previousStatus != 'READY') {
// _notificationManager.notifyPengeringanDimulai();
// } else if (newStatus == 'BERJALAN' && _previousStatus != 'BERJALAN') {
// _notificationManager.notifyIkanTerdeteksi(berat);
// }
// HANYA notifikasi pengeringan selesai
if (newStatus == 'SELESAI' && _previousStatus != 'SELESAI') {
print('🔔 Dashboard: Status changed to SELESAI');
@ -169,8 +238,14 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
@override
Widget build(BuildContext context) {
// List of screens
final List<Widget> screens = [
_buildDashboardContent(),
const NotificationScreen(),
];
return Scaffold(
appBar: AppBar(
appBar: _currentIndex == 0 ? AppBar(
title: const Text('Dashboard Pengering Ikan'),
actions: [
// API status only
@ -183,95 +258,155 @@ class _DashboardScreenWithApiState extends State<DashboardScreenWithApi> {
),
),
],
),
body: RefreshIndicator(
onRefresh: _loadLatestData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status Card
_buildStatusCard(),
const SizedBox(height: 16),
// Metrics
Row(
children: [
Expanded(
child: _buildMetricCard(
icon: Icons.thermostat,
iconColor: Colors.orange,
label: 'Suhu',
value: suhu.toStringAsFixed(1),
unit: '°C',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
icon: Icons.scale,
iconColor: Colors.blue,
label: 'Berat',
value: berat.toStringAsFixed(0),
unit: 'g',
),
),
],
),
const SizedBox(height: 12),
// Target
_buildMetricCard(
icon: Icons.flag,
iconColor: Colors.green,
label: 'Target Berat',
value: target.toStringAsFixed(0),
unit: 'g',
),
const SizedBox(height: 16),
// Devices
const Text(
'Perangkat',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.9,
children: [
_buildDeviceCard(
icon: Icons.whatshot,
iconColor: Colors.deepOrange,
label: 'Heater',
status: heater,
),
_buildDeviceCard(
icon: Icons.air,
iconColor: Colors.blue,
label: 'Fan',
status: fanIn,
),
_buildDeviceCard(
icon: Icons.wind_power,
iconColor: Colors.teal,
label: 'Exhaust',
status: exhaust,
),
],
),
],
) : null,
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
currentIndex: _currentIndex,
onTap: (index) {
if (index == 2) {
// Navigate to chart screen
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ChartScreen()),
);
} else {
setState(() {
_currentIndex = index;
});
}
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.notifications),
label: 'Notifikasi',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart),
label: 'Grafik',
),
],
),
);
}
Widget _buildDashboardContent() {
return RefreshIndicator(
onRefresh: _loadLatestData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status Card
_buildStatusCard(),
const SizedBox(height: 16),
// Metrics
Row(
children: [
Expanded(
child: _buildMetricCard(
icon: Icons.thermostat,
iconColor: Colors.orange,
label: 'Suhu',
value: suhu.toStringAsFixed(1),
unit: '°C',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
icon: Icons.scale,
iconColor: Colors.blue,
label: 'Berat',
value: berat.toStringAsFixed(0),
unit: 'g',
),
),
],
),
const SizedBox(height: 12),
// Target
_buildMetricCard(
icon: Icons.flag,
iconColor: Colors.green,
label: 'Target Berat',
value: target.toStringAsFixed(0),
unit: 'g',
),
const SizedBox(height: 16),
// ======= GRAFIK CHARTS =======
// Temperature Chart Widget
TemperatureChart(
dataPoints: suhuHistory,
title: 'Suhu Real-time',
lineColor: Colors.orange,
gradientStartColor: Colors.orange,
gradientEndColor: Colors.red,
),
const SizedBox(height: 12),
// Weight Chart Widget
WeightChart(
dataPoints: beratHistory,
targetWeight: target,
title: 'Berat Real-time',
lineColor: Colors.blue,
gradientStartColor: Colors.blue,
gradientEndColor: Colors.cyan,
),
const SizedBox(height: 16),
// Devices
const Text(
'Perangkat',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.9,
children: [
_buildDeviceCard(
icon: Icons.whatshot,
iconColor: Colors.deepOrange,
label: 'Heater',
status: heater,
),
_buildDeviceCard(
icon: Icons.air,
iconColor: Colors.blue,
label: 'Fan',
status: fanIn,
),
_buildDeviceCard(
icon: Icons.wind_power,
iconColor: Colors.teal,
label: 'Exhaust',
status: exhaust,
),
],
),
],
),
),
);

View File

@ -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);

View File

@ -115,38 +115,23 @@ class NotificationManager extends ChangeNotifier {
}
}
// Notification helpers for specific events
Future<void> notifyPengeringanDimulai() async {
await addNotification(
title: 'Pengeringan Dimulai',
message: 'Proses pengeringan ikan telah dimulai',
type: NotificationType.info,
);
}
Future<void> notifyIkanTerdeteksi(double berat) async {
await addNotification(
title: 'Ikan Terdeteksi',
message: 'Berat ikan: ${berat.toStringAsFixed(0)} gram',
type: NotificationType.info,
);
}
Future<void> notifyPengeringanBerjalan(double berat, double target) async {
await addNotification(
title: 'Pengeringan Berjalan',
message: 'Berat: ${berat.toStringAsFixed(0)}g | Target: ${target.toStringAsFixed(0)}g',
type: NotificationType.info,
showPush: false, // Don't show push for regular updates
);
}
// Notification helper untuk pengeringan selesai
Future<void> notifyPengeringanSelesai(double beratAkhir) async {
print('🔔 Calling notifyPengeringanSelesai with berat: $beratAkhir');
// Panggil notification service langsung
final notificationService = NotificationService();
await notificationService.notifyPengeringanSelesai(beratAkhir);
// Simpan juga ke notification manager
await addNotification(
title: 'Pengeringan Selesai',
message: 'Target tercapai! Berat akhir: ${beratAkhir.toStringAsFixed(0)} gram',
type: NotificationType.success,
showPush: true,
);
print('✅ notifyPengeringanSelesai completed');
}
Future<void> notifySuhuTinggi(double suhu) async {

View File

@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
class TemperatureChart extends StatelessWidget {
final List<FlSpot> dataPoints;
final String title;
final Color lineColor;
final Color gradientStartColor;
final Color gradientEndColor;
final double minY;
final double maxY;
final String unit;
const TemperatureChart({
super.key,
required this.dataPoints,
this.title = 'Suhu Real-time',
this.lineColor = Colors.orange,
this.gradientStartColor = Colors.orange,
this.gradientEndColor = Colors.red,
this.minY = 20,
this.maxY = 60,
this.unit = '°C',
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: lineColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.thermostat, color: lineColor, size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Text(
dataPoints.isNotEmpty
? '${dataPoints.last.y.toStringAsFixed(1)} $unit'
: '-- $unit',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: lineColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.fiber_manual_record,
color: lineColor,
size: 8,
),
const SizedBox(width: 4),
const Text(
'LIVE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.black54,
),
),
],
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200, // Naik dari 180 jadi 200
child: dataPoints.isEmpty
? Center(
child: Text(
'Menunggu data...',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
),
),
)
: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 10,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 5,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'${value.toInt()}s',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 10,
reservedSize: 45,
getTitlesWidget: (value, meta) {
return Text(
'${value.toInt()}$unit',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
left: BorderSide(color: Colors.grey.shade300),
),
),
minX: dataPoints.isNotEmpty
? dataPoints.first.x
: 0,
maxX: dataPoints.isNotEmpty
? dataPoints.last.x
: 10,
minY: minY,
maxY: maxY,
lineBarsData: [
LineChartBarData(
spots: dataPoints,
isCurved: true,
curveSmoothness: 0.3,
color: lineColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
// Hanya tampilkan dot di titik terakhir
if (index == dataPoints.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.white,
strokeWidth: 3,
strokeColor: lineColor,
);
}
return FlDotCirclePainter(
radius: 0,
color: Colors.transparent,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
gradientStartColor.withOpacity(0.3),
gradientEndColor.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) => Colors.black87,
tooltipRoundedRadius: 8,
fitInsideHorizontally: true,
fitInsideVertically: true,
tooltipPadding: const EdgeInsets.all(8),
tooltipMargin: 8,
getTooltipItems: (List<LineBarSpot> touchedSpots) {
return touchedSpots.map((LineBarSpot touchedSpot) {
return LineTooltipItem(
'${touchedSpot.y.toStringAsFixed(1)} $unit',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
);
}).toList();
},
),
handleBuiltInTouches: true,
),
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,385 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
class WeightChart extends StatelessWidget {
final List<FlSpot> dataPoints;
final double targetWeight;
final String title;
final Color lineColor;
final Color gradientStartColor;
final Color gradientEndColor;
final String unit;
const WeightChart({
super.key,
required this.dataPoints,
required this.targetWeight,
this.title = 'Berat Real-time',
this.lineColor = Colors.blue,
this.gradientStartColor = Colors.blue,
this.gradientEndColor = Colors.cyan,
this.unit = 'g',
});
@override
Widget build(BuildContext context) {
// Filter data yang valid untuk ditampilkan (ganti nilai 0 dengan nilai sebelumnya)
List<FlSpot> displayPoints = [];
double? lastValidValue;
for (var spot in dataPoints) {
if (spot.y > 10) {
// Data valid, gunakan nilai asli
displayPoints.add(spot);
lastValidValue = spot.y;
} else if (lastValidValue != null) {
// Data invalid (0), gunakan nilai terakhir yang valid
displayPoints.add(FlSpot(spot.x, lastValidValue));
}
}
// Jika masih kosong setelah filter, gunakan data asli
if (displayPoints.isEmpty) {
displayPoints = dataPoints;
}
// Hitung min dan max Y berdasarkan data yang akan ditampilkan
double minY = 0;
double maxY = 200;
if (displayPoints.isNotEmpty) {
// Cari nilai min dan max dari data yang akan ditampilkan
double dataMin = displayPoints.map((e) => e.y).reduce((a, b) => a < b ? a : b);
double dataMax = displayPoints.map((e) => e.y).reduce((a, b) => a > b ? a : b);
// Jika ada target, sertakan dalam perhitungan
if (targetWeight > 0) {
dataMin = dataMin < targetWeight ? dataMin : targetWeight;
dataMax = dataMax > targetWeight ? dataMax : targetWeight;
}
// Pastikan minY tidak negatif dan ada padding yang cukup
double range = dataMax - dataMin;
double padding = range > 0 ? range * 0.25 : 50; // 25% padding atau minimal 50
minY = (dataMin - padding).clamp(0, double.infinity);
maxY = dataMax + padding;
// Pastikan ada range minimal 100
if (maxY - minY < 100) {
double center = (maxY + minY) / 2;
minY = (center - 50).clamp(0, double.infinity);
maxY = center + 50;
}
// Debug log
print('📊 Weight Chart: min=$minY, max=$maxY, dataMin=$dataMin, dataMax=$dataMax, displayPoints=${displayPoints.length}/${dataPoints.length}');
} else if (targetWeight > 0) {
// Kalau belum ada data, gunakan target
minY = 0;
maxY = (targetWeight * 1.5).clamp(100, double.infinity);
} else {
// Fallback default
minY = 0;
maxY = 200;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: lineColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.scale, color: lineColor, size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Row(
children: [
Text(
dataPoints.isNotEmpty
? '${dataPoints.last.y.toStringAsFixed(0)} $unit'
: '-- $unit',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 12, color: Colors.grey.shade400),
const SizedBox(width: 4),
Text(
'${targetWeight.toStringAsFixed(0)} $unit',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.green.shade600,
),
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: lineColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.fiber_manual_record,
color: lineColor,
size: 8,
),
const SizedBox(width: 4),
const Text(
'LIVE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.black54,
),
),
],
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200, // Naik dari 180 jadi 200 biar lebih lapang
child: dataPoints.isEmpty
? Center(
child: Text(
'Menunggu data...',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
),
),
)
: Padding(
padding: const EdgeInsets.only(left: 8, right: 8), // Tambah padding kiri kanan
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 50,
getDrawingHorizontalLine: (value) {
// Garis target lebih tebal
if ((value - targetWeight).abs() < 5) {
return FlLine(
color: Colors.green.shade300,
strokeWidth: 2,
dashArray: [5, 5],
);
}
return FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 5,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'${value.toInt()}s',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 50, // Turun ke 50, karena masalahnya bukan disini
getTitlesWidget: (value, meta) {
// Tandai target dengan warna hijau
final isTarget = (value - targetWeight).abs() < 5;
return Text(
'${value.toInt()}$unit',
style: TextStyle(
fontSize: 10,
color: isTarget ? Colors.green : Colors.grey,
fontWeight: isTarget ? FontWeight.bold : FontWeight.normal,
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
left: BorderSide(color: Colors.grey.shade300),
),
),
minX: displayPoints.isNotEmpty
? displayPoints.first.x
: 0,
maxX: displayPoints.isNotEmpty
? displayPoints.last.x
: 10,
minY: minY,
maxY: maxY,
extraLinesData: ExtraLinesData(
horizontalLines: [
HorizontalLine(
y: targetWeight,
color: Colors.green.shade400,
strokeWidth: 2,
dashArray: [8, 4],
label: HorizontalLineLabel(
show: true,
alignment: Alignment.topRight,
padding: const EdgeInsets.only(right: 5, bottom: 5),
style: TextStyle(
color: Colors.green.shade700,
fontSize: 10,
fontWeight: FontWeight.bold,
),
labelResolver: (line) => 'Target',
),
),
],
),
lineBarsData: [
LineChartBarData(
spots: displayPoints,
isCurved: true,
curveSmoothness: 0.3,
color: lineColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
// Hanya tampilkan dot di titik terakhir
if (index == displayPoints.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.white,
strokeWidth: 3,
strokeColor: lineColor,
);
}
return FlDotCirclePainter(
radius: 0,
color: Colors.transparent,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
gradientStartColor.withOpacity(0.3),
gradientEndColor.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchedSpot) => Colors.black87,
tooltipRoundedRadius: 8,
fitInsideHorizontally: true,
fitInsideVertically: true,
tooltipPadding: const EdgeInsets.all(8),
tooltipMargin: 8,
getTooltipItems: (List<LineBarSpot> touchedSpots) {
return touchedSpots.map((LineBarSpot touchedSpot) {
final remaining = touchedSpot.y - targetWeight;
return LineTooltipItem(
'${touchedSpot.y.toStringAsFixed(0)} $unit\n',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
children: [
TextSpan(
text: remaining > 0
? 'Sisa ${remaining.toStringAsFixed(0)} $unit'
: 'Target tercapai!',
style: TextStyle(
color: remaining > 0
? Colors.orange.shade200
: Colors.green.shade200,
fontSize: 10,
),
),
],
);
}).toList();
},
),
handleBuiltInTouches: true,
),
),
),
),
),
],
),
);
}
}

8
nixpacks.toml Normal file
View File

@ -0,0 +1,8 @@
[phases.setup]
nixPkgs = ['nodejs-18_x']
[phases.build]
cmds = ['cd backend && npm install']
[start]
cmd = 'cd backend && node server.js'

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -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"
}
]
}
}