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_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\\
|
||||
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_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\\
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
|
|
@ -24,7 +25,7 @@ android {
|
|||
applicationId = "com.example.telurku"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 23
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
|
@ -39,6 +40,10 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,21 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
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.RECEIVE_BOOT_COMPLETED" />
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
</manifest>
|
||||
|
|
|
|||
130
lib/main.dart
130
lib/main.dart
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_database/firebase_database.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
|
@ -9,11 +10,15 @@ import 'providers/kandang_provider.dart';
|
|||
import 'providers/panen_provider.dart';
|
||||
import 'providers/penjadwalan_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 'screens/landing_page.dart';
|
||||
import 'screens/login_page.dart';
|
||||
import 'screens/signup_page.dart';
|
||||
import 'screens/home_page.dart';
|
||||
import 'screens/panen_debug_screen.dart';
|
||||
import 'screens/notification_debug_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -26,6 +31,10 @@ void main() async {
|
|||
FirebaseDatabase.instance.setPersistenceEnabled(true);
|
||||
}
|
||||
|
||||
if (!kIsWeb) {
|
||||
await NotificationService.instance.initialize();
|
||||
}
|
||||
|
||||
// Initialize background scheduler untuk panen otomatis
|
||||
// TODO: Enable setelah scheduler stabil
|
||||
// await PanenScheduler.initialize();
|
||||
|
|
@ -47,7 +56,7 @@ class MyApp extends StatelessWidget {
|
|||
ChangeNotifierProvider(create: (_) => TelurProvider()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'TelurKu',
|
||||
title: 'TEFA UNGGAS POLIJE',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
|
|
@ -62,6 +71,8 @@ class MyApp extends StatelessWidget {
|
|||
'/login': (context) => const LoginPage(),
|
||||
'/signup': (context) => const SignupPage(),
|
||||
'/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> {
|
||||
late Future<void> _initFuture;
|
||||
PenjadwalanProvider? _penjadwalanProvider;
|
||||
final Set<String> _scheduledReminderKeys = <String>{};
|
||||
Timer? _scheduleSyncDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -122,6 +136,120 @@ class _HomeBootstrapState extends State<HomeBootstrap> {
|
|||
await kandangProvider.initializeWithUser(widget.userId);
|
||||
await panenProvider.loadTodaySnapshots();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ class Penjadwalan {
|
|||
final String durasi;
|
||||
final String keterangan;
|
||||
final bool aktif;
|
||||
final String? tipeJadwal; // 'pagi' atau 'sore'
|
||||
|
||||
Penjadwalan({
|
||||
required this.id,
|
||||
|
|
@ -15,6 +16,7 @@ class Penjadwalan {
|
|||
required this.durasi,
|
||||
required this.keterangan,
|
||||
required this.aktif,
|
||||
this.tipeJadwal,
|
||||
});
|
||||
|
||||
factory Penjadwalan.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -26,6 +28,7 @@ class Penjadwalan {
|
|||
durasi: json['durasi'] ?? '30 menit',
|
||||
keterangan: json['keterangan'] ?? '',
|
||||
aktif: json['aktif'] ?? true,
|
||||
tipeJadwal: json['tipeJadwal'],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +41,7 @@ class Penjadwalan {
|
|||
'durasi': durasi,
|
||||
'keterangan': keterangan,
|
||||
'aktif': aktif,
|
||||
'tipeJadwal': tipeJadwal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:firebase_database/firebase_database.dart';
|
||||
import '../models/panen_model.dart';
|
||||
import '../services/notification_service.dart';
|
||||
|
||||
class PanenProvider extends ChangeNotifier {
|
||||
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||
|
|
@ -12,9 +15,147 @@ class PanenProvider extends ChangeNotifier {
|
|||
final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
||||
|
||||
final List<Panen> _panens = [];
|
||||
final Set<String> _knownRealtimeRecordKeys = <String>{};
|
||||
StreamSubscription<DatabaseEvent>? _recordsRealtimeSubscription;
|
||||
bool _realtimeListenerStarted = false;
|
||||
|
||||
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) {
|
||||
return _panens.where((p) => p.kandangId == kandangId).toList();
|
||||
}
|
||||
|
|
@ -34,7 +175,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
}).toList();
|
||||
}
|
||||
|
||||
void addPanen(
|
||||
Panen addPanen(
|
||||
String kandangId,
|
||||
String kandangNama,
|
||||
int jumlahTelur,
|
||||
|
|
@ -55,6 +196,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
_panens.add(newPanen);
|
||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||
notifyListeners();
|
||||
return newPanen;
|
||||
}
|
||||
|
||||
void deletePanen(String id) {
|
||||
|
|
@ -109,7 +251,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// Add panen dari sensor snapshot dengan tracking jenis panen (pagi/sore)
|
||||
void addPanenFromSensor(
|
||||
Panen addPanenFromSensor(
|
||||
String kandangId,
|
||||
String kandangNama,
|
||||
int sensorValue,
|
||||
|
|
@ -141,6 +283,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
_panens.add(newPanen);
|
||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||
notifyListeners();
|
||||
return newPanen;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -179,7 +322,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
|
||||
/// AUTO-CAPTURE DARI SCHEDULER
|
||||
/// Panggil ini saat scheduler trigger jam 09:00
|
||||
Future<void> captureScheduledPanenPagi(
|
||||
Future<Panen?> captureScheduledPanenPagi(
|
||||
String kandangId,
|
||||
String kandangNama,
|
||||
int sensorValue,
|
||||
|
|
@ -190,7 +333,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
if (kDebugMode) {
|
||||
print('⚠️ Panen pagi untuk $kandangId sudah di-record hari ini');
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Time-Window Pagi: Ambil nilai sensor sebagai total telur pagi
|
||||
|
|
@ -201,13 +344,16 @@ class PanenProvider extends ChangeNotifier {
|
|||
_snapshotPagiHariIni[kandangId] = sensorValue;
|
||||
|
||||
const uuid = Uuid();
|
||||
final now = DateTime.now();
|
||||
final jamStr =
|
||||
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||
final newPanen = Panen(
|
||||
id: uuid.v4(),
|
||||
kandangId: kandangId,
|
||||
kandangNama: kandangNama,
|
||||
jumlahTelur: jumlahTelur,
|
||||
tanggalPanen: DateTime.now(),
|
||||
jam: '09:00',
|
||||
tanggalPanen: now,
|
||||
jam: jamStr,
|
||||
catatan: 'Auto-capture PAGI dari scheduler',
|
||||
jenisPanen: 'pagi',
|
||||
sensorSnapshot: sensorValue,
|
||||
|
|
@ -227,17 +373,19 @@ class PanenProvider extends ChangeNotifier {
|
|||
}
|
||||
|
||||
notifyListeners();
|
||||
return newPanen;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ Error capturing panen pagi: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// AUTO-CAPTURE DARI SCHEDULER
|
||||
/// Panggil ini saat scheduler trigger jam 15:00
|
||||
/// Dengan delta calculation: nilai_sore - nilai_pagi
|
||||
Future<void> captureScheduledPanenSore(
|
||||
Future<Panen?> captureScheduledPanenSore(
|
||||
String kandangId,
|
||||
String kandangNama,
|
||||
int sensorValue,
|
||||
|
|
@ -248,7 +396,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
if (kDebugMode) {
|
||||
print('⚠️ Panen sore untuk $kandangId sudah di-record hari ini');
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ambil nilai pagi hari ini
|
||||
|
|
@ -270,13 +418,16 @@ class PanenProvider extends ChangeNotifier {
|
|||
_snapshotSoreHariIni[kandangId] = sensorValue;
|
||||
|
||||
const uuid = Uuid();
|
||||
final now = DateTime.now();
|
||||
final jamStr =
|
||||
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||
final newPanen = Panen(
|
||||
id: uuid.v4(),
|
||||
kandangId: kandangId,
|
||||
kandangNama: kandangNama,
|
||||
jumlahTelur: jumlahTelur,
|
||||
tanggalPanen: DateTime.now(),
|
||||
jam: '15:00',
|
||||
tanggalPanen: now,
|
||||
jam: jamStr,
|
||||
catatan:
|
||||
'Auto-capture SORE dari scheduler${nilaiPagi != null ? ' (delta: $sensorValue - $nilaiPagi)' : ''}',
|
||||
jenisPanen: 'sore',
|
||||
|
|
@ -299,10 +450,12 @@ class PanenProvider extends ChangeNotifier {
|
|||
}
|
||||
|
||||
notifyListeners();
|
||||
return newPanen;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
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;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isMaximalPenjadwalan => _penjadwalans.length >= 2;
|
||||
|
||||
/// Initialize provider dengan user ID dan load data dari Firebase
|
||||
Future<void> initializeWithUser(String userId) async {
|
||||
|
|
@ -94,20 +95,22 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
|
||||
await _penjadwalanRef.set({
|
||||
'penjadwalan1': {
|
||||
'kandangId': 'kandang1',
|
||||
'kandangNama': 'Kandang 1',
|
||||
'kandangId': 'global',
|
||||
'kandangNama': 'Semua Kandang',
|
||||
'jam': '09:00',
|
||||
'durasi': '30 menit',
|
||||
'keterangan': 'Panen pagi',
|
||||
'aktif': true,
|
||||
'tipeJadwal': 'pagi',
|
||||
},
|
||||
'penjadwalan2': {
|
||||
'kandangId': 'kandang2',
|
||||
'kandangNama': 'Kandang 2',
|
||||
'kandangId': 'global',
|
||||
'kandangNama': 'Semua Kandang',
|
||||
'jam': '15:00',
|
||||
'durasi': '30 menit',
|
||||
'keterangan': 'Panen sore',
|
||||
'aktif': true,
|
||||
'tipeJadwal': 'sore',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -142,9 +145,16 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
String kandangNama,
|
||||
String jam,
|
||||
String durasi,
|
||||
String keterangan,
|
||||
) async {
|
||||
String keterangan, {
|
||||
String? tipeJadwal,
|
||||
}) async {
|
||||
try {
|
||||
// Cek apakah sudah mencapai batas maksimal 2 penjadwalan
|
||||
if (_penjadwalans.length >= 2) {
|
||||
print('Error: Maksimal penjadwalan adalah 2');
|
||||
return;
|
||||
}
|
||||
|
||||
final newId = _getNextPenjadwalanKey();
|
||||
|
||||
final newPenjadwalan = Penjadwalan(
|
||||
|
|
@ -155,6 +165,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
durasi: durasi,
|
||||
keterangan: keterangan,
|
||||
aktif: true,
|
||||
tipeJadwal: tipeJadwal,
|
||||
);
|
||||
|
||||
await _penjadwalanRef.child(newId).set(newPenjadwalan.toJson());
|
||||
|
|
@ -182,6 +193,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
durasi: durasi,
|
||||
keterangan: keterangan,
|
||||
aktif: aktif,
|
||||
tipeJadwal: _penjadwalans[index].tipeJadwal,
|
||||
);
|
||||
|
||||
await _penjadwalanRef.child(id).set(updated.toJson());
|
||||
|
|
@ -213,6 +225,7 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
durasi: _penjadwalans[index].durasi,
|
||||
keterangan: _penjadwalans[index].keterangan,
|
||||
aktif: !_penjadwalans[index].aktif,
|
||||
tipeJadwal: _penjadwalans[index].tipeJadwal,
|
||||
);
|
||||
|
||||
await _penjadwalanRef.child(id).set(updated.toJson());
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ class DashboardPage extends StatelessWidget {
|
|||
kandangKey: 'kandang2',
|
||||
);
|
||||
|
||||
final kandang1TodayDisplay =
|
||||
telurProvider.kandang1HariIni != 0 ? telurProvider.kandang1HariIni : fallbackKandang1Today;
|
||||
final kandang2TodayDisplay =
|
||||
telurProvider.kandang2HariIni != 0 ? telurProvider.kandang2HariIni : fallbackKandang2Today;
|
||||
final kandang1TodayDisplay = telurProvider.kandang1HariIni != 0
|
||||
? telurProvider.kandang1HariIni
|
||||
: fallbackKandang1Today;
|
||||
final kandang2TodayDisplay = telurProvider.kandang2HariIni != 0
|
||||
? telurProvider.kandang2HariIni
|
||||
: fallbackKandang2Today;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -559,15 +561,15 @@ class DashboardPage extends StatelessWidget {
|
|||
final normalizedTarget = kandangKey.toLowerCase();
|
||||
|
||||
return panenProvider.panens.where((panen) {
|
||||
final sameDate =
|
||||
panen.tanggalPanen.year == now.year &&
|
||||
final sameDate = panen.tanggalPanen.year == now.year &&
|
||||
panen.tanggalPanen.month == now.month &&
|
||||
panen.tanggalPanen.day == now.day;
|
||||
if (!sameDate) return false;
|
||||
|
||||
final idText = panen.kandangId.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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/penjadwalan_provider.dart';
|
||||
|
|
@ -37,7 +38,7 @@ class _HomePageState extends State<HomePage> {
|
|||
child: Image.asset('assets/icons/telurku.png'),
|
||||
),
|
||||
title: Text(
|
||||
'TelurKu',
|
||||
'TEFA Unggas Polije',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade800,
|
||||
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) => [
|
||||
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(
|
||||
value: 0,
|
||||
child: Row(
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
itemCount: penjadwalanProvider.penjadwalans.length,
|
||||
itemBuilder: (context, index) {
|
||||
final jadwal = penjadwalanProvider.penjadwalans[index];
|
||||
final isSore = jadwal.tipeJadwal == 'sore';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
|
|
@ -170,12 +171,39 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
jadwal.jam,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
jadwal.jam,
|
||||
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),
|
||||
Text(
|
||||
|
|
@ -262,15 +290,25 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showTambahJadwalBaru(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Tambah Jadwal Baru'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Consumer<PenjadwalanProvider>(
|
||||
builder: (context, penjadwalanProvider, child) {
|
||||
final isDisabled = penjadwalanProvider.isMaximalPenjadwalan;
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isDisabled
|
||||
? () => _showPenjadwalanDisabledDialog(context)
|
||||
: () => _showTambahJadwalBaru(context),
|
||||
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),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showTambahKandangDialog(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Tambah Kandang Baru'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Consumer<KandangProvider>(
|
||||
builder: (context, kandangProvider, child) {
|
||||
final isDisabled = kandangProvider.kandangs.length >= 2;
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isDisabled
|
||||
? () => _showKandangDisabledDialog(context)
|
||||
: () => _showTambahKandangDialog(context),
|
||||
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) {
|
||||
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||
final jamController = TextEditingController();
|
||||
final durasiController = 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(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -486,6 +544,57 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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(
|
||||
controller: jamController,
|
||||
decoration: const InputDecoration(
|
||||
|
|
@ -527,6 +636,7 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
jamController.text,
|
||||
durasiController.text,
|
||||
keteranganController.text,
|
||||
tipeJadwal: tipeJadwal,
|
||||
);
|
||||
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) {
|
||||
final namaController = TextEditingController();
|
||||
final jumlahAyamController = TextEditingController();
|
||||
|
|
|
|||
|
|
@ -64,12 +64,19 @@ class LandingPage extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 30),
|
||||
// App Name
|
||||
Text(
|
||||
'TelurKu',
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'TEFA UNGGAS POLIJE',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
// Login Button
|
||||
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: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';
|
||||
|
||||
/// Debug Screen untuk testing Panen Scheduler
|
||||
|
|
@ -57,21 +61,55 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
|
|||
});
|
||||
|
||||
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
|
||||
|
||||
setState(() {
|
||||
_lastResult = result['success']
|
||||
? '✅ ${result['message']}'
|
||||
: '❌ ${result['message']}';
|
||||
_lastResult = success ? '✅ $message' : '❌ $message';
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message']),
|
||||
backgroundColor: result['success'] ? Colors.green : Colors.red,
|
||||
content: Text(message),
|
||||
backgroundColor: success ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -90,21 +128,55 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
|
|||
});
|
||||
|
||||
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
|
||||
|
||||
setState(() {
|
||||
_lastResult = result['success']
|
||||
? '✅ ${result['message']}'
|
||||
: '❌ ${result['message']}';
|
||||
_lastResult = success ? '✅ $message' : '❌ $message';
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message']),
|
||||
backgroundColor: result['success'] ? Colors.green : Colors.red,
|
||||
content: Text(message),
|
||||
backgroundColor: success ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/panen_model.dart';
|
||||
import '../providers/panen_provider.dart';
|
||||
import '../services/excel_export_service.dart';
|
||||
|
||||
class RiwayatPage extends StatefulWidget {
|
||||
const RiwayatPage({super.key});
|
||||
|
|
@ -169,24 +171,71 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
|||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_lastRefreshAt != null
|
||||
? 'Update: ${DateFormat('dd/MM/yyyy HH:mm').format(_lastRefreshAt!)}'
|
||||
: 'Belum pernah refresh',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _handleRefresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Refresh'),
|
||||
),
|
||||
],
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 520;
|
||||
final updateText = _lastRefreshAt != null
|
||||
? 'Update: ${DateFormat('dd/MM/yyyy HH:mm').format(_lastRefreshAt!)}'
|
||||
: 'Belum pernah refresh';
|
||||
|
||||
final actionButtons = Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _handleRefresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
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 panens = _getFilteredPanens(panenProvider);
|
||||
final groupedPanens = <DateTime, List<dynamic>>{};
|
||||
final groupedPanens = <DateTime, List<Panen>>{};
|
||||
for (final panen in panens) {
|
||||
final key = DateTime(
|
||||
panen.tanggalPanen.year,
|
||||
|
|
@ -221,8 +270,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
|||
.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||
}
|
||||
|
||||
final totalTelur =
|
||||
panens.fold<int>(0, (sum, p) => sum + (p.jumlahTelur as int));
|
||||
final totalTelur = panens.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||
|
||||
final listChildren = <Widget>[
|
||||
Container(
|
||||
|
|
@ -328,8 +376,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
|||
} else {
|
||||
for (final date in sortedDates) {
|
||||
final items = groupedPanens[date]!;
|
||||
final dailyTotal =
|
||||
items.fold<int>(0, (sum, p) => sum + (p.jumlahTelur as int));
|
||||
final dailyTotal = items.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
|
||||
|
||||
listChildren.add(
|
||||
Container(
|
||||
|
|
@ -350,25 +397,12 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('dd MMMM yyyy').format(date),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$dailyTotal telur',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
DateFormat('dd MMMM yyyy').format(date),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...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) {
|
||||
final allPanens = List<dynamic>.from(panenProvider.panens);
|
||||
List<Panen> _getFilteredPanens(PanenProvider panenProvider) {
|
||||
final allPanens = List<Panen>.from(panenProvider.panens);
|
||||
final startDate = _selectedStartDate;
|
||||
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 '../providers/panen_provider.dart';
|
||||
import '../providers/kandang_provider.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Service untuk menghubungkan PanenScheduler dengan PanenProvider
|
||||
/// Bertanggung jawab untuk trigger capture otomatis
|
||||
class PanenAutoCaptureService {
|
||||
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)
|
||||
/// Membaca sensor value untuk SETIAP kandang dan trigger capture
|
||||
static Future<void> triggerMorningCapture(
|
||||
|
|
@ -34,25 +48,46 @@ class PanenAutoCaptureService {
|
|||
// Tambah kandang lain sesuai kebutuhan
|
||||
};
|
||||
|
||||
final capturedTotals = <String, int>{};
|
||||
|
||||
// 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
|
||||
try {
|
||||
final kandang = kandangProvider.kandangs.firstWhere(
|
||||
(k) => k.id == kandangId,
|
||||
);
|
||||
final matched = kandangProvider.kandangs
|
||||
.where((k) => k.id == kandangId)
|
||||
.toList();
|
||||
final kandangNama = matched.isNotEmpty
|
||||
? matched.first.nama
|
||||
: _fallbackKandangName(kandangId);
|
||||
|
||||
panenProvider.captureScheduledPanenPagi(
|
||||
final panen = await panenProvider.captureScheduledPanenPagi(
|
||||
kandangId,
|
||||
kandang.nama,
|
||||
kandangNama,
|
||||
sensorValue as int,
|
||||
);
|
||||
|
||||
if (panen != null) {
|
||||
capturedTotals[
|
||||
_displayNameForNotification(kandangId, kandangNama)] =
|
||||
panen.jumlahTelur;
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('⚠️ Kandang $kandangId tidak found: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (capturedTotals.isNotEmpty) {
|
||||
await NotificationService.instance.showPanenSummaryNotification(
|
||||
jenisPanen: 'pagi',
|
||||
kandangTotals: capturedTotals,
|
||||
);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
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
|
||||
/// Setelah panen sore selesai, hasil akan otomatis direset
|
||||
static Future<void> triggerAfternoonCapture(
|
||||
PanenProvider panenProvider,
|
||||
KandangProvider kandangProvider,
|
||||
) async {
|
||||
KandangProvider kandangProvider, {
|
||||
bool isLastSchedule = false,
|
||||
}) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🟡 Triggering Afternoon Panen Capture');
|
||||
print(
|
||||
'🟡 Triggering Afternoon Panen Capture (isLastSchedule=$isLastSchedule)');
|
||||
}
|
||||
|
||||
// Baca sensor data dari Firebase
|
||||
|
|
@ -90,36 +128,58 @@ class PanenAutoCaptureService {
|
|||
// Tambah kandang lain sesuai kebutuhan
|
||||
};
|
||||
|
||||
final capturedTotals = <String, int>{};
|
||||
|
||||
// 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
|
||||
try {
|
||||
final kandang = kandangProvider.kandangs.firstWhere(
|
||||
(k) => k.id == kandangId,
|
||||
);
|
||||
final matched = kandangProvider.kandangs
|
||||
.where((k) => k.id == kandangId)
|
||||
.toList();
|
||||
final kandangNama = matched.isNotEmpty
|
||||
? matched.first.nama
|
||||
: _fallbackKandangName(kandangId);
|
||||
|
||||
panenProvider.captureScheduledPanenSore(
|
||||
final panen = await panenProvider.captureScheduledPanenSore(
|
||||
kandangId,
|
||||
kandang.nama,
|
||||
kandangNama,
|
||||
sensorValue as int,
|
||||
);
|
||||
|
||||
if (panen != null) {
|
||||
capturedTotals[
|
||||
_displayNameForNotification(kandangId, kandangNama)] =
|
||||
panen.jumlahTelur;
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('⚠️ Kandang $kandangId tidak found: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (capturedTotals.isNotEmpty) {
|
||||
await NotificationService.instance.showPanenSummaryNotification(
|
||||
jenisPanen: 'sore',
|
||||
kandangTotals: capturedTotals,
|
||||
);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ Afternoon capture triggered for all kandang');
|
||||
}
|
||||
|
||||
// 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), () {
|
||||
panenProvider.resetDailySnapshots();
|
||||
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 <flutter_timezone/flutter_timezone_plugin.h>
|
||||
|
||||
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
|
||||
flutter_timezone
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import cloud_firestore
|
|||
import firebase_auth
|
||||
import firebase_core
|
||||
import firebase_database
|
||||
import path_provider_foundation
|
||||
import flutter_local_notifications
|
||||
import flutter_timezone
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
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
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
version: "3.6.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -29,10 +29,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -49,14 +49,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
version: "2.0.4"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -97,6 +105,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -113,22 +129,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -137,6 +185,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -173,10 +229,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "6.0.3"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -246,24 +302,80 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -284,10 +396,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
version: "4.3.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -296,38 +408,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
version: "4.11.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.8"
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -336,6 +464,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -352,14 +488,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -368,6 +520,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -388,18 +556,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.19"
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -428,10 +596,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -448,14 +616,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -464,6 +624,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -473,10 +641,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -513,10 +681,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -525,30 +701,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -569,10 +761,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -582,5 +774,5 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.7.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
dart: ">=3.10.3 <4.0.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.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
cupertino_icons: ^1.0.9
|
||||
|
||||
# Firebase
|
||||
firebase_core: ^3.3.0
|
||||
firebase_auth: ^5.1.4
|
||||
cloud_firestore: ^5.1.0
|
||||
firebase_database: ^11.0.0
|
||||
firebase_core: ^3.15.2
|
||||
firebase_auth: ^5.7.0
|
||||
cloud_firestore: ^5.6.12
|
||||
firebase_database: ^11.3.10
|
||||
|
||||
# State Management
|
||||
provider: ^6.1.0
|
||||
provider: ^6.1.5+1
|
||||
|
||||
# Utilities
|
||||
uuid: ^4.0.0
|
||||
uuid: ^4.5.3
|
||||
intl: ^0.19.0
|
||||
|
||||
# UI/UX
|
||||
google_fonts: ^6.2.1
|
||||
google_fonts: ^6.3.3
|
||||
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
|
||||
# TEMPORARILY DISABLED - Compatibility issues with Android SDK 36
|
||||
|
|
@ -61,7 +67,7 @@ dependencies:
|
|||
dev_dependencies:
|
||||
flutter_test:
|
||||
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
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<<<<<<< HEAD
|
||||
# TelurKu Railway Scheduler
|
||||
|
||||
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`
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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 hour = Number(jam.split(':')[0] || 9);
|
||||
const ket = String(jadwal.keterangan || '').toLowerCase();
|
||||
|
||||
if (ket.includes('sore')) return 'sore';
|
||||
if (ket.includes('pagi')) return 'pagi';
|
||||
return hour >= 12 ? 'sore' : 'pagi';
|
||||
|
||||
return hour < 12 ? 'pagi' : 'sore';
|
||||
}
|
||||
|
||||
function resolveInfraPath(kandangId, kandangData) {
|
||||
|
|
@ -377,7 +381,7 @@ async function migrateLegacyRiwayatData(kandangMap) {
|
|||
cleanupUpdates[legacyKey] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap);
|
||||
if (inferredKandangId) {
|
||||
await recordsRef.push().set({
|
||||
|
|
@ -500,9 +504,9 @@ async function runCompactMaintenance() {
|
|||
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 jenisPanen = detectJenisPanen(jadwal);
|
||||
const jenisPanen = detectJenisPanen(jadwal, jadwalOrder);
|
||||
const durasiMs = parseDurasiMs(jadwal.durasi);
|
||||
|
||||
console.log(
|
||||
|
|
@ -519,10 +523,10 @@ async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey
|
|||
}
|
||||
|
||||
for (const kandangId of targetKandangIds) {
|
||||
const lockKey = `${jadwalId}_${jenisPanen}_${kandangId}`;
|
||||
const lockKey = `${jenisPanen}_${kandangId}`;
|
||||
const gotLock = await acquireRunLock(todayKey, lockKey);
|
||||
if (!gotLock) {
|
||||
console.log(`[skip] ${jadwalId}/${kandangId}: sudah dieksekusi hari ini (${jenisPanen})`);
|
||||
console.log(`[skip] ${jadwalId}/${kandangId}: slot ${jenisPanen} sudah dieksekusi hari ini`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -580,6 +584,13 @@ async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey
|
|||
|
||||
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() {
|
||||
|
|
@ -603,7 +614,22 @@ async function runTick() {
|
|||
const dataSensor = dataSnap.exists() ? dataSnap.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;
|
||||
const jam = String(jadwal.jam || '').slice(0, 5);
|
||||
return jam === nowHHMM;
|
||||
|
|
@ -619,8 +645,8 @@ async function runTick() {
|
|||
|
||||
for (const [jadwalId, jadwal] of aktifSekarang) {
|
||||
try {
|
||||
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey);
|
||||
if (detectJenisPanen(jadwal) === 'sore') {
|
||||
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrderMap[jadwalId], sortedJadwals.length);
|
||||
if (detectJenisPanen(jadwal, jadwalOrderMap[jadwalId]) === 'sore') {
|
||||
hasEveningRun = true;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
||||
#include <firebase_auth/firebase_auth_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) {
|
||||
CloudFirestorePluginCApiRegisterWithRegistrar(
|
||||
|
|
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterTimezonePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
cloud_firestore
|
||||
firebase_auth
|
||||
firebase_core
|
||||
flutter_timezone
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
|||
Loading…
Reference in New Issue