From 036a3543edd64a7e6c4fdeeef62805a78198865c Mon Sep 17 00:00:00 2001 From: Kharisma Date: Sun, 14 Jun 2026 23:14:34 +0700 Subject: [PATCH] Fix bug duplikasi jam 15.00 dan perbaiki zona waktu Jakarta --- .flutter-plugins | 3 + NOTIFIKASI_IMPLEMENTASI_LENGKAP.md | 167 +++++++ PERBAIKAN_SISTEM_PANEN.md | 92 ++++ android/app/build.gradle.kts | 7 +- android/app/src/main/AndroidManifest.xml | 19 + lib/main.dart | 130 +++++- lib/models/penjadwalan_model.dart | 4 + lib/providers/panen_provider.dart | 179 +++++++- lib/providers/penjadwalan_provider.dart | 25 +- lib/screens/dashboard_page.dart | 16 +- lib/screens/home_page.dart | 75 ++- lib/screens/kontrol_page.dart | 196 +++++++- lib/screens/landing_page.dart | 19 +- lib/screens/login_page.dart | 25 - lib/screens/notification_debug_screen.dart | 345 ++++++++++++++ lib/screens/panen_debug_screen.dart | 96 +++- lib/screens/riwayat_page.dart | 234 ++++++++-- lib/services/excel_export_service.dart | 212 +++++++++ lib/services/excel_save_helper.dart | 2 + lib/services/excel_save_helper_io.dart | 35 ++ lib/services/excel_save_helper_web.dart | 23 + lib/services/notification_service.dart | 428 ++++++++++++++++++ lib/services/panen_auto_capture_service.dart | 100 +++- .../panen_runtime_scheduler_service.dart | 181 ++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 +- pubspec.lock | 300 +++++++++--- pubspec.yaml | 24 +- railway-scheduler/README.md | 5 + railway-scheduler/worker.js | 54 ++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 33 files changed, 2777 insertions(+), 236 deletions(-) create mode 100644 NOTIFIKASI_IMPLEMENTASI_LENGKAP.md create mode 100644 PERBAIKAN_SISTEM_PANEN.md create mode 100644 lib/screens/notification_debug_screen.dart create mode 100644 lib/services/excel_export_service.dart create mode 100644 lib/services/excel_save_helper.dart create mode 100644 lib/services/excel_save_helper_io.dart create mode 100644 lib/services/excel_save_helper_web.dart create mode 100644 lib/services/notification_service.dart create mode 100644 lib/services/panen_runtime_scheduler_service.dart diff --git a/.flutter-plugins b/.flutter-plugins index d380b2a..535143e 100644 --- a/.flutter-plugins +++ b/.flutter-plugins @@ -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\\ diff --git a/NOTIFIKASI_IMPLEMENTASI_LENGKAP.md b/NOTIFIKASI_IMPLEMENTASI_LENGKAP.md new file mode 100644 index 0000000..072df70 --- /dev/null +++ b/NOTIFIKASI_IMPLEMENTASI_LENGKAP.md @@ -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. diff --git a/PERBAIKAN_SISTEM_PANEN.md b/PERBAIKAN_SISTEM_PANEN.md new file mode 100644 index 0000000..842d16b --- /dev/null +++ b/PERBAIKAN_SISTEM_PANEN.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 807c5a0..d354764 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 = "../.." } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 24868c7..a64a3d9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,21 @@ + + + + + + + + + + + + diff --git a/lib/main.dart b/lib/main.dart index 2b54b22..a79a737 100644 --- a/lib/main.dart +++ b/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 { late Future _initFuture; + PenjadwalanProvider? _penjadwalanProvider; + final Set _scheduledReminderKeys = {}; + Timer? _scheduleSyncDebounce; @override void initState() { @@ -122,6 +136,120 @@ class _HomeBootstrapState extends State { 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 _syncDailyHarvestReminders(PenjadwalanProvider provider) async { + if (kDebugMode) { + print('📱 [BOOTSTRAP] Syncing harvest reminders...'); + } + + final activeSchedules = + provider.penjadwalans.where((s) => s.aktif).toList(); + final nextKeys = {}; + + 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().stopRealtimePanenNotifications(); + super.dispose(); } @override diff --git a/lib/models/penjadwalan_model.dart b/lib/models/penjadwalan_model.dart index d193c85..fb762f6 100644 --- a/lib/models/penjadwalan_model.dart +++ b/lib/models/penjadwalan_model.dart @@ -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 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, }; } } diff --git a/lib/providers/panen_provider.dart b/lib/providers/panen_provider.dart index 3ecf9cc..993c6fb 100644 --- a/lib/providers/panen_provider.dart +++ b/lib/providers/panen_provider.dart @@ -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 _snapshotSoreHariIni = {}; // kandangId -> nilaiSore final List _panens = []; + final Set _knownRealtimeRecordKeys = {}; + StreamSubscription? _recordsRealtimeSubscription; + bool _realtimeListenerStarted = false; List 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 _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 = { + 'kandang 1': k1, + 'kandang 2': k2, + }; + + await NotificationService.instance.showPanenSummaryNotification( + jenisPanen: jenis, + kandangTotals: totals, + ); + } + + Future 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.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.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 stopRealtimePanenNotifications() async { + await _recordsRealtimeSubscription?.cancel(); + _recordsRealtimeSubscription = null; + _realtimeListenerStarted = false; + _knownRealtimeRecordKeys.clear(); + } + List 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 captureScheduledPanenPagi( + Future 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 captureScheduledPanenSore( + Future 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(); + } } diff --git a/lib/providers/penjadwalan_provider.dart b/lib/providers/penjadwalan_provider.dart index 0fd29b2..0ed24c1 100644 --- a/lib/providers/penjadwalan_provider.dart +++ b/lib/providers/penjadwalan_provider.dart @@ -16,6 +16,7 @@ class PenjadwalanProvider extends ChangeNotifier { List get penjadwalans => _penjadwalans; bool get isLoading => _isLoading; + bool get isMaximalPenjadwalan => _penjadwalans.length >= 2; /// Initialize provider dengan user ID dan load data dari Firebase Future 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()); diff --git a/lib/screens/dashboard_page.dart b/lib/screens/dashboard_page.dart index 9b0514e..b6c19f8 100644 --- a/lib/screens/dashboard_page.dart +++ b/lib/screens/dashboard_page.dart @@ -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(0, (sum, panen) => sum + panen.jumlahTelur); } } diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart index 114bd77..a94df6f 100644 --- a/lib/screens/home_page.dart +++ b/lib/screens/home_page.dart @@ -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 { 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 { ], ), ); + } 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( diff --git a/lib/screens/kontrol_page.dart b/lib/screens/kontrol_page.dart index 51b0b9e..e0d1ea8 100644 --- a/lib/screens/kontrol_page.dart +++ b/lib/screens/kontrol_page.dart @@ -145,6 +145,7 @@ class _KontrolPageState extends State { 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 { 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 { 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( + 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 { 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( + 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 { } void _showTambahJadwalBaru(BuildContext context) { + final penjadwalanProvider = context.read(); 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 { 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 { jamController.text, durasiController.text, keteranganController.text, + tipeJadwal: tipeJadwal, ); Navigator.pop(context); } @@ -538,6 +648,44 @@ class _KontrolPageState extends State { ); } + void _showPenjadwalanDisabledDialog(BuildContext context) { + final penjadwalanProvider = context.read(); + 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(); + 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(); diff --git a/lib/screens/landing_page.dart b/lib/screens/landing_page.dart index b547e07..aeec0d3 100644 --- a/lib/screens/landing_page.dart +++ b/lib/screens/landing_page.dart @@ -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), diff --git a/lib/screens/login_page.dart b/lib/screens/login_page.dart index d2b7ab3..9e4906f 100644 --- a/lib/screens/login_page.dart +++ b/lib/screens/login_page.dart @@ -200,31 +200,6 @@ class _LoginPageState extends State { ), ), ), - 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( diff --git a/lib/screens/notification_debug_screen.dart b/lib/screens/notification_debug_screen.dart new file mode 100644 index 0000000..046611f --- /dev/null +++ b/lib/screens/notification_debug_screen.dart @@ -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 createState() => + _NotificationDebugScreenState(); +} + +class _NotificationDebugScreenState extends State { + bool _isLoading = false; + bool? _canScheduleExactNotifications; + String? _lastStatus; + List _pendingNotifications = []; + + @override + void initState() { + super.initState(); + _loadPendingNotifications(); + } + + Future _setStatus(String message) async { + if (!mounted) return; + setState(() { + _lastStatus = message; + }); + } + + Future _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 _sendSamplePanenPagi() async { + await _runAction(() async { + await NotificationService.instance.showPanenSummaryNotification( + jenisPanen: 'pagi', + kandangTotals: const { + 'kandang 1': 15, + 'kandang 2': 15, + }, + ); + }, 'Notif panen pagi terkirim'); + } + + Future _sendSamplePanenSore() async { + await _runAction(() async { + await NotificationService.instance.showPanenSummaryNotification( + jenisPanen: 'sore', + kandangTotals: const { + 'kandang 1': 12, + 'kandang 2': 8, + }, + ); + }, 'Notif panen sore terkirim'); + } + + Future _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 _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 _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 _cancelAllNotifications() async { + await _runAction(() async { + await NotificationService.instance.cancelAll(); + }, 'Semua notifikasi dibatalkan'); + + await _loadPendingNotifications(); + } + + Future _runAction( + Future 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, + ), + ), + ), + ); + } +} diff --git a/lib/screens/panen_debug_screen.dart b/lib/screens/panen_debug_screen.dart index 0fad4f3..52461e9 100644 --- a/lib/screens/panen_debug_screen.dart +++ b/lib/screens/panen_debug_screen.dart @@ -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 { }); try { - final result = await PanenScheduler.triggerPanenPagiManual(); + final panenProvider = context.read(); + final kandangProvider = context.read(); + + 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(0, (sum, p) => sum + p.jumlahTelur); + final k1 = todayPagi + .where( + (p) => + p.kandangId.toLowerCase() == 'kandang_1' || + p.kandangId.toLowerCase() == 'kandang1', + ) + .fold(0, (sum, p) => sum + p.jumlahTelur); + final k2 = todayPagi + .where( + (p) => + p.kandangId.toLowerCase() == 'kandang_2' || + p.kandangId.toLowerCase() == 'kandang2', + ) + .fold(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 { }); try { - final result = await PanenScheduler.triggerPanenSoreManual(); + final panenProvider = context.read(); + final kandangProvider = context.read(); + + 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(0, (sum, p) => sum + p.jumlahTelur); + final k1 = todaySore + .where( + (p) => + p.kandangId.toLowerCase() == 'kandang_1' || + p.kandangId.toLowerCase() == 'kandang1', + ) + .fold(0, (sum, p) => sum + p.jumlahTelur); + final k2 = todaySore + .where( + (p) => + p.kandangId.toLowerCase() == 'kandang_2' || + p.kandangId.toLowerCase() == 'kandang2', + ) + .fold(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, ), ); } diff --git a/lib/screens/riwayat_page.dart b/lib/screens/riwayat_page.dart index 0c3ba9f..071f215 100644 --- a/lib/screens/riwayat_page.dart +++ b/lib/screens/riwayat_page.dart @@ -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 { }, ), 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 { final panenProvider = context.watch(); final panens = _getFilteredPanens(panenProvider); - final groupedPanens = >{}; + final groupedPanens = >{}; for (final panen in panens) { final key = DateTime( panen.tanggalPanen.year, @@ -221,8 +270,7 @@ class _RiwayatPageState extends State { .sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen)); } - final totalTelur = - panens.fold(0, (sum, p) => sum + (p.jumlahTelur as int)); + final totalTelur = panens.fold(0, (sum, p) => sum + p.jumlahTelur); final listChildren = [ Container( @@ -328,8 +376,7 @@ class _RiwayatPageState extends State { } else { for (final date in sortedDates) { final items = groupedPanens[date]!; - final dailyTotal = - items.fold(0, (sum, p) => sum + (p.jumlahTelur as int)); + final dailyTotal = items.fold(0, (sum, p) => sum + p.jumlahTelur); listChildren.add( Container( @@ -350,25 +397,12 @@ class _RiwayatPageState extends State { 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 { ), ), ), + 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 { ); } - List _getFilteredPanens(PanenProvider panenProvider) { - final allPanens = List.from(panenProvider.panens); + List _getFilteredPanens(PanenProvider panenProvider) { + final allPanens = List.from(panenProvider.panens); final startDate = _selectedStartDate; final endDate = _selectedEndDate; @@ -504,4 +559,95 @@ class _RiwayatPageState extends State { }); } } + + Future _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(); + 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'), + ), + ], + ); + }, + ); + } } diff --git a/lib/services/excel_export_service.dart b/lib/services/excel_export_service.dart new file mode 100644 index 0000000..8b058dd --- /dev/null +++ b/lib/services/excel_export_service.dart @@ -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 exportWeeklyReport({ + required List 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 _generateExcelContent( + List 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 = {}; + + 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; +} diff --git a/lib/services/excel_save_helper.dart b/lib/services/excel_save_helper.dart new file mode 100644 index 0000000..3158061 --- /dev/null +++ b/lib/services/excel_save_helper.dart @@ -0,0 +1,2 @@ +export 'excel_save_helper_io.dart' + if (dart.library.html) 'excel_save_helper_web.dart'; diff --git a/lib/services/excel_save_helper_io.dart b/lib/services/excel_save_helper_io.dart new file mode 100644 index 0000000..ab6e2ac --- /dev/null +++ b/lib/services/excel_save_helper_io.dart @@ -0,0 +1,35 @@ +import 'dart:io'; +import 'package:media_store_plus/media_store_plus.dart'; + +Future saveReportFile({ + required String fileName, + required List 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; +} diff --git a/lib/services/excel_save_helper_web.dart b/lib/services/excel_save_helper_web.dart new file mode 100644 index 0000000..0525102 --- /dev/null +++ b/lib/services/excel_save_helper_web.dart @@ -0,0 +1,23 @@ +import 'package:universal_html/html.dart' as html; + +Future saveReportFile({ + required String fileName, + required List 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)'; +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..974bf58 --- /dev/null +++ b/lib/services/notification_service.dart @@ -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 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 _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 _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 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 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 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 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 canScheduleExactNotifications() async { + if (kIsWeb) return false; + await initialize(); + final androidPlugin = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + return await androidPlugin?.canScheduleExactNotifications() ?? false; + } + + Future requestExactAlarmsPermission() async { + if (kIsWeb) return false; + await initialize(); + final androidPlugin = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + return await androidPlugin?.requestExactAlarmsPermission() ?? false; + } + + Future cancelAll() async { + if (kIsWeb) return; + await _plugin.cancelAll(); + } + + Future> pendingNotifications() async { + if (kIsWeb) return []; + return _plugin.pendingNotificationRequests(); + } + + Future showPanenSummaryNotification({ + required String jenisPanen, + required Map kandangTotals, + }) async { + final normalizedJenis = jenisPanen.trim().toLowerCase(); + final total = + kandangTotals.values.fold(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 ?? ''}'); + } + } +} diff --git a/lib/services/panen_auto_capture_service.dart b/lib/services/panen_auto_capture_service.dart index 83e02b0..0fd6582 100644 --- a/lib/services/panen_auto_capture_service.dart +++ b/lib/services/panen_auto_capture_service.dart @@ -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 triggerMorningCapture( @@ -34,25 +48,46 @@ class PanenAutoCaptureService { // Tambah kandang lain sesuai kebutuhan }; + final capturedTotals = {}; + // 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 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 = {}; + // 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)'); } }); } diff --git a/lib/services/panen_runtime_scheduler_service.dart b/lib/services/panen_runtime_scheduler_service.dart new file mode 100644 index 0000000..4a65082 --- /dev/null +++ b/lib/services/panen_runtime_scheduler_service.dart @@ -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 _lastSoreTriggerDateByIndex = + {}; // Track per jadwal sore + + static Future 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 _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); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f05e0fb 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..44f065f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_timezone ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b09efdc..5f13583 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index e41ba24..15284ed 100644 --- a/pubspec.lock +++ b/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" diff --git a/pubspec.yaml b/pubspec.yaml index 268d7c2..ccd4333 100644 --- a/pubspec.yaml +++ b/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 diff --git a/railway-scheduler/README.md b/railway-scheduler/README.md index e74662d..8e404b4 100644 --- a/railway-scheduler/README.md +++ b/railway-scheduler/README.md @@ -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 diff --git a/railway-scheduler/worker.js b/railway-scheduler/worker.js index f3cd9a2..930ea29 100644 --- a/railway-scheduler/worker.js +++ b/railway-scheduler/worker.js @@ -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) { diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bf6d21a..e398add 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include 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")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b83b40a..f84c2e4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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)