Fix bug duplikasi jam 15.00 dan perbaiki zona waktu Jakarta
This commit is contained in:
parent
853ed48715
commit
036a3543ed
|
|
@ -7,6 +7,9 @@ firebase_core=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.de
|
||||||
firebase_core_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_core_web-2.24.1\\
|
firebase_core_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_core_web-2.24.1\\
|
||||||
firebase_database=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database-11.3.10\\
|
firebase_database=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database-11.3.10\\
|
||||||
firebase_database_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database_web-0.2.6+16\\
|
firebase_database_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database_web-0.2.6+16\\
|
||||||
|
flutter_local_notifications=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_local_notifications-17.2.4\\
|
||||||
|
flutter_local_notifications_linux=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_local_notifications_linux-4.0.1\\
|
||||||
|
flutter_timezone=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_timezone-4.1.1\\
|
||||||
path_provider=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider-2.1.5\\
|
path_provider=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider-2.1.5\\
|
||||||
path_provider_android=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_android-2.2.19\\
|
path_provider_android=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_android-2.2.19\\
|
||||||
path_provider_foundation=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_foundation-2.4.2\\
|
path_provider_foundation=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_foundation-2.4.2\\
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
# Catatan Lengkap Implementasi Notifikasi (TelurKu)
|
||||||
|
|
||||||
|
Dokumen ini menjelaskan kenapa sistem notifikasi saat ini berjalan lancar, bagaimana konsep kerjanya, batasannya saat app di-minimize/ditutup, dan apa saja yang perlu dibuat jika ingin dipakai ulang di aplikasi berikutnya.
|
||||||
|
|
||||||
|
## 1) Ringkasan Kenapa Notifikasi Bisa Berjalan Lancar
|
||||||
|
|
||||||
|
Sistem notifikasi berjalan baik karena ada kombinasi yang benar antara:
|
||||||
|
|
||||||
|
1. Inisialisasi service notifikasi sejak awal app startup.
|
||||||
|
2. Channel Android sudah dibuat dengan importance tinggi.
|
||||||
|
3. Permission notifikasi dan exact alarm diminta secara eksplisit.
|
||||||
|
4. Scheduling memakai timezone-aware API (zoned schedule).
|
||||||
|
5. Ada fallback jika exact alarm tidak diizinkan (tetap kirim inexact).
|
||||||
|
6. Receiver Android untuk scheduled notification + boot completed sudah terpasang.
|
||||||
|
7. ID notifikasi harian dibuat stabil dari scheduleKey sehingga update/cancel konsisten.
|
||||||
|
|
||||||
|
## 2) Arsitektur Notifikasi Saat Ini (di Project Ini)
|
||||||
|
|
||||||
|
### 2.1 Service Utama
|
||||||
|
|
||||||
|
- Service utama: [lib/services/notification_service.dart](lib/services/notification_service.dart)
|
||||||
|
- Fungsi penting:
|
||||||
|
- initialize: setup timezone, init plugin, channel, permissions
|
||||||
|
- showInstantNotification: kirim notifikasi langsung
|
||||||
|
- scheduleNotification: jadwalkan notifikasi sekali jalan
|
||||||
|
- scheduleDailyNotificationAtTime: jadwal harian (repeat per jam yang sama)
|
||||||
|
- cancelScheduledNotification/cancelAll/pendingNotifications
|
||||||
|
|
||||||
|
### 2.2 Titik Inisialisasi App
|
||||||
|
|
||||||
|
- Inisialisasi dipanggil di startup app non-web: [lib/main.dart](lib/main.dart)
|
||||||
|
- Bagian bootstrap juga:
|
||||||
|
- memuat data awal,
|
||||||
|
- sinkron jadwal aktif,
|
||||||
|
- menjadwalkan ulang notifikasi harian,
|
||||||
|
- cancel jadwal yang tidak aktif lagi.
|
||||||
|
|
||||||
|
### 2.3 Trigger Notifikasi dari Data Realtime
|
||||||
|
|
||||||
|
- Realtime listener panen: [lib/providers/panen_provider.dart](lib/providers/panen_provider.dart)
|
||||||
|
- Saat ada record baru Firebase Realtime DB, app membuat notifikasi ringkasan panen (pagi/sore).
|
||||||
|
|
||||||
|
### 2.4 Trigger Otomatis Berdasarkan Jam Panen
|
||||||
|
|
||||||
|
- Runtime scheduler 30 detik: [lib/services/panen_runtime_scheduler_service.dart](lib/services/panen_runtime_scheduler_service.dart)
|
||||||
|
- Service auto capture panen: [lib/services/panen_auto_capture_service.dart](lib/services/panen_auto_capture_service.dart)
|
||||||
|
- Setelah capture, sistem kirim notifikasi summary per jenis panen.
|
||||||
|
|
||||||
|
### 2.5 Konfigurasi Android yang Wajib
|
||||||
|
|
||||||
|
- Manifest: [android/app/src/main/AndroidManifest.xml](android/app/src/main/AndroidManifest.xml)
|
||||||
|
- Sudah ada:
|
||||||
|
- receiver scheduled notification
|
||||||
|
- receiver boot completed
|
||||||
|
- POST_NOTIFICATIONS
|
||||||
|
- SCHEDULE_EXACT_ALARM dan USE_EXACT_ALARM
|
||||||
|
- RECEIVE_BOOT_COMPLETED
|
||||||
|
|
||||||
|
## 3) Perilaku Notifikasi Berdasarkan Kondisi App
|
||||||
|
|
||||||
|
### 3.1 App Foreground
|
||||||
|
|
||||||
|
- Notifikasi langsung (instant) akan muncul sesuai konfigurasi plugin/channel.
|
||||||
|
|
||||||
|
### 3.2 App Minimize / Background
|
||||||
|
|
||||||
|
- Notifikasi terjadwal tetap bisa tampil jika sudah dijadwalkan sebelumnya.
|
||||||
|
- Ini karena alarm dikelola oleh OS, bukan loop UI Flutter.
|
||||||
|
|
||||||
|
### 3.3 App Ditutup User (swipe away / terminate proses)
|
||||||
|
|
||||||
|
- Notifikasi lokal terjadwal yang sudah terdaftar umumnya tetap bisa tampil.
|
||||||
|
- Tetapi proses Flutter berhenti, artinya:
|
||||||
|
- listener realtime Firebase berhenti,
|
||||||
|
- runtime scheduler internal berhenti,
|
||||||
|
- logic yang butuh eksekusi Dart real-time tidak jalan lagi.
|
||||||
|
|
||||||
|
### 3.4 App Baru Dinyalakan Ulang / Device Reboot
|
||||||
|
|
||||||
|
- Dengan boot receiver, jadwal plugin bisa dipulihkan (tergantung platform/plugin).
|
||||||
|
- Tetap bagus jika app melakukan resync jadwal saat startup (sudah dilakukan di bootstrap).
|
||||||
|
|
||||||
|
## 4) Hal Penting: Notifikasi Lokal vs Push Notification
|
||||||
|
|
||||||
|
### Notifikasi lokal
|
||||||
|
|
||||||
|
Cocok untuk:
|
||||||
|
- reminder berbasis waktu yang sudah diketahui,
|
||||||
|
- alarm harian,
|
||||||
|
- notifikasi yang bisa dijadwalkan dari dalam app.
|
||||||
|
|
||||||
|
Keterbatasan:
|
||||||
|
- tidak ideal untuk event server real-time saat app benar-benar mati total.
|
||||||
|
|
||||||
|
### Push notification (FCM/APNs)
|
||||||
|
|
||||||
|
Diperlukan jika kamu ingin:
|
||||||
|
- kirim notif dari server kapan pun,
|
||||||
|
- user tetap menerima event baru meskipun app tidak aktif.
|
||||||
|
|
||||||
|
Kesimpulan praktis:
|
||||||
|
- Reminder waktu tetap: lokal notification.
|
||||||
|
- Event dari backend/sensor saat app tidak aktif: push notification.
|
||||||
|
|
||||||
|
## 5) Konsep Implementasi Ulang ke App Berikutnya
|
||||||
|
|
||||||
|
Urutan implementasi yang direkomendasikan:
|
||||||
|
|
||||||
|
1. Pasang package:
|
||||||
|
- flutter_local_notifications
|
||||||
|
- timezone
|
||||||
|
- flutter_timezone
|
||||||
|
2. Buat service singleton notifikasi (pola seperti NotificationService).
|
||||||
|
3. Pada initialize:
|
||||||
|
- init timezone,
|
||||||
|
- init plugin,
|
||||||
|
- create channel Android,
|
||||||
|
- request permission notif + exact alarm.
|
||||||
|
4. Buat API wrapper:
|
||||||
|
- showInstantNotification
|
||||||
|
- scheduleOneShot
|
||||||
|
- scheduleDaily
|
||||||
|
- cancelByKey
|
||||||
|
- pending list
|
||||||
|
5. Gunakan stable notification ID berbasis key agar update/cancel tidak salah sasaran.
|
||||||
|
6. Simpan source of truth jadwal (DB lokal/remote), lalu resync di startup app.
|
||||||
|
7. Tambahkan debug screen untuk:
|
||||||
|
- test instant notif,
|
||||||
|
- test jadwal 30 detik,
|
||||||
|
- lihat pending schedule,
|
||||||
|
- test exact alarm permission.
|
||||||
|
8. Tambahkan Android receivers + permission yang diperlukan.
|
||||||
|
9. Uji di beberapa kondisi device (lihat checklist pengujian di bawah).
|
||||||
|
10. Jika butuh event server saat app mati, tambahkan FCM backend.
|
||||||
|
|
||||||
|
## 6) Checklist Pengujian yang Wajib
|
||||||
|
|
||||||
|
1. Foreground test: instant notif muncul.
|
||||||
|
2. Background test: jadwal 30 detik tetap muncul saat app diminimize.
|
||||||
|
3. Terminated test: jadwal tetap muncul setelah app diswipe (untuk jadwal yang sudah tercatat).
|
||||||
|
4. Reboot test: setelah restart HP, jadwal masih ada atau berhasil resync saat app dibuka.
|
||||||
|
5. Exact alarm off test: pastikan fallback inexact tetap bekerja.
|
||||||
|
6. Permission denied test: app tidak crash, tampilkan instruksi jelas ke user.
|
||||||
|
7. OEM aggressive battery test (Xiaomi/Oppo/Vivo, dsb): validasi behavior nyata.
|
||||||
|
|
||||||
|
## 7) Batasan Nyata di Android Modern
|
||||||
|
|
||||||
|
1. Doze/battery optimization bisa menunda notifikasi non-exact.
|
||||||
|
2. Exact alarm butuh izin user di banyak device/versi Android.
|
||||||
|
3. Sebagian vendor membatasi background process dengan agresif.
|
||||||
|
4. Karena itu, selalu sediakan fallback, status pending, dan edukasi user.
|
||||||
|
|
||||||
|
## 8) Rekomendasi Praktis untuk Produksi
|
||||||
|
|
||||||
|
1. Pisahkan notifikasi menjadi:
|
||||||
|
- reminder terjadwal (lokal),
|
||||||
|
- event backend (push).
|
||||||
|
2. Simpan mapping scheduleKey -> bisnis ID secara konsisten.
|
||||||
|
3. Hindari log print berlebih pada mode produksi.
|
||||||
|
4. Tambahkan telemetry sederhana (berapa jadwal aktif, terakhir sync kapan).
|
||||||
|
5. Siapkan halaman troubleshooting notifikasi untuk user.
|
||||||
|
|
||||||
|
## 9) Kesimpulan
|
||||||
|
|
||||||
|
Notifikasi di project ini sudah berada di jalur yang benar untuk use-case reminder dan summary lokal. Kunci stabilitasnya ada di setup channel + permission + timezone + scheduler yang disinkronkan ulang saat startup.
|
||||||
|
|
||||||
|
Untuk kebutuhan notifikasi real-time ketika app benar-benar mati dan event datang dari server, kamu harus menambahkan arsitektur push notification (FCM/APNs) di sisi backend.
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Perbaikan Sistem Panen - Dokumentasi Perubahan
|
||||||
|
|
||||||
|
## Masalah yang Diperbaiki
|
||||||
|
|
||||||
|
Sebelumnya, sistem panen menunjukkan duplikasi entry riwayat saat hari yang sama:
|
||||||
|
- Contoh: Jadwal sore jam 14:34 menghasilkan capture di 14:34 DAN 15:00
|
||||||
|
- Akar masalah: Flutter runtime scheduler hardcoded untuk trigger di jam 15:00 secara otomatis, terlepas dari jadwal aktif
|
||||||
|
|
||||||
|
## Perubahan yang Dilakukan
|
||||||
|
|
||||||
|
### 1. Worker Railway (`railway-scheduler/worker.js`)
|
||||||
|
**Perubahan klasifikasi pagi/sore:**
|
||||||
|
- Sebelum: Cek keterangan jadwal ("sore", "pagi")
|
||||||
|
- Sesudah: Hanya lihat jam - jika `jam < 12` = pagi, jika `jam >= 12` = sore
|
||||||
|
|
||||||
|
**Perubahan locking eksekusi:**
|
||||||
|
- Sebelum: Lock key = `jadwalId_jenisPanen_kandangId` (per jadwal)
|
||||||
|
- Sesudah: Lock key = `jenisPanen_kandangId` (per slot)
|
||||||
|
- Akibat: Satu slot pagi/sore per kandang per hari hanya jalan 1x, tidak peduli berapa jadwal cocok
|
||||||
|
|
||||||
|
### 2. Flutter Runtime Scheduler (`lib/services/panen_runtime_scheduler_service.dart`)
|
||||||
|
**Perubahan deteksi jadwal:**
|
||||||
|
- Sebelum: Default jam pagi 09:00 dan sore 15:00, cari jadwal yang cocok untuk override
|
||||||
|
- Sesudah: Cari jadwal aktif, ambil jam pagi dan sore HANYA jika ada jadwal untuk slot itu
|
||||||
|
- Akibat: Jika tidak ada jadwal sore, capture sore tidak akan trigger (tidak hardcoded 15:00)
|
||||||
|
|
||||||
|
**Perubahan trigger window:**
|
||||||
|
- Sebelum: Selalu trigger pagi di jam 09:00-09:10 dan sore di jam 15:00-15:10
|
||||||
|
- Sesudah: Hanya trigger jika jadwal aktif untuk slot itu (null check)
|
||||||
|
|
||||||
|
### 3. Flutter Panen Provider (`lib/providers/panen_provider.dart`)
|
||||||
|
**Perubahan klasifikasi jenis panen:**
|
||||||
|
- Sebelum: Hardcoded untuk jam 09 = pagi, jam 15 = sore
|
||||||
|
- Sesudah: Parse jam, jika hour < 12 = pagi, hour >= 12 = sore
|
||||||
|
- Akibat: Sistem konsisten dengan worker, hitung delta sore dengan benar
|
||||||
|
|
||||||
|
## Cara Kerja Sistem Sekarang
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Jadwal Aktif: Kandang Global │
|
||||||
|
│ Pagi: Jam 09:00 | Sore: Jam 14:34 (Misal) │
|
||||||
|
└────────────────────┬────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────┴───────────┐
|
||||||
|
▼ ▼
|
||||||
|
WORKER RAILWAY FLUTTER APP
|
||||||
|
(Production) (Backup/Manual)
|
||||||
|
│ │
|
||||||
|
Setiap menit: Setiap 30 detik:
|
||||||
|
- Cek jadwal aktif - Cek jadwal aktif
|
||||||
|
- Jika jam cocok - Jika jam cocok
|
||||||
|
- LOCK SLOT (pagi/sore) - Trigger capture
|
||||||
|
- Capture ke Firebase - Simpan jika belum
|
||||||
|
│ │
|
||||||
|
└────────────┬───────────┘
|
||||||
|
▼
|
||||||
|
Firebase /riwayat/records
|
||||||
|
(1 entry per slot per kandang per hari)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skenario Sebelum vs Sesudah
|
||||||
|
|
||||||
|
### Skenario: Jadwal 1 Pagi 09:00 + Sore 14:34
|
||||||
|
|
||||||
|
**SEBELUM:**
|
||||||
|
- Pukul 09:00: Worker trigger capture pagi ✅ Kandang1: 100, Kandang2: 100
|
||||||
|
- Pukul 14:34: Worker trigger capture sore ✅ Kandang1: 20 (delta: 120-100), Kandang2: 20
|
||||||
|
- Pukul 15:00: Flutter trigger capture sore ❌ Kandang1: 0, Kandang2: 0 (DUPLIKASI!)
|
||||||
|
- Total riwayat hari ini: 6 entry (3 per kandang)
|
||||||
|
|
||||||
|
**SESUDAH:**
|
||||||
|
- Pukul 09:00: Worker trigger capture pagi ✅ Kandang1: 100, Kandang2: 100
|
||||||
|
- Pukul 14:34: Worker trigger capture sore ✅ Kandang1: 20 (delta: 120-100), Kandang2: 20
|
||||||
|
- Pukul 15:00: Flutter cek jadwal - tidak ada jadwal di 15:00 ❌ SKIP
|
||||||
|
- Total riwayat hari ini: 4 entry (2 per kandang) ✅
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Buat jadwal pagi jam 09:00 + sore jam 14:34
|
||||||
|
- [ ] Cek riwayat hari ini - harus ada 2 entry per kandang (pagi + sore)
|
||||||
|
- [ ] Cek jam panen - pagi di 09:00, sore di 14:34 (bukan 15:00)
|
||||||
|
- [ ] Cek nilai sore = sensor_sekarang - nilai_pagi
|
||||||
|
- [ ] Tunggu 24 jam, cek apakah snapshot direset otomatis di tengah malam
|
||||||
|
|
||||||
|
## Catatan
|
||||||
|
|
||||||
|
- Jika ada jadwal HANYA sore (tanpa pagi), capture pagi tidak akan trigger
|
||||||
|
- Jika ada jadwal HANYA pagi (tanpa sore), capture sore tidak akan trigger
|
||||||
|
- Saat ini sistem SETARA antara Worker Railway dan Flutter Runtime Scheduler
|
||||||
|
- Kalau kedua trigger di waktu bersamaan, Firebase lock akan mencegah duplikasi
|
||||||
|
- Lock lebih dipercaya ke worker (production), Flutter hanya fallback
|
||||||
|
|
@ -13,6 +13,7 @@ android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
|
@ -24,7 +25,7 @@ android {
|
||||||
applicationId = "com.example.telurku"
|
applicationId = "com.example.telurku"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = 23
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
@ -39,6 +40,10 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- Receiver wajib untuk notifikasi terjadwal flutter_local_notifications -->
|
||||||
|
<receiver
|
||||||
|
android:exported="false"
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
|
<receiver
|
||||||
|
android:exported="false"
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
@ -48,7 +63,11 @@
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||||
|
|
||||||
<!-- Android 12+ requires SCHEDULE_EXACT_ALARM untuk background tasks -->
|
<!-- Android 12+ requires SCHEDULE_EXACT_ALARM untuk background tasks -->
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
130
lib/main.dart
130
lib/main.dart
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
import 'package:firebase_database/firebase_database.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
@ -9,11 +10,15 @@ import 'providers/kandang_provider.dart';
|
||||||
import 'providers/panen_provider.dart';
|
import 'providers/panen_provider.dart';
|
||||||
import 'providers/penjadwalan_provider.dart';
|
import 'providers/penjadwalan_provider.dart';
|
||||||
import 'providers/riwayat_provider.dart';
|
import 'providers/riwayat_provider.dart';
|
||||||
|
import 'services/notification_service.dart';
|
||||||
|
import 'services/panen_runtime_scheduler_service.dart';
|
||||||
// import 'services/panen_scheduler.dart'; // TODO: Enable after scheduler fixed
|
// import 'services/panen_scheduler.dart'; // TODO: Enable after scheduler fixed
|
||||||
import 'screens/landing_page.dart';
|
import 'screens/landing_page.dart';
|
||||||
import 'screens/login_page.dart';
|
import 'screens/login_page.dart';
|
||||||
import 'screens/signup_page.dart';
|
import 'screens/signup_page.dart';
|
||||||
import 'screens/home_page.dart';
|
import 'screens/home_page.dart';
|
||||||
|
import 'screens/panen_debug_screen.dart';
|
||||||
|
import 'screens/notification_debug_screen.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -26,6 +31,10 @@ void main() async {
|
||||||
FirebaseDatabase.instance.setPersistenceEnabled(true);
|
FirebaseDatabase.instance.setPersistenceEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!kIsWeb) {
|
||||||
|
await NotificationService.instance.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize background scheduler untuk panen otomatis
|
// Initialize background scheduler untuk panen otomatis
|
||||||
// TODO: Enable setelah scheduler stabil
|
// TODO: Enable setelah scheduler stabil
|
||||||
// await PanenScheduler.initialize();
|
// await PanenScheduler.initialize();
|
||||||
|
|
@ -47,7 +56,7 @@ class MyApp extends StatelessWidget {
|
||||||
ChangeNotifierProvider(create: (_) => TelurProvider()),
|
ChangeNotifierProvider(create: (_) => TelurProvider()),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'TelurKu',
|
title: 'TEFA UNGGAS POLIJE',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|
@ -62,6 +71,8 @@ class MyApp extends StatelessWidget {
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
'/signup': (context) => const SignupPage(),
|
'/signup': (context) => const SignupPage(),
|
||||||
'/home': (context) => const AuthWrapper(),
|
'/home': (context) => const AuthWrapper(),
|
||||||
|
'/debug/panen': (context) => const PanenDebugScreen(),
|
||||||
|
'/debug/notifikasi': (context) => const NotificationDebugScreen(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -106,6 +117,9 @@ class HomeBootstrap extends StatefulWidget {
|
||||||
|
|
||||||
class _HomeBootstrapState extends State<HomeBootstrap> {
|
class _HomeBootstrapState extends State<HomeBootstrap> {
|
||||||
late Future<void> _initFuture;
|
late Future<void> _initFuture;
|
||||||
|
PenjadwalanProvider? _penjadwalanProvider;
|
||||||
|
final Set<String> _scheduledReminderKeys = <String>{};
|
||||||
|
Timer? _scheduleSyncDebounce;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -122,6 +136,120 @@ class _HomeBootstrapState extends State<HomeBootstrap> {
|
||||||
await kandangProvider.initializeWithUser(widget.userId);
|
await kandangProvider.initializeWithUser(widget.userId);
|
||||||
await panenProvider.loadTodaySnapshots();
|
await panenProvider.loadTodaySnapshots();
|
||||||
await panenProvider.restorePanenHistoryFromFirebase();
|
await panenProvider.restorePanenHistoryFromFirebase();
|
||||||
|
await panenProvider.startRealtimePanenNotifications();
|
||||||
|
_attachPenjadwalanListener(penjadwalanProvider);
|
||||||
|
|
||||||
|
await _syncDailyHarvestReminders(penjadwalanProvider);
|
||||||
|
|
||||||
|
await PanenRuntimeSchedulerService.start(
|
||||||
|
panenProvider: panenProvider,
|
||||||
|
kandangProvider: kandangProvider,
|
||||||
|
penjadwalanProvider: penjadwalanProvider,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attachPenjadwalanListener(PenjadwalanProvider provider) {
|
||||||
|
if (identical(_penjadwalanProvider, provider)) return;
|
||||||
|
|
||||||
|
_penjadwalanProvider?.removeListener(_onPenjadwalanChanged);
|
||||||
|
_penjadwalanProvider = provider;
|
||||||
|
_penjadwalanProvider?.addListener(_onPenjadwalanChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPenjadwalanChanged() {
|
||||||
|
_scheduleSyncDebounce?.cancel();
|
||||||
|
_scheduleSyncDebounce = Timer(const Duration(seconds: 1), () async {
|
||||||
|
final provider = _penjadwalanProvider;
|
||||||
|
if (!mounted || provider == null) return;
|
||||||
|
await _syncDailyHarvestReminders(provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, int)? _tryParseHourMinute(String rawJam) {
|
||||||
|
final match = RegExp(r'(\d{1,2})[:.](\d{2})').firstMatch(rawJam.trim());
|
||||||
|
if (match == null) return null;
|
||||||
|
|
||||||
|
final hour = int.tryParse(match.group(1)!);
|
||||||
|
final minute = int.tryParse(match.group(2)!);
|
||||||
|
if (hour == null || minute == null) return null;
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||||
|
return (hour, minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncDailyHarvestReminders(PenjadwalanProvider provider) async {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('📱 [BOOTSTRAP] Syncing harvest reminders...');
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeSchedules =
|
||||||
|
provider.penjadwalans.where((s) => s.aktif).toList();
|
||||||
|
final nextKeys = <String>{};
|
||||||
|
|
||||||
|
for (final schedule in activeSchedules) {
|
||||||
|
final scheduleKey = 'panen:${schedule.id}:${schedule.kandangId}';
|
||||||
|
nextKeys.add(scheduleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
final removedKeys = _scheduledReminderKeys.difference(nextKeys).toList();
|
||||||
|
for (final removedKey in removedKeys) {
|
||||||
|
await NotificationService.instance
|
||||||
|
.cancelScheduledNotification(removedKey);
|
||||||
|
_scheduledReminderKeys.remove(removedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final schedule in activeSchedules) {
|
||||||
|
final parsed = _tryParseHourMinute(schedule.jam);
|
||||||
|
if (parsed == null) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ [BOOTSTRAP] Invalid time format: ${schedule.jam}');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hour = parsed.$1;
|
||||||
|
final minute = parsed.$2;
|
||||||
|
final jenisPanen = hour < 12 ? 'pagi' : 'sore';
|
||||||
|
final scheduleKey = 'panen:${schedule.id}:${schedule.kandangId}';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await NotificationService.instance.scheduleDailyNotificationAtTime(
|
||||||
|
scheduleKey: scheduleKey,
|
||||||
|
title:
|
||||||
|
'Panen ${jenisPanen[0].toUpperCase()}${jenisPanen.substring(1)}',
|
||||||
|
body:
|
||||||
|
'Jadwal panen ${schedule.kandangNama} dimulai pukul ${schedule.jam}. Buka aplikasi untuk detail.',
|
||||||
|
hour: hour,
|
||||||
|
minute: minute,
|
||||||
|
payload: 'panen_schedule:${schedule.id}',
|
||||||
|
);
|
||||||
|
_scheduledReminderKeys.add(scheduleKey);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'✅ [BOOTSTRAP] Scheduled reminder: $jenisPanen ${schedule.kandangNama} @ ${schedule.jam}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('❌ [BOOTSTRAP] Failed schedule ${schedule.id}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pending = await NotificationService.instance.pendingNotifications();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'📱 [BOOTSTRAP] Active schedules: ${activeSchedules.length}, pending notifications: ${pending.length}');
|
||||||
|
print('✅ [BOOTSTRAP] Sync reminders done');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scheduleSyncDebounce?.cancel();
|
||||||
|
_penjadwalanProvider?.removeListener(_onPenjadwalanChanged);
|
||||||
|
PanenRuntimeSchedulerService.stop();
|
||||||
|
context.read<PanenProvider>().stopRealtimePanenNotifications();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ class Penjadwalan {
|
||||||
final String durasi;
|
final String durasi;
|
||||||
final String keterangan;
|
final String keterangan;
|
||||||
final bool aktif;
|
final bool aktif;
|
||||||
|
final String? tipeJadwal; // 'pagi' atau 'sore'
|
||||||
|
|
||||||
Penjadwalan({
|
Penjadwalan({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -15,6 +16,7 @@ class Penjadwalan {
|
||||||
required this.durasi,
|
required this.durasi,
|
||||||
required this.keterangan,
|
required this.keterangan,
|
||||||
required this.aktif,
|
required this.aktif,
|
||||||
|
this.tipeJadwal,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Penjadwalan.fromJson(Map<String, dynamic> json) {
|
factory Penjadwalan.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -26,6 +28,7 @@ class Penjadwalan {
|
||||||
durasi: json['durasi'] ?? '30 menit',
|
durasi: json['durasi'] ?? '30 menit',
|
||||||
keterangan: json['keterangan'] ?? '',
|
keterangan: json['keterangan'] ?? '',
|
||||||
aktif: json['aktif'] ?? true,
|
aktif: json['aktif'] ?? true,
|
||||||
|
tipeJadwal: json['tipeJadwal'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +41,7 @@ class Penjadwalan {
|
||||||
'durasi': durasi,
|
'durasi': durasi,
|
||||||
'keterangan': keterangan,
|
'keterangan': keterangan,
|
||||||
'aktif': aktif,
|
'aktif': aktif,
|
||||||
|
'tipeJadwal': tipeJadwal,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
import 'package:firebase_database/firebase_database.dart';
|
||||||
import '../models/panen_model.dart';
|
import '../models/panen_model.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
|
|
||||||
class PanenProvider extends ChangeNotifier {
|
class PanenProvider extends ChangeNotifier {
|
||||||
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||||
|
|
@ -12,9 +15,147 @@ class PanenProvider extends ChangeNotifier {
|
||||||
final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
||||||
|
|
||||||
final List<Panen> _panens = [];
|
final List<Panen> _panens = [];
|
||||||
|
final Set<String> _knownRealtimeRecordKeys = <String>{};
|
||||||
|
StreamSubscription<DatabaseEvent>? _recordsRealtimeSubscription;
|
||||||
|
bool _realtimeListenerStarted = false;
|
||||||
|
|
||||||
List<Panen> get panens => _panens;
|
List<Panen> get panens => _panens;
|
||||||
|
|
||||||
|
bool _isKandang1(String kandangId) {
|
||||||
|
final normalized = kandangId.toLowerCase().replaceAll('_', '');
|
||||||
|
return normalized == 'kandang1';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isKandang2(String kandangId) {
|
||||||
|
final normalized = kandangId.toLowerCase().replaceAll('_', '');
|
||||||
|
return normalized == 'kandang2';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _jenisFromPanen(Panen panen) {
|
||||||
|
final jenis = (panen.jenisPanen ?? '').trim().toLowerCase();
|
||||||
|
if (jenis == 'pagi' || jenis == 'sore') return jenis;
|
||||||
|
|
||||||
|
// Parse jam untuk klasifikasi: < 12 adalah pagi, >= 12 adalah sore
|
||||||
|
final jam = panen.jam.trim();
|
||||||
|
final jamParts = jam.split(':');
|
||||||
|
if (jamParts.isNotEmpty) {
|
||||||
|
final hour = int.tryParse(jamParts[0]);
|
||||||
|
if (hour != null) {
|
||||||
|
return hour < 12 ? 'pagi' : 'sore';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'panen';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSameDate(DateTime a, DateTime b) {
|
||||||
|
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _notifySummaryForJenis(Panen latestPanen) async {
|
||||||
|
final jenis = _jenisFromPanen(latestPanen);
|
||||||
|
if (jenis != 'pagi' && jenis != 'sore') return;
|
||||||
|
|
||||||
|
int k1 = 0;
|
||||||
|
int k2 = 0;
|
||||||
|
for (final panen in _panens) {
|
||||||
|
if (!_isSameDate(panen.tanggalPanen, latestPanen.tanggalPanen)) continue;
|
||||||
|
if (_jenisFromPanen(panen) != jenis) continue;
|
||||||
|
|
||||||
|
if (_isKandang1(panen.kandangId)) {
|
||||||
|
k1 += panen.jumlahTelur;
|
||||||
|
} else if (_isKandang2(panen.kandangId)) {
|
||||||
|
k2 += panen.jumlahTelur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final totals = <String, int>{
|
||||||
|
'kandang 1': k1,
|
||||||
|
'kandang 2': k2,
|
||||||
|
};
|
||||||
|
|
||||||
|
await NotificationService.instance.showPanenSummaryNotification(
|
||||||
|
jenisPanen: jenis,
|
||||||
|
kandangTotals: totals,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startRealtimePanenNotifications() async {
|
||||||
|
if (_realtimeListenerStarted) return;
|
||||||
|
|
||||||
|
final ref = _database.ref('riwayat/records');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await ref.get();
|
||||||
|
if (snapshot.exists && snapshot.value is Map) {
|
||||||
|
final data = Map<dynamic, dynamic>.from(snapshot.value as Map);
|
||||||
|
for (final entry in data.entries) {
|
||||||
|
_knownRealtimeRecordKeys.add(entry.key.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Seed realtime panen keys gagal: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordsRealtimeSubscription = ref.onChildAdded.listen((event) async {
|
||||||
|
try {
|
||||||
|
final key = event.snapshot.key;
|
||||||
|
if (key == null || _knownRealtimeRecordKeys.contains(key)) return;
|
||||||
|
_knownRealtimeRecordKeys.add(key);
|
||||||
|
|
||||||
|
if (!event.snapshot.exists || event.snapshot.value is! Map) return;
|
||||||
|
|
||||||
|
final panenData =
|
||||||
|
Map<dynamic, dynamic>.from(event.snapshot.value as Map);
|
||||||
|
final tanggalRaw = panenData['tanggal_panen'];
|
||||||
|
final tanggal = DateTime.tryParse('${tanggalRaw ?? ''}');
|
||||||
|
if (tanggal == null) return;
|
||||||
|
|
||||||
|
final newPanen = Panen(
|
||||||
|
id: panenData['id'] ?? '',
|
||||||
|
kandangId: panenData['kandang_id'] ?? '',
|
||||||
|
kandangNama: panenData['kandang_nama'] ?? '',
|
||||||
|
jumlahTelur: panenData['jumlah_telur'] ?? 0,
|
||||||
|
tanggalPanen: tanggal,
|
||||||
|
jam: panenData['jam'] ?? '',
|
||||||
|
catatan: panenData['catatan'] ?? '',
|
||||||
|
jenisPanen: panenData['jenis_panen'],
|
||||||
|
sensorSnapshot: panenData['sensor_snapshot'],
|
||||||
|
panenSebelumnya: panenData['panen_sebelumnya'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final exists =
|
||||||
|
_panens.any((p) => p.id == newPanen.id && p.id.isNotEmpty);
|
||||||
|
if (!exists) {
|
||||||
|
_panens.add(newPanen);
|
||||||
|
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifySummaryForJenis(newPanen);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Realtime panen notification error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_realtimeListenerStarted = true;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('✅ Realtime panen notification listener started');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopRealtimePanenNotifications() async {
|
||||||
|
await _recordsRealtimeSubscription?.cancel();
|
||||||
|
_recordsRealtimeSubscription = null;
|
||||||
|
_realtimeListenerStarted = false;
|
||||||
|
_knownRealtimeRecordKeys.clear();
|
||||||
|
}
|
||||||
|
|
||||||
List<Panen> getPanenByKandang(String kandangId) {
|
List<Panen> getPanenByKandang(String kandangId) {
|
||||||
return _panens.where((p) => p.kandangId == kandangId).toList();
|
return _panens.where((p) => p.kandangId == kandangId).toList();
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +175,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
void addPanen(
|
Panen addPanen(
|
||||||
String kandangId,
|
String kandangId,
|
||||||
String kandangNama,
|
String kandangNama,
|
||||||
int jumlahTelur,
|
int jumlahTelur,
|
||||||
|
|
@ -55,6 +196,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_panens.add(newPanen);
|
_panens.add(newPanen);
|
||||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return newPanen;
|
||||||
}
|
}
|
||||||
|
|
||||||
void deletePanen(String id) {
|
void deletePanen(String id) {
|
||||||
|
|
@ -109,7 +251,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add panen dari sensor snapshot dengan tracking jenis panen (pagi/sore)
|
/// Add panen dari sensor snapshot dengan tracking jenis panen (pagi/sore)
|
||||||
void addPanenFromSensor(
|
Panen addPanenFromSensor(
|
||||||
String kandangId,
|
String kandangId,
|
||||||
String kandangNama,
|
String kandangNama,
|
||||||
int sensorValue,
|
int sensorValue,
|
||||||
|
|
@ -141,6 +283,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_panens.add(newPanen);
|
_panens.add(newPanen);
|
||||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return newPanen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -179,7 +322,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
|
|
||||||
/// AUTO-CAPTURE DARI SCHEDULER
|
/// AUTO-CAPTURE DARI SCHEDULER
|
||||||
/// Panggil ini saat scheduler trigger jam 09:00
|
/// Panggil ini saat scheduler trigger jam 09:00
|
||||||
Future<void> captureScheduledPanenPagi(
|
Future<Panen?> captureScheduledPanenPagi(
|
||||||
String kandangId,
|
String kandangId,
|
||||||
String kandangNama,
|
String kandangNama,
|
||||||
int sensorValue,
|
int sensorValue,
|
||||||
|
|
@ -190,7 +333,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ Panen pagi untuk $kandangId sudah di-record hari ini');
|
print('⚠️ Panen pagi untuk $kandangId sudah di-record hari ini');
|
||||||
}
|
}
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time-Window Pagi: Ambil nilai sensor sebagai total telur pagi
|
// Time-Window Pagi: Ambil nilai sensor sebagai total telur pagi
|
||||||
|
|
@ -201,13 +344,16 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_snapshotPagiHariIni[kandangId] = sensorValue;
|
_snapshotPagiHariIni[kandangId] = sensorValue;
|
||||||
|
|
||||||
const uuid = Uuid();
|
const uuid = Uuid();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final jamStr =
|
||||||
|
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||||
final newPanen = Panen(
|
final newPanen = Panen(
|
||||||
id: uuid.v4(),
|
id: uuid.v4(),
|
||||||
kandangId: kandangId,
|
kandangId: kandangId,
|
||||||
kandangNama: kandangNama,
|
kandangNama: kandangNama,
|
||||||
jumlahTelur: jumlahTelur,
|
jumlahTelur: jumlahTelur,
|
||||||
tanggalPanen: DateTime.now(),
|
tanggalPanen: now,
|
||||||
jam: '09:00',
|
jam: jamStr,
|
||||||
catatan: 'Auto-capture PAGI dari scheduler',
|
catatan: 'Auto-capture PAGI dari scheduler',
|
||||||
jenisPanen: 'pagi',
|
jenisPanen: 'pagi',
|
||||||
sensorSnapshot: sensorValue,
|
sensorSnapshot: sensorValue,
|
||||||
|
|
@ -227,17 +373,19 @@ class PanenProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return newPanen;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('❌ Error capturing panen pagi: $e');
|
print('❌ Error capturing panen pagi: $e');
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AUTO-CAPTURE DARI SCHEDULER
|
/// AUTO-CAPTURE DARI SCHEDULER
|
||||||
/// Panggil ini saat scheduler trigger jam 15:00
|
/// Panggil ini saat scheduler trigger jam 15:00
|
||||||
/// Dengan delta calculation: nilai_sore - nilai_pagi
|
/// Dengan delta calculation: nilai_sore - nilai_pagi
|
||||||
Future<void> captureScheduledPanenSore(
|
Future<Panen?> captureScheduledPanenSore(
|
||||||
String kandangId,
|
String kandangId,
|
||||||
String kandangNama,
|
String kandangNama,
|
||||||
int sensorValue,
|
int sensorValue,
|
||||||
|
|
@ -248,7 +396,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ Panen sore untuk $kandangId sudah di-record hari ini');
|
print('⚠️ Panen sore untuk $kandangId sudah di-record hari ini');
|
||||||
}
|
}
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ambil nilai pagi hari ini
|
// Ambil nilai pagi hari ini
|
||||||
|
|
@ -270,13 +418,16 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_snapshotSoreHariIni[kandangId] = sensorValue;
|
_snapshotSoreHariIni[kandangId] = sensorValue;
|
||||||
|
|
||||||
const uuid = Uuid();
|
const uuid = Uuid();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final jamStr =
|
||||||
|
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||||
final newPanen = Panen(
|
final newPanen = Panen(
|
||||||
id: uuid.v4(),
|
id: uuid.v4(),
|
||||||
kandangId: kandangId,
|
kandangId: kandangId,
|
||||||
kandangNama: kandangNama,
|
kandangNama: kandangNama,
|
||||||
jumlahTelur: jumlahTelur,
|
jumlahTelur: jumlahTelur,
|
||||||
tanggalPanen: DateTime.now(),
|
tanggalPanen: now,
|
||||||
jam: '15:00',
|
jam: jamStr,
|
||||||
catatan:
|
catatan:
|
||||||
'Auto-capture SORE dari scheduler${nilaiPagi != null ? ' (delta: $sensorValue - $nilaiPagi)' : ''}',
|
'Auto-capture SORE dari scheduler${nilaiPagi != null ? ' (delta: $sensorValue - $nilaiPagi)' : ''}',
|
||||||
jenisPanen: 'sore',
|
jenisPanen: 'sore',
|
||||||
|
|
@ -299,10 +450,12 @@ class PanenProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return newPanen;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('❌ Error capturing panen sore: $e');
|
print('❌ Error capturing panen sore: $e');
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,4 +662,10 @@ class PanenProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_recordsRealtimeSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
|
|
||||||
List<Penjadwalan> get penjadwalans => _penjadwalans;
|
List<Penjadwalan> get penjadwalans => _penjadwalans;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isMaximalPenjadwalan => _penjadwalans.length >= 2;
|
||||||
|
|
||||||
/// Initialize provider dengan user ID dan load data dari Firebase
|
/// Initialize provider dengan user ID dan load data dari Firebase
|
||||||
Future<void> initializeWithUser(String userId) async {
|
Future<void> initializeWithUser(String userId) async {
|
||||||
|
|
@ -94,20 +95,22 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
|
|
||||||
await _penjadwalanRef.set({
|
await _penjadwalanRef.set({
|
||||||
'penjadwalan1': {
|
'penjadwalan1': {
|
||||||
'kandangId': 'kandang1',
|
'kandangId': 'global',
|
||||||
'kandangNama': 'Kandang 1',
|
'kandangNama': 'Semua Kandang',
|
||||||
'jam': '09:00',
|
'jam': '09:00',
|
||||||
'durasi': '30 menit',
|
'durasi': '30 menit',
|
||||||
'keterangan': 'Panen pagi',
|
'keterangan': 'Panen pagi',
|
||||||
'aktif': true,
|
'aktif': true,
|
||||||
|
'tipeJadwal': 'pagi',
|
||||||
},
|
},
|
||||||
'penjadwalan2': {
|
'penjadwalan2': {
|
||||||
'kandangId': 'kandang2',
|
'kandangId': 'global',
|
||||||
'kandangNama': 'Kandang 2',
|
'kandangNama': 'Semua Kandang',
|
||||||
'jam': '15:00',
|
'jam': '15:00',
|
||||||
'durasi': '30 menit',
|
'durasi': '30 menit',
|
||||||
'keterangan': 'Panen sore',
|
'keterangan': 'Panen sore',
|
||||||
'aktif': true,
|
'aktif': true,
|
||||||
|
'tipeJadwal': 'sore',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -142,9 +145,16 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
String kandangNama,
|
String kandangNama,
|
||||||
String jam,
|
String jam,
|
||||||
String durasi,
|
String durasi,
|
||||||
String keterangan,
|
String keterangan, {
|
||||||
) async {
|
String? tipeJadwal,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
// Cek apakah sudah mencapai batas maksimal 2 penjadwalan
|
||||||
|
if (_penjadwalans.length >= 2) {
|
||||||
|
print('Error: Maksimal penjadwalan adalah 2');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final newId = _getNextPenjadwalanKey();
|
final newId = _getNextPenjadwalanKey();
|
||||||
|
|
||||||
final newPenjadwalan = Penjadwalan(
|
final newPenjadwalan = Penjadwalan(
|
||||||
|
|
@ -155,6 +165,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
durasi: durasi,
|
durasi: durasi,
|
||||||
keterangan: keterangan,
|
keterangan: keterangan,
|
||||||
aktif: true,
|
aktif: true,
|
||||||
|
tipeJadwal: tipeJadwal,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _penjadwalanRef.child(newId).set(newPenjadwalan.toJson());
|
await _penjadwalanRef.child(newId).set(newPenjadwalan.toJson());
|
||||||
|
|
@ -182,6 +193,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
durasi: durasi,
|
durasi: durasi,
|
||||||
keterangan: keterangan,
|
keterangan: keterangan,
|
||||||
aktif: aktif,
|
aktif: aktif,
|
||||||
|
tipeJadwal: _penjadwalans[index].tipeJadwal,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _penjadwalanRef.child(id).set(updated.toJson());
|
await _penjadwalanRef.child(id).set(updated.toJson());
|
||||||
|
|
@ -213,6 +225,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
durasi: _penjadwalans[index].durasi,
|
durasi: _penjadwalans[index].durasi,
|
||||||
keterangan: _penjadwalans[index].keterangan,
|
keterangan: _penjadwalans[index].keterangan,
|
||||||
aktif: !_penjadwalans[index].aktif,
|
aktif: !_penjadwalans[index].aktif,
|
||||||
|
tipeJadwal: _penjadwalans[index].tipeJadwal,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _penjadwalanRef.child(id).set(updated.toJson());
|
await _penjadwalanRef.child(id).set(updated.toJson());
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ class DashboardPage extends StatelessWidget {
|
||||||
kandangKey: 'kandang2',
|
kandangKey: 'kandang2',
|
||||||
);
|
);
|
||||||
|
|
||||||
final kandang1TodayDisplay =
|
final kandang1TodayDisplay = telurProvider.kandang1HariIni != 0
|
||||||
telurProvider.kandang1HariIni != 0 ? telurProvider.kandang1HariIni : fallbackKandang1Today;
|
? telurProvider.kandang1HariIni
|
||||||
final kandang2TodayDisplay =
|
: fallbackKandang1Today;
|
||||||
telurProvider.kandang2HariIni != 0 ? telurProvider.kandang2HariIni : fallbackKandang2Today;
|
final kandang2TodayDisplay = telurProvider.kandang2HariIni != 0
|
||||||
|
? telurProvider.kandang2HariIni
|
||||||
|
: fallbackKandang2Today;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -559,15 +561,15 @@ class DashboardPage extends StatelessWidget {
|
||||||
final normalizedTarget = kandangKey.toLowerCase();
|
final normalizedTarget = kandangKey.toLowerCase();
|
||||||
|
|
||||||
return panenProvider.panens.where((panen) {
|
return panenProvider.panens.where((panen) {
|
||||||
final sameDate =
|
final sameDate = panen.tanggalPanen.year == now.year &&
|
||||||
panen.tanggalPanen.year == now.year &&
|
|
||||||
panen.tanggalPanen.month == now.month &&
|
panen.tanggalPanen.month == now.month &&
|
||||||
panen.tanggalPanen.day == now.day;
|
panen.tanggalPanen.day == now.day;
|
||||||
if (!sameDate) return false;
|
if (!sameDate) return false;
|
||||||
|
|
||||||
final idText = panen.kandangId.toLowerCase().replaceAll('_', '');
|
final idText = panen.kandangId.toLowerCase().replaceAll('_', '');
|
||||||
final namaText = panen.kandangNama.toLowerCase().replaceAll(' ', '');
|
final namaText = panen.kandangNama.toLowerCase().replaceAll(' ', '');
|
||||||
return idText.contains(normalizedTarget) || namaText.contains(normalizedTarget);
|
return idText.contains(normalizedTarget) ||
|
||||||
|
namaText.contains(normalizedTarget);
|
||||||
}).fold<int>(0, (sum, panen) => sum + panen.jumlahTelur);
|
}).fold<int>(0, (sum, panen) => sum + panen.jumlahTelur);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/penjadwalan_provider.dart';
|
import '../providers/penjadwalan_provider.dart';
|
||||||
|
|
@ -37,7 +38,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
child: Image.asset('assets/icons/telurku.png'),
|
child: Image.asset('assets/icons/telurku.png'),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'TelurKu',
|
'TEFA Unggas Polije',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey.shade800,
|
color: Colors.grey.shade800,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -120,9 +121,81 @@ class _HomePageState extends State<HomePage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (value == 1) {
|
||||||
|
Navigator.pushNamed(context, '/debug/panen');
|
||||||
|
} else if (value == 2) {
|
||||||
|
Navigator.pushNamed(context, '/debug/notifikasi');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
|
if (kDebugMode)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 1,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.blue.shade400,
|
||||||
|
Colors.blue.shade600,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.science,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Debug Panen',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (kDebugMode)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 2,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.green.shade400,
|
||||||
|
Colors.green.shade600,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.notifications,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Debug Notifikasi',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 0,
|
value: 0,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
itemCount: penjadwalanProvider.penjadwalans.length,
|
itemCount: penjadwalanProvider.penjadwalans.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final jadwal = penjadwalanProvider.penjadwalans[index];
|
final jadwal = penjadwalanProvider.penjadwalans[index];
|
||||||
|
final isSore = jadwal.tipeJadwal == 'sore';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
|
@ -170,12 +171,39 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
jadwal.jam,
|
children: [
|
||||||
style: const TextStyle(
|
Text(
|
||||||
fontSize: 18,
|
jadwal.jam,
|
||||||
fontWeight: FontWeight.bold,
|
style: const TextStyle(
|
||||||
),
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSore
|
||||||
|
? Colors.deepOrange.shade300
|
||||||
|
: Colors.amber.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isSore ? 'Sore' : 'Pagi',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isSore
|
||||||
|
? Colors.white
|
||||||
|
: Colors.brown.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -262,15 +290,25 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: Consumer<PenjadwalanProvider>(
|
||||||
onPressed: () => _showTambahJadwalBaru(context),
|
builder: (context, penjadwalanProvider, child) {
|
||||||
icon: const Icon(Icons.add),
|
final isDisabled = penjadwalanProvider.isMaximalPenjadwalan;
|
||||||
label: const Text('Tambah Jadwal Baru'),
|
return ElevatedButton.icon(
|
||||||
style: ElevatedButton.styleFrom(
|
onPressed: isDisabled
|
||||||
backgroundColor: Colors.green.shade600,
|
? () => _showPenjadwalanDisabledDialog(context)
|
||||||
foregroundColor: Colors.white,
|
: () => _showTambahJadwalBaru(context),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
icon: const Icon(Icons.add),
|
||||||
),
|
label: const Text('Tambah Jadwal Baru'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isDisabled
|
||||||
|
? Colors.grey.shade400
|
||||||
|
: Colors.green.shade600,
|
||||||
|
foregroundColor:
|
||||||
|
isDisabled ? Colors.grey.shade700 : Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -394,15 +432,25 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: Consumer<KandangProvider>(
|
||||||
onPressed: () => _showTambahKandangDialog(context),
|
builder: (context, kandangProvider, child) {
|
||||||
icon: const Icon(Icons.add),
|
final isDisabled = kandangProvider.kandangs.length >= 2;
|
||||||
label: const Text('Tambah Kandang Baru'),
|
return ElevatedButton.icon(
|
||||||
style: ElevatedButton.styleFrom(
|
onPressed: isDisabled
|
||||||
backgroundColor: Colors.green.shade600,
|
? () => _showKandangDisabledDialog(context)
|
||||||
foregroundColor: Colors.white,
|
: () => _showTambahKandangDialog(context),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
icon: const Icon(Icons.add),
|
||||||
),
|
label: const Text('Tambah Kandang Baru'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isDisabled
|
||||||
|
? Colors.grey.shade400
|
||||||
|
: Colors.green.shade600,
|
||||||
|
foregroundColor:
|
||||||
|
isDisabled ? Colors.grey.shade700 : Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -475,10 +523,20 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTambahJadwalBaru(BuildContext context) {
|
void _showTambahJadwalBaru(BuildContext context) {
|
||||||
|
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||||
final jamController = TextEditingController();
|
final jamController = TextEditingController();
|
||||||
final durasiController = TextEditingController();
|
final durasiController = TextEditingController();
|
||||||
final keteranganController = TextEditingController();
|
final keteranganController = TextEditingController();
|
||||||
|
|
||||||
|
// Tentukan tipe jadwal berdasarkan jumlah jadwal yang sudah ada
|
||||||
|
String tipeJadwal = 'pagi'; // Default pagi
|
||||||
|
String judulTipe = 'Pagi';
|
||||||
|
|
||||||
|
if (penjadwalanProvider.penjadwalans.isNotEmpty) {
|
||||||
|
tipeJadwal = 'sore';
|
||||||
|
judulTipe = 'Sore';
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
|
|
@ -486,6 +544,57 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tipeJadwal == 'sore'
|
||||||
|
? Colors.deepOrange.shade50
|
||||||
|
: Colors.amber.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
tipeJadwal == 'sore' ? Icons.nights_stay : Icons.wb_sunny,
|
||||||
|
color: tipeJadwal == 'sore'
|
||||||
|
? Colors.deepOrange.shade600
|
||||||
|
: Colors.amber.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Jadwal Panen $judulTipe',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: tipeJadwal == 'sore'
|
||||||
|
? Colors.deepOrange.shade700
|
||||||
|
: Colors.amber.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (tipeJadwal == 'sore') ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
border: Border.all(color: Colors.blue.shade200),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Info: Hasil panen sore akan otomatis direset setiap hari',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
TextField(
|
TextField(
|
||||||
controller: jamController,
|
controller: jamController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|
@ -527,6 +636,7 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
jamController.text,
|
jamController.text,
|
||||||
durasiController.text,
|
durasiController.text,
|
||||||
keteranganController.text,
|
keteranganController.text,
|
||||||
|
tipeJadwal: tipeJadwal,
|
||||||
);
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
@ -538,6 +648,44 @@ class _KontrolPageState extends State<KontrolPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showPenjadwalanDisabledDialog(BuildContext context) {
|
||||||
|
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Batas Penjadwalan Tercapai'),
|
||||||
|
content: Text(
|
||||||
|
'Anda sudah memiliki ${penjadwalanProvider.penjadwalans.length} jadwal panen. Batas maksimal adalah 2 jadwal (pagi dan sore). Silahkan hapus salah satu jadwal terlebih dahulu sebelum menambahkan jadwal baru.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showKandangDisabledDialog(BuildContext context) {
|
||||||
|
final kandangProvider = context.read<KandangProvider>();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Batas Kandang Tercapai'),
|
||||||
|
content: Text(
|
||||||
|
'Anda sudah memiliki ${kandangProvider.kandangs.length} kandang. Batas maksimal kandang adalah 2. Silahkan hubungi pembuat aplikasi untuk menambahkan kandang lebih dari 2.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showTambahKandangDialog(BuildContext context) {
|
void _showTambahKandangDialog(BuildContext context) {
|
||||||
final namaController = TextEditingController();
|
final namaController = TextEditingController();
|
||||||
final jumlahAyamController = TextEditingController();
|
final jumlahAyamController = TextEditingController();
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,19 @@ class LandingPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
// App Name
|
// App Name
|
||||||
Text(
|
SizedBox(
|
||||||
'TelurKu',
|
width: double.infinity,
|
||||||
style: TextStyle(
|
child: FittedBox(
|
||||||
fontSize: 48,
|
fit: BoxFit.scaleDown,
|
||||||
fontWeight: FontWeight.bold,
|
child: Text(
|
||||||
color: Colors.orange.shade700,
|
'TEFA UNGGAS POLIJE',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
|
||||||
|
|
@ -200,31 +200,6 @@ class _LoginPageState extends State<LoginPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Forget Password Link
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
// TODO: Navigate to forgot password page
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Fitur ini akan segera tersedia',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Lupa Password?',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.orange.shade600,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
// Login Button
|
// Login Button
|
||||||
Consumer<AuthProvider>(
|
Consumer<AuthProvider>(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
|
|
||||||
|
class NotificationDebugScreen extends StatefulWidget {
|
||||||
|
const NotificationDebugScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NotificationDebugScreen> createState() =>
|
||||||
|
_NotificationDebugScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationDebugScreenState extends State<NotificationDebugScreen> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool? _canScheduleExactNotifications;
|
||||||
|
String? _lastStatus;
|
||||||
|
List<PendingNotificationRequest> _pendingNotifications = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPendingNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setStatus(String message) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_lastStatus = message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPendingNotifications() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await NotificationService.instance.initialize();
|
||||||
|
final canScheduleExact =
|
||||||
|
await NotificationService.instance.canScheduleExactNotifications();
|
||||||
|
final pending = await NotificationService.instance.pendingNotifications();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingNotifications = pending;
|
||||||
|
_canScheduleExactNotifications = canScheduleExact;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_lastStatus = 'Gagal memuat notifikasi tertunda: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendSamplePanenPagi() async {
|
||||||
|
await _runAction(() async {
|
||||||
|
await NotificationService.instance.showPanenSummaryNotification(
|
||||||
|
jenisPanen: 'pagi',
|
||||||
|
kandangTotals: const {
|
||||||
|
'kandang 1': 15,
|
||||||
|
'kandang 2': 15,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, 'Notif panen pagi terkirim');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendSamplePanenSore() async {
|
||||||
|
await _runAction(() async {
|
||||||
|
await NotificationService.instance.showPanenSummaryNotification(
|
||||||
|
jenisPanen: 'sore',
|
||||||
|
kandangTotals: const {
|
||||||
|
'kandang 1': 12,
|
||||||
|
'kandang 2': 8,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, 'Notif panen sore terkirim');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendImmediateSimple() async {
|
||||||
|
await _runAction(() async {
|
||||||
|
await NotificationService.instance.showInstantNotification(
|
||||||
|
title: 'Tes Notifikasi',
|
||||||
|
body: 'Notifikasi lokal berhasil dikirim dari halaman debug.',
|
||||||
|
payload: 'debug:immediate',
|
||||||
|
);
|
||||||
|
}, 'Notif langsung terkirim');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scheduleThirtySecondNotification() async {
|
||||||
|
await _runAction(() async {
|
||||||
|
await NotificationService.instance.scheduleNotification(
|
||||||
|
title: 'Tes Notifikasi Terjadwal',
|
||||||
|
body:
|
||||||
|
'Notif ini dijadwalkan 30 detik sebelumnya. Coba minimize atau tutup app setelah dijadwalkan.',
|
||||||
|
delay: const Duration(seconds: 30),
|
||||||
|
prioritizeBackgroundDelivery: true,
|
||||||
|
payload: 'debug:scheduled_30s',
|
||||||
|
);
|
||||||
|
}, 'Notif 30 detik dijadwalkan');
|
||||||
|
|
||||||
|
await _loadPendingNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestExactAlarmPermission() async {
|
||||||
|
await _runAction(() async {
|
||||||
|
final granted =
|
||||||
|
await NotificationService.instance.requestExactAlarmsPermission();
|
||||||
|
if (!granted) {
|
||||||
|
throw 'Izin exact alarm belum diberikan. Aktifkan dari settings aplikasi.';
|
||||||
|
}
|
||||||
|
}, 'Izin exact alarm berhasil diminta');
|
||||||
|
|
||||||
|
await _loadPendingNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelAllNotifications() async {
|
||||||
|
await _runAction(() async {
|
||||||
|
await NotificationService.instance.cancelAll();
|
||||||
|
}, 'Semua notifikasi dibatalkan');
|
||||||
|
|
||||||
|
await _loadPendingNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runAction(
|
||||||
|
Future<void> Function() action, String successText) async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await NotificationService.instance.initialize();
|
||||||
|
await action();
|
||||||
|
await _setStatus(successText);
|
||||||
|
} catch (e) {
|
||||||
|
await _setStatus('Error: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Debug Notifikasi'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isLoading ? null : _loadPendingNotifications,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildInfoCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildActionCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPendingCard(),
|
||||||
|
if (_lastStatus != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildStatusCard(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard() {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Cara kerja notifikasi',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'• Notifikasi langsung akan muncul saat app masih aktif atau berjalan di background.\n'
|
||||||
|
'• Notifikasi terjadwal tetap bisa muncul walaupun app diminimize atau ditutup, selama notif sudah dijadwalkan sebelum app berhenti.\n'
|
||||||
|
'• Kalau exact alarm tidak aktif, Android bisa menunda jadwal lokal. Gunakan tombol Aktifkan Exact Alarm agar notif 30 detik lebih presisi.\n'
|
||||||
|
'• Jika yang diminta adalah notif dari event server saat app benar-benar mati total, itu perlu push notification dari backend/FCM.',
|
||||||
|
style: TextStyle(fontSize: 13, height: 1.4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionCard() {
|
||||||
|
return Card(
|
||||||
|
elevation: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Aksi Debug',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _sendSamplePanenPagi,
|
||||||
|
icon: const Icon(Icons.wb_sunny),
|
||||||
|
label: const Text('Simulasi Panen Pagi'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _sendSamplePanenSore,
|
||||||
|
icon: const Icon(Icons.wb_twilight),
|
||||||
|
label: const Text('Simulasi Panen Sore'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _sendImmediateSimple,
|
||||||
|
icon: const Icon(Icons.notifications_active),
|
||||||
|
label: const Text('Kirim Notifikasi Langsung'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _scheduleThirtySecondNotification,
|
||||||
|
icon: const Icon(Icons.schedule),
|
||||||
|
label: const Text('Jadwalkan Notif 30 Detik'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _requestExactAlarmPermission,
|
||||||
|
icon: const Icon(Icons.alarm_on),
|
||||||
|
label: const Text('Aktifkan Exact Alarm'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _cancelAllNotifications,
|
||||||
|
icon: const Icon(Icons.cancel),
|
||||||
|
label: const Text('Batalkan Semua Notifikasi'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPendingCard() {
|
||||||
|
return Card(
|
||||||
|
elevation: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Notifikasi Tertunda',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Exact alarm: ${_canScheduleExactNotifications == null ? 'memuat...' : (_canScheduleExactNotifications! ? 'aktif' : 'belum aktif')}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: _canScheduleExactNotifications == true
|
||||||
|
? Colors.green.shade700
|
||||||
|
: Colors.red.shade700,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_pendingNotifications.isEmpty)
|
||||||
|
const Text('Belum ada notifikasi terjadwal.')
|
||||||
|
else
|
||||||
|
..._pendingNotifications.map(
|
||||||
|
(notification) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(notification.title ?? '(tanpa judul)'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(notification.body ?? '(tanpa isi)'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'ID: ${notification.id}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusCard() {
|
||||||
|
final success = _lastStatus!.contains('terkirim') ||
|
||||||
|
_lastStatus!.contains('dijadwalkan') ||
|
||||||
|
_lastStatus!.contains('dibatalkan');
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: success ? Colors.green.shade50 : Colors.red.shade50,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
_lastStatus!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: success ? Colors.green.shade800 : Colors.red.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/kandang_provider.dart';
|
||||||
|
import '../providers/panen_provider.dart';
|
||||||
|
import '../services/panen_auto_capture_service.dart';
|
||||||
import '../services/panen_scheduler.dart';
|
import '../services/panen_scheduler.dart';
|
||||||
|
|
||||||
/// Debug Screen untuk testing Panen Scheduler
|
/// Debug Screen untuk testing Panen Scheduler
|
||||||
|
|
@ -57,21 +61,55 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await PanenScheduler.triggerPanenPagiManual();
|
final panenProvider = context.read<PanenProvider>();
|
||||||
|
final kandangProvider = context.read<KandangProvider>();
|
||||||
|
|
||||||
|
await PanenAutoCaptureService.triggerMorningCapture(
|
||||||
|
panenProvider,
|
||||||
|
kandangProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
final today = DateTime.now();
|
||||||
|
final todayPagi = panenProvider.panens.where((p) {
|
||||||
|
return p.jenisPanen == 'pagi' &&
|
||||||
|
p.tanggalPanen.year == today.year &&
|
||||||
|
p.tanggalPanen.month == today.month &&
|
||||||
|
p.tanggalPanen.day == today.day;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final total = todayPagi.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
|
final k1 = todayPagi
|
||||||
|
.where(
|
||||||
|
(p) =>
|
||||||
|
p.kandangId.toLowerCase() == 'kandang_1' ||
|
||||||
|
p.kandangId.toLowerCase() == 'kandang1',
|
||||||
|
)
|
||||||
|
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
|
final k2 = todayPagi
|
||||||
|
.where(
|
||||||
|
(p) =>
|
||||||
|
p.kandangId.toLowerCase() == 'kandang_2' ||
|
||||||
|
p.kandangId.toLowerCase() == 'kandang2',
|
||||||
|
)
|
||||||
|
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
|
|
||||||
|
final bool success = todayPagi.isNotEmpty;
|
||||||
|
final String message = success
|
||||||
|
? 'Panen Pagi berhasil diproses (total $total | kandang 1: $k1 | kandang 2: $k2)'
|
||||||
|
: 'Belum ada data panen pagi yang tersimpan';
|
||||||
|
|
||||||
await _loadData(); // Reload snapshot
|
await _loadData(); // Reload snapshot
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_lastResult = result['success']
|
_lastResult = success ? '✅ $message' : '❌ $message';
|
||||||
? '✅ ${result['message']}'
|
|
||||||
: '❌ ${result['message']}';
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(result['message']),
|
content: Text(message),
|
||||||
backgroundColor: result['success'] ? Colors.green : Colors.red,
|
backgroundColor: success ? Colors.green : Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -90,21 +128,55 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await PanenScheduler.triggerPanenSoreManual();
|
final panenProvider = context.read<PanenProvider>();
|
||||||
|
final kandangProvider = context.read<KandangProvider>();
|
||||||
|
|
||||||
|
await PanenAutoCaptureService.triggerAfternoonCapture(
|
||||||
|
panenProvider,
|
||||||
|
kandangProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
final today = DateTime.now();
|
||||||
|
final todaySore = panenProvider.panens.where((p) {
|
||||||
|
return p.jenisPanen == 'sore' &&
|
||||||
|
p.tanggalPanen.year == today.year &&
|
||||||
|
p.tanggalPanen.month == today.month &&
|
||||||
|
p.tanggalPanen.day == today.day;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final total = todaySore.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
|
final k1 = todaySore
|
||||||
|
.where(
|
||||||
|
(p) =>
|
||||||
|
p.kandangId.toLowerCase() == 'kandang_1' ||
|
||||||
|
p.kandangId.toLowerCase() == 'kandang1',
|
||||||
|
)
|
||||||
|
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
|
final k2 = todaySore
|
||||||
|
.where(
|
||||||
|
(p) =>
|
||||||
|
p.kandangId.toLowerCase() == 'kandang_2' ||
|
||||||
|
p.kandangId.toLowerCase() == 'kandang2',
|
||||||
|
)
|
||||||
|
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
|
|
||||||
|
final bool success = todaySore.isNotEmpty;
|
||||||
|
final String message = success
|
||||||
|
? 'Panen Sore berhasil diproses (total $total | kandang 1: $k1 | kandang 2: $k2)'
|
||||||
|
: 'Belum ada data panen sore yang tersimpan';
|
||||||
|
|
||||||
await _loadData(); // Reload snapshot
|
await _loadData(); // Reload snapshot
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_lastResult = result['success']
|
_lastResult = success ? '✅ $message' : '❌ $message';
|
||||||
? '✅ ${result['message']}'
|
|
||||||
: '❌ ${result['message']}';
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(result['message']),
|
content: Text(message),
|
||||||
backgroundColor: result['success'] ? Colors.green : Colors.red,
|
backgroundColor: success ? Colors.green : Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/panen_model.dart';
|
||||||
import '../providers/panen_provider.dart';
|
import '../providers/panen_provider.dart';
|
||||||
|
import '../services/excel_export_service.dart';
|
||||||
|
|
||||||
class RiwayatPage extends StatefulWidget {
|
class RiwayatPage extends StatefulWidget {
|
||||||
const RiwayatPage({super.key});
|
const RiwayatPage({super.key});
|
||||||
|
|
@ -169,24 +171,71 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
LayoutBuilder(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
builder: (context, constraints) {
|
||||||
children: [
|
final compact = constraints.maxWidth < 520;
|
||||||
Text(
|
final updateText = _lastRefreshAt != null
|
||||||
_lastRefreshAt != null
|
? 'Update: ${DateFormat('dd/MM/yyyy HH:mm').format(_lastRefreshAt!)}'
|
||||||
? 'Update: ${DateFormat('dd/MM/yyyy HH:mm').format(_lastRefreshAt!)}'
|
: 'Belum pernah refresh';
|
||||||
: 'Belum pernah refresh',
|
|
||||||
style: TextStyle(
|
final actionButtons = Wrap(
|
||||||
fontSize: 12,
|
spacing: 8,
|
||||||
color: Colors.grey.shade600,
|
runSpacing: 8,
|
||||||
),
|
children: [
|
||||||
),
|
TextButton.icon(
|
||||||
TextButton.icon(
|
onPressed: _handleRefresh,
|
||||||
onPressed: _handleRefresh,
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
icon: const Icon(Icons.refresh, size: 16),
|
label: const Text('Refresh'),
|
||||||
label: const Text('Refresh'),
|
),
|
||||||
),
|
ElevatedButton.icon(
|
||||||
],
|
onPressed: _downloadExcelReport,
|
||||||
|
icon: const Icon(Icons.download, size: 16),
|
||||||
|
label: const Text('Download Excel'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green.shade600,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
updateText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
actionButtons,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
updateText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
actionButtons,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -204,7 +253,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
final panenProvider = context.watch<PanenProvider>();
|
final panenProvider = context.watch<PanenProvider>();
|
||||||
|
|
||||||
final panens = _getFilteredPanens(panenProvider);
|
final panens = _getFilteredPanens(panenProvider);
|
||||||
final groupedPanens = <DateTime, List<dynamic>>{};
|
final groupedPanens = <DateTime, List<Panen>>{};
|
||||||
for (final panen in panens) {
|
for (final panen in panens) {
|
||||||
final key = DateTime(
|
final key = DateTime(
|
||||||
panen.tanggalPanen.year,
|
panen.tanggalPanen.year,
|
||||||
|
|
@ -221,8 +270,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
}
|
}
|
||||||
|
|
||||||
final totalTelur =
|
final totalTelur = panens.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
panens.fold<int>(0, (sum, p) => sum + (p.jumlahTelur as int));
|
|
||||||
|
|
||||||
final listChildren = <Widget>[
|
final listChildren = <Widget>[
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -328,8 +376,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
} else {
|
} else {
|
||||||
for (final date in sortedDates) {
|
for (final date in sortedDates) {
|
||||||
final items = groupedPanens[date]!;
|
final items = groupedPanens[date]!;
|
||||||
final dailyTotal =
|
final dailyTotal = items.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||||
items.fold<int>(0, (sum, p) => sum + (p.jumlahTelur as int));
|
|
||||||
|
|
||||||
listChildren.add(
|
listChildren.add(
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -350,25 +397,12 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
DateFormat('dd MMMM yyyy').format(date),
|
||||||
children: [
|
style: const TextStyle(
|
||||||
Text(
|
fontSize: 15,
|
||||||
DateFormat('dd MMMM yyyy').format(date),
|
fontWeight: FontWeight.bold,
|
||||||
style: const TextStyle(
|
),
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'$dailyTotal telur',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.orange.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
...items.map(
|
...items.map(
|
||||||
|
|
@ -415,6 +449,27 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Total telur hari ini: $dailyTotal',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -433,8 +488,8 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<dynamic> _getFilteredPanens(PanenProvider panenProvider) {
|
List<Panen> _getFilteredPanens(PanenProvider panenProvider) {
|
||||||
final allPanens = List<dynamic>.from(panenProvider.panens);
|
final allPanens = List<Panen>.from(panenProvider.panens);
|
||||||
final startDate = _selectedStartDate;
|
final startDate = _selectedStartDate;
|
||||||
final endDate = _selectedEndDate;
|
final endDate = _selectedEndDate;
|
||||||
|
|
||||||
|
|
@ -504,4 +559,95 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadExcelReport() async {
|
||||||
|
if (_selectedStartDate == null || _selectedEndDate == null) {
|
||||||
|
_showErrorMessage('Pilih tanggal mulai dan akhir terlebih dahulu');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedStartDate!.isAfter(_selectedEndDate!)) {
|
||||||
|
_showErrorMessage(
|
||||||
|
'Tanggal mulai tidak boleh lebih besar dari tanggal akhir');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_showLoadingDialog('Membuat laporan Excel...');
|
||||||
|
|
||||||
|
final panenProvider = context.read<PanenProvider>();
|
||||||
|
final panens = _getFilteredPanens(panenProvider);
|
||||||
|
|
||||||
|
final filePath = await ExcelExportService.exportWeeklyReport(
|
||||||
|
panens: panens,
|
||||||
|
startDate: _selectedStartDate!,
|
||||||
|
endDate: _selectedEndDate!,
|
||||||
|
kandangFilter: _selectedKandang,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop(); // Close loading dialog
|
||||||
|
|
||||||
|
_showSuccessMessage('Laporan berhasil dibuat:\n$filePath');
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop(); // Close loading dialog
|
||||||
|
_showErrorMessage('Gagal membuat laporan: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLoadingDialog(String message) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(message),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuccessMessage(String message) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('✓ Sukses'),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorMessage(String message) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('✗ Error'),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import 'package:excel/excel.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../models/panen_model.dart';
|
||||||
|
import 'excel_save_helper.dart';
|
||||||
|
|
||||||
|
class ExcelExportService {
|
||||||
|
static Future<String> exportWeeklyReport({
|
||||||
|
required List<Panen> panens,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
required String kandangFilter,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
print('[ExcelExportService] Starting export...');
|
||||||
|
|
||||||
|
final bytes = _generateExcelContent(
|
||||||
|
panens,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
kandangFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final fileName = 'Laporan_Telur_${_formatDate(now)}.xlsx';
|
||||||
|
|
||||||
|
final savedLocation = await saveReportFile(
|
||||||
|
fileName: fileName,
|
||||||
|
bytes: bytes,
|
||||||
|
mimeType:
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
);
|
||||||
|
|
||||||
|
print('[ExcelExportService] Export completed successfully');
|
||||||
|
return savedLocation;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
print('[ExcelExportService] ERROR: $e');
|
||||||
|
print('[ExcelExportService] Stack trace: $stackTrace');
|
||||||
|
throw Exception('Error creating report: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatDate(DateTime date) {
|
||||||
|
final day = date.day.toString().padLeft(2, '0');
|
||||||
|
final month = date.month.toString().padLeft(2, '0');
|
||||||
|
final year = date.year;
|
||||||
|
return '$day-$month-$year';
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _generateExcelContent(
|
||||||
|
List<Panen> panens,
|
||||||
|
DateTime startDate,
|
||||||
|
DateTime endDate,
|
||||||
|
String kandangFilter,
|
||||||
|
) {
|
||||||
|
final excel = Excel.createExcel();
|
||||||
|
const sheetName = 'Laporan';
|
||||||
|
final defaultSheet = excel.getDefaultSheet();
|
||||||
|
if (defaultSheet != null && defaultSheet != sheetName) {
|
||||||
|
excel.rename(defaultSheet, sheetName);
|
||||||
|
}
|
||||||
|
final sheet = excel[sheetName];
|
||||||
|
|
||||||
|
final boldStyle = CellStyle(bold: true);
|
||||||
|
|
||||||
|
CellIndex idx(int row, int col) =>
|
||||||
|
CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row);
|
||||||
|
|
||||||
|
void writeText(int row, int col, String value, {bool bold = false}) {
|
||||||
|
sheet.updateCell(
|
||||||
|
idx(row, col),
|
||||||
|
TextCellValue(value),
|
||||||
|
cellStyle: bold ? boldStyle : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeInt(int row, int col, int value, {bool bold = false}) {
|
||||||
|
sheet.updateCell(
|
||||||
|
idx(row, col),
|
||||||
|
IntCellValue(value),
|
||||||
|
cellStyle: bold ? boldStyle : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalKandang1Pagi = 0;
|
||||||
|
int totalKandang1Sore = 0;
|
||||||
|
int totalKandang2Pagi = 0;
|
||||||
|
int totalKandang2Sore = 0;
|
||||||
|
int totalOverall = 0;
|
||||||
|
final perHari = <DateTime, _DailyReport>{};
|
||||||
|
|
||||||
|
for (final panen in panens) {
|
||||||
|
final dateKey = DateTime(
|
||||||
|
panen.tanggalPanen.year,
|
||||||
|
panen.tanggalPanen.month,
|
||||||
|
panen.tanggalPanen.day,
|
||||||
|
);
|
||||||
|
|
||||||
|
final daily = perHari.putIfAbsent(dateKey, () => _DailyReport());
|
||||||
|
final jumlah = panen.jumlahTelur;
|
||||||
|
final normalized = _normalizeKandang(panen.kandangId, panen.kandangNama);
|
||||||
|
final jenis = _normalizeJenisPanen(panen);
|
||||||
|
|
||||||
|
if (normalized.contains('kandang1')) {
|
||||||
|
if (jenis == 'pagi') {
|
||||||
|
totalKandang1Pagi += jumlah;
|
||||||
|
daily.kandang1Pagi += jumlah;
|
||||||
|
} else if (jenis == 'sore') {
|
||||||
|
totalKandang1Sore += jumlah;
|
||||||
|
daily.kandang1Sore += jumlah;
|
||||||
|
}
|
||||||
|
} else if (normalized.contains('kandang2')) {
|
||||||
|
if (jenis == 'pagi') {
|
||||||
|
totalKandang2Pagi += jumlah;
|
||||||
|
daily.kandang2Pagi += jumlah;
|
||||||
|
} else if (jenis == 'sore') {
|
||||||
|
totalKandang2Sore += jumlah;
|
||||||
|
daily.kandang2Sore += jumlah;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalOverall += jumlah;
|
||||||
|
daily.total += jumlah;
|
||||||
|
}
|
||||||
|
|
||||||
|
final periodText =
|
||||||
|
'${DateFormat('dd/MM/yyyy').format(startDate)} - ${DateFormat('dd/MM/yyyy').format(endDate)}';
|
||||||
|
final filterText =
|
||||||
|
kandangFilter == 'semua' ? 'Semua Kandang' : kandangFilter;
|
||||||
|
|
||||||
|
writeText(0, 0, 'LAPORAN PRODUKSI TELUR', bold: true);
|
||||||
|
writeText(1, 0, 'Periode', bold: true);
|
||||||
|
writeText(1, 1, periodText);
|
||||||
|
writeText(2, 0, 'Filter Kandang', bold: true);
|
||||||
|
writeText(2, 1, filterText);
|
||||||
|
|
||||||
|
final totalKandang1 = totalKandang1Pagi + totalKandang1Sore;
|
||||||
|
final totalKandang2 = totalKandang2Pagi + totalKandang2Sore;
|
||||||
|
|
||||||
|
writeText(4, 0, 'Jumlah total telur kandang 1 (pagi)', bold: true);
|
||||||
|
writeInt(4, 1, totalKandang1Pagi, bold: true);
|
||||||
|
writeText(5, 0, 'Jumlah total telur kandang 1 (sore)', bold: true);
|
||||||
|
writeInt(5, 1, totalKandang1Sore, bold: true);
|
||||||
|
writeText(6, 0, 'Jumlah total telur kandang 1', bold: true);
|
||||||
|
writeInt(6, 1, totalKandang1, bold: true);
|
||||||
|
|
||||||
|
writeText(8, 0, 'Jumlah total telur kandang 2 (pagi)', bold: true);
|
||||||
|
writeInt(8, 1, totalKandang2Pagi, bold: true);
|
||||||
|
writeText(9, 0, 'Jumlah total telur kandang 2 (sore)', bold: true);
|
||||||
|
writeInt(9, 1, totalKandang2Sore, bold: true);
|
||||||
|
writeText(10, 0, 'Jumlah total telur kandang 2', bold: true);
|
||||||
|
writeInt(10, 1, totalKandang2, bold: true);
|
||||||
|
|
||||||
|
writeText(12, 0, 'Total keseluruhan', bold: true);
|
||||||
|
writeInt(12, 1, totalOverall, bold: true);
|
||||||
|
|
||||||
|
writeText(14, 0, 'Tanggal', bold: true);
|
||||||
|
writeText(14, 1, 'Kandang 1 Pagi', bold: true);
|
||||||
|
writeText(14, 2, 'Kandang 1 Sore', bold: true);
|
||||||
|
writeText(14, 3, 'Kandang 2 Pagi', bold: true);
|
||||||
|
writeText(14, 4, 'Kandang 2 Sore', bold: true);
|
||||||
|
writeText(14, 5, 'Total Telur Per Hari', bold: true);
|
||||||
|
|
||||||
|
final sortedDates = perHari.keys.toList()..sort((a, b) => a.compareTo(b));
|
||||||
|
var row = 15;
|
||||||
|
for (final date in sortedDates) {
|
||||||
|
final daily = perHari[date]!;
|
||||||
|
writeText(row, 0, DateFormat('dd/MM/yyyy').format(date));
|
||||||
|
writeInt(row, 1, daily.kandang1Pagi);
|
||||||
|
writeInt(row, 2, daily.kandang1Sore);
|
||||||
|
writeInt(row, 3, daily.kandang2Pagi);
|
||||||
|
writeInt(row, 4, daily.kandang2Sore);
|
||||||
|
writeInt(row, 5, daily.total);
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final encoded = excel.encode();
|
||||||
|
if (encoded == null) {
|
||||||
|
throw Exception('Gagal membuat file Excel');
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeKandang(String kandangId, String kandangNama) {
|
||||||
|
return '${kandangId.toLowerCase()} ${kandangNama.toLowerCase()}'
|
||||||
|
.replaceAll('_', '')
|
||||||
|
.replaceAll(' ', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeJenisPanen(Panen panen) {
|
||||||
|
final explicit = (panen.jenisPanen ?? '').trim().toLowerCase();
|
||||||
|
if (explicit == 'pagi' || explicit == 'sore') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final match = RegExp(r'^(\d{1,2})[:.]').firstMatch(panen.jam.trim());
|
||||||
|
final hour = match != null ? int.tryParse(match.group(1)!) : null;
|
||||||
|
if (hour != null) {
|
||||||
|
return hour < 12 ? 'pagi' : 'sore';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sore';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DailyReport {
|
||||||
|
int kandang1Pagi = 0;
|
||||||
|
int kandang1Sore = 0;
|
||||||
|
int kandang2Pagi = 0;
|
||||||
|
int kandang2Sore = 0;
|
||||||
|
int total = 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export 'excel_save_helper_io.dart'
|
||||||
|
if (dart.library.html) 'excel_save_helper_web.dart';
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:media_store_plus/media_store_plus.dart';
|
||||||
|
|
||||||
|
Future<String> saveReportFile({
|
||||||
|
required String fileName,
|
||||||
|
required List<int> bytes,
|
||||||
|
String? mimeType,
|
||||||
|
}) async {
|
||||||
|
final tempDir = Directory.systemTemp;
|
||||||
|
final tempFilePath = '${tempDir.path}/$fileName';
|
||||||
|
final tempFile = File(tempFilePath);
|
||||||
|
await tempFile.writeAsBytes(bytes, flush: true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await MediaStore.ensureInitialized();
|
||||||
|
MediaStore.appFolder = 'TelurKu';
|
||||||
|
final mediaStore = MediaStore();
|
||||||
|
|
||||||
|
final saveInfo = await mediaStore.saveFile(
|
||||||
|
tempFilePath: tempFilePath,
|
||||||
|
dirType: DirType.download,
|
||||||
|
dirName: DirName.download,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saveInfo != null) {
|
||||||
|
return 'Download/TelurKu/${saveInfo.name}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback to temp file when direct save is not available.
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFilePath;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import 'package:universal_html/html.dart' as html;
|
||||||
|
|
||||||
|
Future<String> saveReportFile({
|
||||||
|
required String fileName,
|
||||||
|
required List<int> bytes,
|
||||||
|
String? mimeType,
|
||||||
|
}) async {
|
||||||
|
final blob = html.Blob([
|
||||||
|
bytes,
|
||||||
|
], mimeType ?? 'application/octet-stream');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
|
||||||
|
final anchor = html.AnchorElement(href: url)
|
||||||
|
..setAttribute('download', fileName)
|
||||||
|
..style.display = 'none';
|
||||||
|
|
||||||
|
html.document.body?.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
|
||||||
|
return 'Download dimulai di browser ($fileName)';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
NotificationService._();
|
||||||
|
|
||||||
|
static final NotificationService instance = NotificationService._();
|
||||||
|
|
||||||
|
static const String _channelId = 'telurku_panen_channel';
|
||||||
|
static const String _channelName = 'Panen TelurKu';
|
||||||
|
static const String _channelDescription =
|
||||||
|
'Notifikasi panen dan debug TelurKu';
|
||||||
|
|
||||||
|
final FlutterLocalNotificationsPlugin _plugin =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
static const int _maxAndroidNotificationId = 2147483647;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
int _notificationCounter = 0;
|
||||||
|
|
||||||
|
int _stableIdFromKey(String key) {
|
||||||
|
var hash = 0;
|
||||||
|
for (final codeUnit in key.codeUnits) {
|
||||||
|
hash = 0x1fffffff & (hash + codeUnit);
|
||||||
|
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||||
|
hash ^= (hash >> 6);
|
||||||
|
}
|
||||||
|
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||||
|
hash ^= (hash >> 11);
|
||||||
|
hash = 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||||
|
return hash % _maxAndroidNotificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
if (kIsWeb) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tz.initializeTimeZones();
|
||||||
|
final localTimeZone = await FlutterTimezone.getLocalTimezone();
|
||||||
|
tz.setLocalLocation(tz.getLocation(localTimeZone));
|
||||||
|
|
||||||
|
const androidSettings = AndroidInitializationSettings(
|
||||||
|
'@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
const iosSettings = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: true,
|
||||||
|
requestBadgePermission: true,
|
||||||
|
requestSoundPermission: true,
|
||||||
|
defaultPresentAlert: true,
|
||||||
|
defaultPresentBadge: true,
|
||||||
|
defaultPresentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initSettings = InitializationSettings(
|
||||||
|
android: androidSettings,
|
||||||
|
iOS: iosSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _plugin.initialize(
|
||||||
|
initSettings,
|
||||||
|
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||||
|
);
|
||||||
|
await _setupNotificationChannel();
|
||||||
|
await _requestPermissions();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('✅ NotificationService initialized dengan channel panen');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('❌ NotificationService init failed: $e');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupNotificationChannel() async {
|
||||||
|
try {
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
|
||||||
|
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||||
|
_channelId,
|
||||||
|
_channelName,
|
||||||
|
description: _channelDescription,
|
||||||
|
importance: Importance.max,
|
||||||
|
playSound: true,
|
||||||
|
enableLights: true,
|
||||||
|
enableVibration: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await androidPlugin?.createNotificationChannel(channel);
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('✅ Notification channel telurku_panen_channel created');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Error setting notification channel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestPermissions() async {
|
||||||
|
try {
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
|
||||||
|
final notifPermission =
|
||||||
|
await androidPlugin?.requestNotificationsPermission() ?? false;
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
notifPermission
|
||||||
|
? '✅ Notification permission granted'
|
||||||
|
: '⚠️ Notification permission not granted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final exactAlarmPermission =
|
||||||
|
await androidPlugin?.requestExactAlarmsPermission() ?? false;
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(exactAlarmPermission
|
||||||
|
? '✅ Exact alarm permission granted'
|
||||||
|
: '⚠️ Exact alarm permission not granted');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Error requesting permissions: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationDetails _buildNotificationDetails() {
|
||||||
|
return const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
_channelId,
|
||||||
|
_channelName,
|
||||||
|
channelDescription: _channelDescription,
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
),
|
||||||
|
iOS: DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _nextId() {
|
||||||
|
// Android mewajibkan ID notifikasi dalam rentang signed 32-bit.
|
||||||
|
_notificationCounter = (_notificationCounter + 1) % 100000;
|
||||||
|
final nowPart = DateTime.now().millisecondsSinceEpoch % 2000000000;
|
||||||
|
final id = nowPart + _notificationCounter;
|
||||||
|
return id > _maxAndroidNotificationId ? id - _maxAndroidNotificationId : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showInstantNotification({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
String? payload,
|
||||||
|
}) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
await initialize();
|
||||||
|
await _plugin.show(
|
||||||
|
_nextId(),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
_buildNotificationDetails(),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scheduleNotification({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required Duration delay,
|
||||||
|
bool prioritizeBackgroundDelivery = false,
|
||||||
|
String? payload,
|
||||||
|
}) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
final scheduledDate = tz.TZDateTime.now(tz.local).add(delay);
|
||||||
|
final notificationId = _nextId();
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
final canScheduleExact =
|
||||||
|
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||||
|
|
||||||
|
if (!canScheduleExact) {
|
||||||
|
final permissionGranted =
|
||||||
|
await androidPlugin?.requestExactAlarmsPermission() ?? false;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
permissionGranted
|
||||||
|
? '✅ Exact alarm permission granted'
|
||||||
|
: '⚠️ Exact alarm permission not granted, using inexact fallback',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final useExactSchedule =
|
||||||
|
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||||
|
final preferredAndroidScheduleMode =
|
||||||
|
prioritizeBackgroundDelivery && useExactSchedule
|
||||||
|
? AndroidScheduleMode.alarmClock
|
||||||
|
: (useExactSchedule
|
||||||
|
? AndroidScheduleMode.exactAllowWhileIdle
|
||||||
|
: AndroidScheduleMode.inexactAllowWhileIdle);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
notificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
scheduledDate,
|
||||||
|
_buildNotificationDetails(),
|
||||||
|
androidScheduleMode: preferredAndroidScheduleMode,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code != 'exact_alarms_not_permitted') {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Exact alarm tidak diizinkan, fallback ke inexact alarm.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
notificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
scheduledDate,
|
||||||
|
_buildNotificationDetails(),
|
||||||
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scheduleDailyNotificationAtTime({
|
||||||
|
required String scheduleKey,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required int hour,
|
||||||
|
required int minute,
|
||||||
|
String? payload,
|
||||||
|
}) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
final scheduledDate = _nextTimeAt(hour, minute);
|
||||||
|
final notificationId = _stableIdFromKey(scheduleKey);
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
final canScheduleExactBeforeRequest =
|
||||||
|
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||||
|
|
||||||
|
if (!canScheduleExactBeforeRequest) {
|
||||||
|
await androidPlugin?.requestExactAlarmsPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
final canScheduleExact =
|
||||||
|
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||||
|
final preferredMode = canScheduleExact
|
||||||
|
? AndroidScheduleMode.alarmClock
|
||||||
|
: AndroidScheduleMode.inexactAllowWhileIdle;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'⏰ Daily reminder [$scheduleKey] -> ${scheduledDate.toLocal()} (mode: $preferredMode)');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
notificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
scheduledDate,
|
||||||
|
_buildNotificationDetails(),
|
||||||
|
androidScheduleMode: preferredMode,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
matchDateTimeComponents: DateTimeComponents.time,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code != 'exact_alarms_not_permitted') {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Daily reminder primary mode failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
notificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
scheduledDate,
|
||||||
|
_buildNotificationDetails(),
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
matchDateTimeComponents: DateTimeComponents.time,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
notificationId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
scheduledDate,
|
||||||
|
_buildNotificationDetails(),
|
||||||
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
matchDateTimeComponents: DateTimeComponents.time,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelScheduledNotification(String scheduleKey) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
try {
|
||||||
|
final notificationId = _stableIdFromKey(scheduleKey);
|
||||||
|
await _plugin.cancel(notificationId);
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'✅ Cancelled scheduled notification: $scheduleKey (ID: $notificationId)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ Error cancelling notification $scheduleKey: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tz.TZDateTime _nextTimeAt(int hour, int minute) {
|
||||||
|
final now = tz.TZDateTime.now(tz.local);
|
||||||
|
var scheduled =
|
||||||
|
tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
|
||||||
|
if (scheduled.isBefore(now)) {
|
||||||
|
scheduled = scheduled.add(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
return scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> canScheduleExactNotifications() async {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
await initialize();
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
return await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> requestExactAlarmsPermission() async {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
await initialize();
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
return await androidPlugin?.requestExactAlarmsPermission() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelAll() async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
await _plugin.cancelAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<PendingNotificationRequest>> pendingNotifications() async {
|
||||||
|
if (kIsWeb) return <PendingNotificationRequest>[];
|
||||||
|
return _plugin.pendingNotificationRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showPanenSummaryNotification({
|
||||||
|
required String jenisPanen,
|
||||||
|
required Map<String, int> kandangTotals,
|
||||||
|
}) async {
|
||||||
|
final normalizedJenis = jenisPanen.trim().toLowerCase();
|
||||||
|
final total =
|
||||||
|
kandangTotals.values.fold<int>(0, (sum, value) => sum + value);
|
||||||
|
final detailText = kandangTotals.entries
|
||||||
|
.map((entry) => '${entry.key} = ${entry.value}')
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
final title = 'Panen ${_capitalize(normalizedJenis)}';
|
||||||
|
final body =
|
||||||
|
'Panen ${normalizedJenis.isEmpty ? jenisPanen : normalizedJenis} telah dilakukan, didapatkan total telur $total${detailText.isNotEmpty ? ', $detailText' : ''}';
|
||||||
|
|
||||||
|
await showInstantNotification(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payload: 'panen:$normalizedJenis',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _capitalize(String value) {
|
||||||
|
if (value.isEmpty) return value;
|
||||||
|
return value[0].toUpperCase() + value.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onNotificationResponse(NotificationResponse response) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('🔔 Notification tapped: ${response.payload ?? ''}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,26 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
import 'package:firebase_database/firebase_database.dart';
|
||||||
import '../providers/panen_provider.dart';
|
import '../providers/panen_provider.dart';
|
||||||
import '../providers/kandang_provider.dart';
|
import '../providers/kandang_provider.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
/// Service untuk menghubungkan PanenScheduler dengan PanenProvider
|
/// Service untuk menghubungkan PanenScheduler dengan PanenProvider
|
||||||
/// Bertanggung jawab untuk trigger capture otomatis
|
/// Bertanggung jawab untuk trigger capture otomatis
|
||||||
class PanenAutoCaptureService {
|
class PanenAutoCaptureService {
|
||||||
static final FirebaseDatabase _database = FirebaseDatabase.instance;
|
static final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||||
|
|
||||||
|
static String _fallbackKandangName(String kandangId) {
|
||||||
|
if (kandangId == 'kandang_1') return 'Kandang 1';
|
||||||
|
if (kandangId == 'kandang_2') return 'Kandang 2';
|
||||||
|
return kandangId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _displayNameForNotification(String kandangId, String name) {
|
||||||
|
final lower = kandangId.toLowerCase();
|
||||||
|
if (lower == 'kandang_1' || lower == 'kandang1') return 'kandang 1';
|
||||||
|
if (lower == 'kandang_2' || lower == 'kandang2') return 'kandang 2';
|
||||||
|
return name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/// Trigger capture panen pagi (biasanya dipanggil scheduler jam 09:00)
|
/// Trigger capture panen pagi (biasanya dipanggil scheduler jam 09:00)
|
||||||
/// Membaca sensor value untuk SETIAP kandang dan trigger capture
|
/// Membaca sensor value untuk SETIAP kandang dan trigger capture
|
||||||
static Future<void> triggerMorningCapture(
|
static Future<void> triggerMorningCapture(
|
||||||
|
|
@ -34,25 +48,46 @@ class PanenAutoCaptureService {
|
||||||
// Tambah kandang lain sesuai kebutuhan
|
// Tambah kandang lain sesuai kebutuhan
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final capturedTotals = <String, int>{};
|
||||||
|
|
||||||
// Trigger capture untuk setiap kandang
|
// Trigger capture untuk setiap kandang
|
||||||
sensorMap.forEach((kandangId, sensorValue) {
|
for (final entry in sensorMap.entries) {
|
||||||
|
final kandangId = entry.key;
|
||||||
|
final sensorValue = entry.value;
|
||||||
|
|
||||||
// Get kandang nama dari KandangProvider
|
// Get kandang nama dari KandangProvider
|
||||||
try {
|
try {
|
||||||
final kandang = kandangProvider.kandangs.firstWhere(
|
final matched = kandangProvider.kandangs
|
||||||
(k) => k.id == kandangId,
|
.where((k) => k.id == kandangId)
|
||||||
);
|
.toList();
|
||||||
|
final kandangNama = matched.isNotEmpty
|
||||||
|
? matched.first.nama
|
||||||
|
: _fallbackKandangName(kandangId);
|
||||||
|
|
||||||
panenProvider.captureScheduledPanenPagi(
|
final panen = await panenProvider.captureScheduledPanenPagi(
|
||||||
kandangId,
|
kandangId,
|
||||||
kandang.nama,
|
kandangNama,
|
||||||
sensorValue as int,
|
sensorValue as int,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (panen != null) {
|
||||||
|
capturedTotals[
|
||||||
|
_displayNameForNotification(kandangId, kandangNama)] =
|
||||||
|
panen.jumlahTelur;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ Kandang $kandangId tidak found: $e');
|
print('⚠️ Kandang $kandangId tidak found: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (capturedTotals.isNotEmpty) {
|
||||||
|
await NotificationService.instance.showPanenSummaryNotification(
|
||||||
|
jenisPanen: 'pagi',
|
||||||
|
kandangTotals: capturedTotals,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ Morning capture triggered for all kandang');
|
print('✅ Morning capture triggered for all kandang');
|
||||||
|
|
@ -65,15 +100,18 @@ class PanenAutoCaptureService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger capture panen sore (biasanya dipanggil scheduler jam 15:00)
|
/// Trigger capture panen sore (biasanya dipanggil scheduler jam sore)
|
||||||
/// Membaca sensor value untuk SETIAP kandang dan hitung delta
|
/// Membaca sensor value untuk SETIAP kandang dan hitung delta
|
||||||
|
/// Setelah panen sore selesai, hasil akan otomatis direset
|
||||||
static Future<void> triggerAfternoonCapture(
|
static Future<void> triggerAfternoonCapture(
|
||||||
PanenProvider panenProvider,
|
PanenProvider panenProvider,
|
||||||
KandangProvider kandangProvider,
|
KandangProvider kandangProvider, {
|
||||||
) async {
|
bool isLastSchedule = false,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🟡 Triggering Afternoon Panen Capture');
|
print(
|
||||||
|
'🟡 Triggering Afternoon Panen Capture (isLastSchedule=$isLastSchedule)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baca sensor data dari Firebase
|
// Baca sensor data dari Firebase
|
||||||
|
|
@ -90,36 +128,58 @@ class PanenAutoCaptureService {
|
||||||
// Tambah kandang lain sesuai kebutuhan
|
// Tambah kandang lain sesuai kebutuhan
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final capturedTotals = <String, int>{};
|
||||||
|
|
||||||
// Trigger capture untuk setiap kandang (dengan delta)
|
// Trigger capture untuk setiap kandang (dengan delta)
|
||||||
sensorMap.forEach((kandangId, sensorValue) {
|
for (final entry in sensorMap.entries) {
|
||||||
|
final kandangId = entry.key;
|
||||||
|
final sensorValue = entry.value;
|
||||||
|
|
||||||
// Get kandang nama dari KandangProvider
|
// Get kandang nama dari KandangProvider
|
||||||
try {
|
try {
|
||||||
final kandang = kandangProvider.kandangs.firstWhere(
|
final matched = kandangProvider.kandangs
|
||||||
(k) => k.id == kandangId,
|
.where((k) => k.id == kandangId)
|
||||||
);
|
.toList();
|
||||||
|
final kandangNama = matched.isNotEmpty
|
||||||
|
? matched.first.nama
|
||||||
|
: _fallbackKandangName(kandangId);
|
||||||
|
|
||||||
panenProvider.captureScheduledPanenSore(
|
final panen = await panenProvider.captureScheduledPanenSore(
|
||||||
kandangId,
|
kandangId,
|
||||||
kandang.nama,
|
kandangNama,
|
||||||
sensorValue as int,
|
sensorValue as int,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (panen != null) {
|
||||||
|
capturedTotals[
|
||||||
|
_displayNameForNotification(kandangId, kandangNama)] =
|
||||||
|
panen.jumlahTelur;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('⚠️ Kandang $kandangId tidak found: $e');
|
print('⚠️ Kandang $kandangId tidak found: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (capturedTotals.isNotEmpty) {
|
||||||
|
await NotificationService.instance.showPanenSummaryNotification(
|
||||||
|
jenisPanen: 'sore',
|
||||||
|
kandangTotals: capturedTotals,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ Afternoon capture triggered for all kandang');
|
print('✅ Afternoon capture triggered for all kandang');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset snapshot harian setelah panen sore selesai
|
// Reset snapshot harian setelah panen sore selesai
|
||||||
// (Persiapan untuk hari berikutnya)
|
// Ini memastikan bahwa hasil panen sore akan otomatis direset untuk hari berikutnya
|
||||||
Future.delayed(const Duration(seconds: 5), () {
|
Future.delayed(const Duration(seconds: 5), () {
|
||||||
panenProvider.resetDailySnapshots();
|
panenProvider.resetDailySnapshots();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🔄 Daily snapshots reset after afternoon capture');
|
print(
|
||||||
|
'🔄 Daily snapshots reset after afternoon capture (panen sore)');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../providers/kandang_provider.dart';
|
||||||
|
import '../providers/panen_provider.dart';
|
||||||
|
import '../providers/penjadwalan_provider.dart';
|
||||||
|
import 'panen_auto_capture_service.dart';
|
||||||
|
|
||||||
|
/// Scheduler ringan di level aplikasi untuk memicu panen otomatis pagi/sore.
|
||||||
|
///
|
||||||
|
/// Catatan: ini berjalan selama proses app masih hidup (foreground/background).
|
||||||
|
/// Jika proses dibunuh OS, trigger akan berhenti sampai app dibuka lagi.
|
||||||
|
class PanenRuntimeSchedulerService {
|
||||||
|
PanenRuntimeSchedulerService._();
|
||||||
|
|
||||||
|
static Timer? _timer;
|
||||||
|
static bool _isRunning = false;
|
||||||
|
static bool _isProcessing = false;
|
||||||
|
static String? _lastPagiTriggerDate;
|
||||||
|
static final Map<int, String> _lastSoreTriggerDateByIndex =
|
||||||
|
{}; // Track per jadwal sore
|
||||||
|
|
||||||
|
static Future<void> start({
|
||||||
|
required PanenProvider panenProvider,
|
||||||
|
required KandangProvider kandangProvider,
|
||||||
|
required PenjadwalanProvider penjadwalanProvider,
|
||||||
|
}) async {
|
||||||
|
stop();
|
||||||
|
|
||||||
|
_isRunning = true;
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 30), (_) async {
|
||||||
|
await _tick(
|
||||||
|
panenProvider: panenProvider,
|
||||||
|
kandangProvider: kandangProvider,
|
||||||
|
penjadwalanProvider: penjadwalanProvider,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await _tick(
|
||||||
|
panenProvider: panenProvider,
|
||||||
|
kandangProvider: kandangProvider,
|
||||||
|
penjadwalanProvider: penjadwalanProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('✅ PanenRuntimeSchedulerService started (30s tick)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void stop() {
|
||||||
|
_isRunning = false;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _tick({
|
||||||
|
required PanenProvider panenProvider,
|
||||||
|
required KandangProvider kandangProvider,
|
||||||
|
required PenjadwalanProvider penjadwalanProvider,
|
||||||
|
}) async {
|
||||||
|
if (!_isRunning || _isProcessing) return;
|
||||||
|
|
||||||
|
_isProcessing = true;
|
||||||
|
try {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final dateKey =
|
||||||
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
final activeSchedules =
|
||||||
|
penjadwalanProvider.penjadwalans.where((j) => j.aktif).toList();
|
||||||
|
|
||||||
|
// Sort by ID urutan untuk memastikan penjadwalan1, penjadwalan2, dst
|
||||||
|
activeSchedules.sort((a, b) {
|
||||||
|
int numA = int.tryParse(a.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||||
|
int numB = int.tryParse(b.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||||
|
return numA.compareTo(numB);
|
||||||
|
});
|
||||||
|
|
||||||
|
int? morningHour;
|
||||||
|
int? morningMinute;
|
||||||
|
final afternoonSchedules = <(int index, int hour, int minute)>[];
|
||||||
|
|
||||||
|
// Jadwal pertama (penjadwalan1) = PAGI
|
||||||
|
// Jadwal ke-2+ (penjadwalan2, penjadwalan3, dst) = SORE
|
||||||
|
for (int i = 0; i < activeSchedules.length; i++) {
|
||||||
|
final schedule = activeSchedules[i];
|
||||||
|
final parsed = _parseJam(schedule.jam);
|
||||||
|
if (parsed == null) continue;
|
||||||
|
|
||||||
|
final hour = parsed.$1;
|
||||||
|
final minute = parsed.$2;
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
// Jadwal pertama = PAGI
|
||||||
|
morningHour = hour;
|
||||||
|
morningMinute = minute;
|
||||||
|
} else {
|
||||||
|
// Jadwal ke-2+ = SORE (simpan semua jadwal sore)
|
||||||
|
afternoonSchedules.add((i, hour, minute));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger pagi hanya jika ada jadwal pagi yang aktif
|
||||||
|
if (morningHour != null && morningMinute != null) {
|
||||||
|
final morningStart = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
morningHour,
|
||||||
|
morningMinute,
|
||||||
|
);
|
||||||
|
final morningEnd = morningStart.add(const Duration(minutes: 10));
|
||||||
|
final isMorningWindow =
|
||||||
|
!now.isBefore(morningStart) && !now.isAfter(morningEnd);
|
||||||
|
if (isMorningWindow && _lastPagiTriggerDate != dateKey) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'🕘 Trigger runtime panen pagi ($dateKey @ $morningHour:${morningMinute.toString().padLeft(2, '0')})');
|
||||||
|
}
|
||||||
|
await PanenAutoCaptureService.triggerMorningCapture(
|
||||||
|
panenProvider,
|
||||||
|
kandangProvider,
|
||||||
|
);
|
||||||
|
_lastPagiTriggerDate = dateKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger semua jadwal sore yang waktunya match
|
||||||
|
for (int schedIdx = 0; schedIdx < afternoonSchedules.length; schedIdx++) {
|
||||||
|
final (index, afternoonHour, afternoonMinute) =
|
||||||
|
afternoonSchedules[schedIdx];
|
||||||
|
final isLastSchedule = schedIdx == afternoonSchedules.length - 1;
|
||||||
|
|
||||||
|
final afternoonStart = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
afternoonHour,
|
||||||
|
afternoonMinute,
|
||||||
|
);
|
||||||
|
final afternoonEnd = afternoonStart.add(const Duration(minutes: 10));
|
||||||
|
final isAfternoonWindow =
|
||||||
|
!now.isBefore(afternoonStart) && !now.isAfter(afternoonEnd);
|
||||||
|
|
||||||
|
final lastTriggerDate = _lastSoreTriggerDateByIndex[index];
|
||||||
|
final shouldTrigger = isAfternoonWindow &&
|
||||||
|
(lastTriggerDate == null || lastTriggerDate != dateKey);
|
||||||
|
|
||||||
|
if (shouldTrigger) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'🕒 Trigger runtime panen sore #${index} ($dateKey @ $afternoonHour:${afternoonMinute.toString().padLeft(2, '0')})');
|
||||||
|
}
|
||||||
|
await PanenAutoCaptureService.triggerAfternoonCapture(
|
||||||
|
panenProvider,
|
||||||
|
kandangProvider,
|
||||||
|
isLastSchedule: isLastSchedule,
|
||||||
|
);
|
||||||
|
_lastSoreTriggerDateByIndex[index] = dateKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('❌ PanenRuntimeSchedulerService tick error: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static (int, int)? _parseJam(String jam) {
|
||||||
|
final parts = jam.split(':');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
final hour = int.tryParse(parts[0]);
|
||||||
|
final minute = int.tryParse(parts[1]);
|
||||||
|
if (hour == null || minute == null) return null;
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||||
|
return (hour, minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_timezone_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin");
|
||||||
|
flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_timezone
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import cloud_firestore
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_database
|
import firebase_database
|
||||||
import path_provider_foundation
|
import flutter_local_notifications
|
||||||
|
import flutter_timezone
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
|
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
300
pubspec.lock
300
pubspec.lock
|
|
@ -13,10 +13,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.9"
|
version: "3.6.1"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -29,10 +29,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.12.0"
|
version: "2.13.1"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -49,14 +49,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: checked_yaml
|
name: checked_yaml
|
||||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.0.4"
|
||||||
cli_util:
|
cli_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -97,6 +105,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.4.12"
|
version: "4.4.12"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -113,22 +129,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cupertino_icons
|
name: cupertino_icons
|
||||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.9"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
|
excel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: excel
|
||||||
|
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.6"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.2"
|
version: "1.3.3"
|
||||||
ffi:
|
ffi:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -137,6 +185,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -173,10 +229,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_platform_interface
|
name: firebase_core_platform_interface
|
||||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "6.0.3"
|
||||||
firebase_core_web:
|
firebase_core_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -246,24 +302,80 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "5.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "17.2.4"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.1"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.2.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_timezone:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_timezone
|
||||||
|
sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
google_fonts:
|
google_fonts:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.3"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -284,10 +396,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.3.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -296,38 +408,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.19.0"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "4.11.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.8"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -336,6 +464,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -352,14 +488,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.11.1"
|
||||||
|
media_store_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: media_store_plus
|
||||||
|
sha256: "4b4971365e00a4ed9fde14abf40d7c27475b66b8bba9bf43478ae2ecb449df20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.6"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -368,6 +520,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -388,18 +556,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.19"
|
version: "2.3.1"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -428,10 +596,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -448,14 +616,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
posix:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: posix
|
|
||||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.0"
|
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -464,6 +624,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -473,10 +641,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -513,10 +681,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.7"
|
||||||
|
timezone:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -525,30 +701,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_html:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: universal_html
|
||||||
|
sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
universal_io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_io
|
||||||
|
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.3.1"
|
version: "15.0.2"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -569,10 +761,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.0"
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -582,5 +774,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.7.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.38.4"
|
||||||
|
|
|
||||||
24
pubspec.yaml
24
pubspec.yaml
|
|
@ -33,24 +33,30 @@ dependencies:
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.9
|
||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
firebase_core: ^3.3.0
|
firebase_core: ^3.15.2
|
||||||
firebase_auth: ^5.1.4
|
firebase_auth: ^5.7.0
|
||||||
cloud_firestore: ^5.1.0
|
cloud_firestore: ^5.6.12
|
||||||
firebase_database: ^11.0.0
|
firebase_database: ^11.3.10
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
provider: ^6.1.0
|
provider: ^6.1.5+1
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid: ^4.0.0
|
uuid: ^4.5.3
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
|
|
||||||
# UI/UX
|
# UI/UX
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.3.3
|
||||||
flutter_dotenv: ^6.0.0
|
flutter_dotenv: ^6.0.0
|
||||||
|
flutter_local_notifications: ^17.2.4
|
||||||
|
timezone: ^0.9.4
|
||||||
|
flutter_timezone: ^4.1.1
|
||||||
|
universal_html: ^2.3.0
|
||||||
|
excel: ^4.0.6
|
||||||
|
media_store_plus: ^0.1.3
|
||||||
|
|
||||||
# Scheduler & Background Tasks
|
# Scheduler & Background Tasks
|
||||||
# TEMPORARILY DISABLED - Compatibility issues with Android SDK 36
|
# TEMPORARILY DISABLED - Compatibility issues with Android SDK 36
|
||||||
|
|
@ -61,7 +67,7 @@ dependencies:
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_launcher_icons: ^0.14.3
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<<<<<<< HEAD
|
||||||
# TelurKu Railway Scheduler
|
# TelurKu Railway Scheduler
|
||||||
|
|
||||||
Worker Node.js untuk menjalankan penjadwalan panen otomatis di Railway.
|
Worker Node.js untuk menjalankan penjadwalan panen otomatis di Railway.
|
||||||
|
|
@ -87,3 +88,7 @@ Setelah jadwal sore berhasil dieksekusi, worker akan me-reset:
|
||||||
- `data/infra2 = 0`
|
- `data/infra2 = 0`
|
||||||
|
|
||||||
Ringkasan harian di `riwayat/summary` tetap disimpan untuk kebutuhan tampilan Flutter.
|
Ringkasan harian di `riwayat/summary` tetap disimpan untuk kebutuhan tampilan Flutter.
|
||||||
|
=======
|
||||||
|
# lurtelurku_backend
|
||||||
|
Deployment project for Railway
|
||||||
|
>>>>>>> 6d5419d62de4e76250a0a5160a8d0820de478a16
|
||||||
|
|
|
||||||
|
|
@ -135,14 +135,18 @@ function resolveTargetKandangIds(jadwal, kandangMap) {
|
||||||
return [kandangId];
|
return [kandangId];
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectJenisPanen(jadwal) {
|
function detectJenisPanen(jadwal, jadwalOrder) {
|
||||||
|
// jadwalOrder 0 = urutan pertama = PAGI
|
||||||
|
// jadwalOrder 1+ = urutan selanjutnya = SORE (atau sesuai jam)
|
||||||
|
if (jadwalOrder === 0) {
|
||||||
|
return 'pagi';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untuk jadwal ke-2+, check jam: < 12 = pagi, >= 12 = sore
|
||||||
const jam = String(jadwal.jam || '09:00');
|
const jam = String(jadwal.jam || '09:00');
|
||||||
const hour = Number(jam.split(':')[0] || 9);
|
const hour = Number(jam.split(':')[0] || 9);
|
||||||
const ket = String(jadwal.keterangan || '').toLowerCase();
|
|
||||||
|
return hour < 12 ? 'pagi' : 'sore';
|
||||||
if (ket.includes('sore')) return 'sore';
|
|
||||||
if (ket.includes('pagi')) return 'pagi';
|
|
||||||
return hour >= 12 ? 'sore' : 'pagi';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInfraPath(kandangId, kandangData) {
|
function resolveInfraPath(kandangId, kandangData) {
|
||||||
|
|
@ -377,7 +381,7 @@ async function migrateLegacyRiwayatData(kandangMap) {
|
||||||
cleanupUpdates[legacyKey] = null;
|
cleanupUpdates[legacyKey] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap);
|
const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap);
|
||||||
if (inferredKandangId) {
|
if (inferredKandangId) {
|
||||||
await recordsRef.push().set({
|
await recordsRef.push().set({
|
||||||
|
|
@ -500,9 +504,9 @@ async function runCompactMaintenance() {
|
||||||
await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS);
|
await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey) {
|
async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrder, totalJadwal) {
|
||||||
const jam = String(jadwal.jam || '09:00');
|
const jam = String(jadwal.jam || '09:00');
|
||||||
const jenisPanen = detectJenisPanen(jadwal);
|
const jenisPanen = detectJenisPanen(jadwal, jadwalOrder);
|
||||||
const durasiMs = parseDurasiMs(jadwal.durasi);
|
const durasiMs = parseDurasiMs(jadwal.durasi);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -519,10 +523,10 @@ async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const kandangId of targetKandangIds) {
|
for (const kandangId of targetKandangIds) {
|
||||||
const lockKey = `${jadwalId}_${jenisPanen}_${kandangId}`;
|
const lockKey = `${jenisPanen}_${kandangId}`;
|
||||||
const gotLock = await acquireRunLock(todayKey, lockKey);
|
const gotLock = await acquireRunLock(todayKey, lockKey);
|
||||||
if (!gotLock) {
|
if (!gotLock) {
|
||||||
console.log(`[skip] ${jadwalId}/${kandangId}: sudah dieksekusi hari ini (${jenisPanen})`);
|
console.log(`[skip] ${jadwalId}/${kandangId}: slot ${jenisPanen} sudah dieksekusi hari ini`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,6 +584,13 @@ async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey
|
||||||
|
|
||||||
console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`);
|
console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jika ini jadwal terakhir, reset snapshot untuk hari ini
|
||||||
|
if (jadwalOrder === totalJadwal - 1) {
|
||||||
|
const snapshotRef = admin.database().ref(`panen_snapshot/${todayKey}`);
|
||||||
|
await snapshotRef.remove();
|
||||||
|
console.log(`[ok] Reset snapshot panen untuk hari ${todayKey} (jadwal terakhir selesai)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTick() {
|
async function runTick() {
|
||||||
|
|
@ -603,7 +614,22 @@ async function runTick() {
|
||||||
const dataSensor = dataSnap.exists() ? dataSnap.val() : {};
|
const dataSensor = dataSnap.exists() ? dataSnap.val() : {};
|
||||||
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
|
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
|
||||||
|
|
||||||
const aktifSekarang = Object.entries(jadwalMap).filter(([_, jadwal]) => {
|
// Sort jadwal by ID untuk menentukan urutan: penjadwalan1 = pagi, penjadwalan2+ = sore
|
||||||
|
const sortedJadwals = Object.entries(jadwalMap)
|
||||||
|
.map(([id, jadwal]) => [id, jadwal])
|
||||||
|
.sort((a, b) => {
|
||||||
|
const numA = parseInt(a[0].replace('penjadwalan', '')) || 999;
|
||||||
|
const numB = parseInt(b[0].replace('penjadwalan', '')) || 999;
|
||||||
|
return numA - numB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map untuk deteksi urutan
|
||||||
|
const jadwalOrderMap = {};
|
||||||
|
sortedJadwals.forEach(([id, jadwal], index) => {
|
||||||
|
jadwalOrderMap[id] = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
const aktifSekarang = sortedJadwals.filter(([_, jadwal]) => {
|
||||||
if (!jadwal || jadwal.aktif !== true) return false;
|
if (!jadwal || jadwal.aktif !== true) return false;
|
||||||
const jam = String(jadwal.jam || '').slice(0, 5);
|
const jam = String(jadwal.jam || '').slice(0, 5);
|
||||||
return jam === nowHHMM;
|
return jam === nowHHMM;
|
||||||
|
|
@ -619,8 +645,8 @@ async function runTick() {
|
||||||
|
|
||||||
for (const [jadwalId, jadwal] of aktifSekarang) {
|
for (const [jadwalId, jadwal] of aktifSekarang) {
|
||||||
try {
|
try {
|
||||||
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey);
|
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrderMap[jadwalId], sortedJadwals.length);
|
||||||
if (detectJenisPanen(jadwal) === 'sore') {
|
if (detectJenisPanen(jadwal, jadwalOrderMap[jadwalId]) === 'sore') {
|
||||||
hasEveningRun = true;
|
hasEveningRun = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
|
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
CloudFirestorePluginCApiRegisterWithRegistrar(
|
CloudFirestorePluginCApiRegisterWithRegistrar(
|
||||||
|
|
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
FlutterTimezonePluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
cloud_firestore
|
cloud_firestore
|
||||||
firebase_auth
|
firebase_auth
|
||||||
firebase_core
|
firebase_core
|
||||||
|
flutter_timezone
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue