Fix bug duplikasi jam 15.00 dan perbaiki zona waktu Jakarta

This commit is contained in:
Kharisma 2026-06-14 23:14:34 +07:00
parent 853ed48715
commit 036a3543ed
33 changed files with 2777 additions and 236 deletions

View File

@ -7,6 +7,9 @@ firebase_core=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.de
firebase_core_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_core_web-2.24.1\\ firebase_core_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_core_web-2.24.1\\
firebase_database=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database-11.3.10\\ firebase_database=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database-11.3.10\\
firebase_database_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database_web-0.2.6+16\\ firebase_database_web=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\firebase_database_web-0.2.6+16\\
flutter_local_notifications=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_local_notifications-17.2.4\\
flutter_local_notifications_linux=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_local_notifications_linux-4.0.1\\
flutter_timezone=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_timezone-4.1.1\\
path_provider=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider-2.1.5\\ path_provider=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider-2.1.5\\
path_provider_android=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_android-2.2.19\\ path_provider_android=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_android-2.2.19\\
path_provider_foundation=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_foundation-2.4.2\\ path_provider_foundation=C:\\Users\\ACER SWIFT3\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_foundation-2.4.2\\

View File

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

92
PERBAIKAN_SISTEM_PANEN.md Normal file
View File

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

View File

@ -13,6 +13,7 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
@ -24,7 +25,7 @@ android {
applicationId = "com.example.telurku" applicationId = "com.example.telurku"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 23 minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@ -39,6 +40,10 @@ android {
} }
} }
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter { flutter {
source = "../.." source = "../.."
} }

View File

@ -30,6 +30,21 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Receiver wajib untuk notifikasi terjadwal flutter_local_notifications -->
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@ -48,7 +63,11 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<!-- Android 12+ requires SCHEDULE_EXACT_ALARM untuk background tasks --> <!-- Android 12+ requires SCHEDULE_EXACT_ALARM untuk background tasks -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
</manifest> </manifest>

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/firebase_database.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -9,11 +10,15 @@ import 'providers/kandang_provider.dart';
import 'providers/panen_provider.dart'; import 'providers/panen_provider.dart';
import 'providers/penjadwalan_provider.dart'; import 'providers/penjadwalan_provider.dart';
import 'providers/riwayat_provider.dart'; import 'providers/riwayat_provider.dart';
import 'services/notification_service.dart';
import 'services/panen_runtime_scheduler_service.dart';
// import 'services/panen_scheduler.dart'; // TODO: Enable after scheduler fixed // import 'services/panen_scheduler.dart'; // TODO: Enable after scheduler fixed
import 'screens/landing_page.dart'; import 'screens/landing_page.dart';
import 'screens/login_page.dart'; import 'screens/login_page.dart';
import 'screens/signup_page.dart'; import 'screens/signup_page.dart';
import 'screens/home_page.dart'; import 'screens/home_page.dart';
import 'screens/panen_debug_screen.dart';
import 'screens/notification_debug_screen.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -26,6 +31,10 @@ void main() async {
FirebaseDatabase.instance.setPersistenceEnabled(true); FirebaseDatabase.instance.setPersistenceEnabled(true);
} }
if (!kIsWeb) {
await NotificationService.instance.initialize();
}
// Initialize background scheduler untuk panen otomatis // Initialize background scheduler untuk panen otomatis
// TODO: Enable setelah scheduler stabil // TODO: Enable setelah scheduler stabil
// await PanenScheduler.initialize(); // await PanenScheduler.initialize();
@ -47,7 +56,7 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => TelurProvider()), ChangeNotifierProvider(create: (_) => TelurProvider()),
], ],
child: MaterialApp( child: MaterialApp(
title: 'TelurKu', title: 'TEFA UNGGAS POLIJE',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
@ -62,6 +71,8 @@ class MyApp extends StatelessWidget {
'/login': (context) => const LoginPage(), '/login': (context) => const LoginPage(),
'/signup': (context) => const SignupPage(), '/signup': (context) => const SignupPage(),
'/home': (context) => const AuthWrapper(), '/home': (context) => const AuthWrapper(),
'/debug/panen': (context) => const PanenDebugScreen(),
'/debug/notifikasi': (context) => const NotificationDebugScreen(),
}, },
), ),
); );
@ -106,6 +117,9 @@ class HomeBootstrap extends StatefulWidget {
class _HomeBootstrapState extends State<HomeBootstrap> { class _HomeBootstrapState extends State<HomeBootstrap> {
late Future<void> _initFuture; late Future<void> _initFuture;
PenjadwalanProvider? _penjadwalanProvider;
final Set<String> _scheduledReminderKeys = <String>{};
Timer? _scheduleSyncDebounce;
@override @override
void initState() { void initState() {
@ -122,6 +136,120 @@ class _HomeBootstrapState extends State<HomeBootstrap> {
await kandangProvider.initializeWithUser(widget.userId); await kandangProvider.initializeWithUser(widget.userId);
await panenProvider.loadTodaySnapshots(); await panenProvider.loadTodaySnapshots();
await panenProvider.restorePanenHistoryFromFirebase(); await panenProvider.restorePanenHistoryFromFirebase();
await panenProvider.startRealtimePanenNotifications();
_attachPenjadwalanListener(penjadwalanProvider);
await _syncDailyHarvestReminders(penjadwalanProvider);
await PanenRuntimeSchedulerService.start(
panenProvider: panenProvider,
kandangProvider: kandangProvider,
penjadwalanProvider: penjadwalanProvider,
);
}
void _attachPenjadwalanListener(PenjadwalanProvider provider) {
if (identical(_penjadwalanProvider, provider)) return;
_penjadwalanProvider?.removeListener(_onPenjadwalanChanged);
_penjadwalanProvider = provider;
_penjadwalanProvider?.addListener(_onPenjadwalanChanged);
}
void _onPenjadwalanChanged() {
_scheduleSyncDebounce?.cancel();
_scheduleSyncDebounce = Timer(const Duration(seconds: 1), () async {
final provider = _penjadwalanProvider;
if (!mounted || provider == null) return;
await _syncDailyHarvestReminders(provider);
});
}
(int, int)? _tryParseHourMinute(String rawJam) {
final match = RegExp(r'(\d{1,2})[:.](\d{2})').firstMatch(rawJam.trim());
if (match == null) return null;
final hour = int.tryParse(match.group(1)!);
final minute = int.tryParse(match.group(2)!);
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
Future<void> _syncDailyHarvestReminders(PenjadwalanProvider provider) async {
if (kDebugMode) {
print('📱 [BOOTSTRAP] Syncing harvest reminders...');
}
final activeSchedules =
provider.penjadwalans.where((s) => s.aktif).toList();
final nextKeys = <String>{};
for (final schedule in activeSchedules) {
final scheduleKey = 'panen:${schedule.id}:${schedule.kandangId}';
nextKeys.add(scheduleKey);
}
final removedKeys = _scheduledReminderKeys.difference(nextKeys).toList();
for (final removedKey in removedKeys) {
await NotificationService.instance
.cancelScheduledNotification(removedKey);
_scheduledReminderKeys.remove(removedKey);
}
for (final schedule in activeSchedules) {
final parsed = _tryParseHourMinute(schedule.jam);
if (parsed == null) {
if (kDebugMode) {
print('⚠️ [BOOTSTRAP] Invalid time format: ${schedule.jam}');
}
continue;
}
final hour = parsed.$1;
final minute = parsed.$2;
final jenisPanen = hour < 12 ? 'pagi' : 'sore';
final scheduleKey = 'panen:${schedule.id}:${schedule.kandangId}';
try {
await NotificationService.instance.scheduleDailyNotificationAtTime(
scheduleKey: scheduleKey,
title:
'Panen ${jenisPanen[0].toUpperCase()}${jenisPanen.substring(1)}',
body:
'Jadwal panen ${schedule.kandangNama} dimulai pukul ${schedule.jam}. Buka aplikasi untuk detail.',
hour: hour,
minute: minute,
payload: 'panen_schedule:${schedule.id}',
);
_scheduledReminderKeys.add(scheduleKey);
if (kDebugMode) {
print(
'✅ [BOOTSTRAP] Scheduled reminder: $jenisPanen ${schedule.kandangNama} @ ${schedule.jam}');
}
} catch (e) {
if (kDebugMode) {
print('❌ [BOOTSTRAP] Failed schedule ${schedule.id}: $e');
}
}
}
final pending = await NotificationService.instance.pendingNotifications();
if (kDebugMode) {
print(
'📱 [BOOTSTRAP] Active schedules: ${activeSchedules.length}, pending notifications: ${pending.length}');
print('✅ [BOOTSTRAP] Sync reminders done');
}
}
@override
void dispose() {
_scheduleSyncDebounce?.cancel();
_penjadwalanProvider?.removeListener(_onPenjadwalanChanged);
PanenRuntimeSchedulerService.stop();
context.read<PanenProvider>().stopRealtimePanenNotifications();
super.dispose();
} }
@override @override

View File

@ -6,6 +6,7 @@ class Penjadwalan {
final String durasi; final String durasi;
final String keterangan; final String keterangan;
final bool aktif; final bool aktif;
final String? tipeJadwal; // 'pagi' atau 'sore'
Penjadwalan({ Penjadwalan({
required this.id, required this.id,
@ -15,6 +16,7 @@ class Penjadwalan {
required this.durasi, required this.durasi,
required this.keterangan, required this.keterangan,
required this.aktif, required this.aktif,
this.tipeJadwal,
}); });
factory Penjadwalan.fromJson(Map<String, dynamic> json) { factory Penjadwalan.fromJson(Map<String, dynamic> json) {
@ -26,6 +28,7 @@ class Penjadwalan {
durasi: json['durasi'] ?? '30 menit', durasi: json['durasi'] ?? '30 menit',
keterangan: json['keterangan'] ?? '', keterangan: json['keterangan'] ?? '',
aktif: json['aktif'] ?? true, aktif: json['aktif'] ?? true,
tipeJadwal: json['tipeJadwal'],
); );
} }
@ -38,6 +41,7 @@ class Penjadwalan {
'durasi': durasi, 'durasi': durasi,
'keterangan': keterangan, 'keterangan': keterangan,
'aktif': aktif, 'aktif': aktif,
'tipeJadwal': tipeJadwal,
}; };
} }
} }

View File

@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/firebase_database.dart';
import '../models/panen_model.dart'; import '../models/panen_model.dart';
import '../services/notification_service.dart';
class PanenProvider extends ChangeNotifier { class PanenProvider extends ChangeNotifier {
final FirebaseDatabase _database = FirebaseDatabase.instance; final FirebaseDatabase _database = FirebaseDatabase.instance;
@ -12,9 +15,147 @@ class PanenProvider extends ChangeNotifier {
final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
final List<Panen> _panens = []; final List<Panen> _panens = [];
final Set<String> _knownRealtimeRecordKeys = <String>{};
StreamSubscription<DatabaseEvent>? _recordsRealtimeSubscription;
bool _realtimeListenerStarted = false;
List<Panen> get panens => _panens; List<Panen> get panens => _panens;
bool _isKandang1(String kandangId) {
final normalized = kandangId.toLowerCase().replaceAll('_', '');
return normalized == 'kandang1';
}
bool _isKandang2(String kandangId) {
final normalized = kandangId.toLowerCase().replaceAll('_', '');
return normalized == 'kandang2';
}
String _jenisFromPanen(Panen panen) {
final jenis = (panen.jenisPanen ?? '').trim().toLowerCase();
if (jenis == 'pagi' || jenis == 'sore') return jenis;
// Parse jam untuk klasifikasi: < 12 adalah pagi, >= 12 adalah sore
final jam = panen.jam.trim();
final jamParts = jam.split(':');
if (jamParts.isNotEmpty) {
final hour = int.tryParse(jamParts[0]);
if (hour != null) {
return hour < 12 ? 'pagi' : 'sore';
}
}
return 'panen';
}
bool _isSameDate(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
Future<void> _notifySummaryForJenis(Panen latestPanen) async {
final jenis = _jenisFromPanen(latestPanen);
if (jenis != 'pagi' && jenis != 'sore') return;
int k1 = 0;
int k2 = 0;
for (final panen in _panens) {
if (!_isSameDate(panen.tanggalPanen, latestPanen.tanggalPanen)) continue;
if (_jenisFromPanen(panen) != jenis) continue;
if (_isKandang1(panen.kandangId)) {
k1 += panen.jumlahTelur;
} else if (_isKandang2(panen.kandangId)) {
k2 += panen.jumlahTelur;
}
}
final totals = <String, int>{
'kandang 1': k1,
'kandang 2': k2,
};
await NotificationService.instance.showPanenSummaryNotification(
jenisPanen: jenis,
kandangTotals: totals,
);
}
Future<void> startRealtimePanenNotifications() async {
if (_realtimeListenerStarted) return;
final ref = _database.ref('riwayat/records');
try {
final snapshot = await ref.get();
if (snapshot.exists && snapshot.value is Map) {
final data = Map<dynamic, dynamic>.from(snapshot.value as Map);
for (final entry in data.entries) {
_knownRealtimeRecordKeys.add(entry.key.toString());
}
}
} catch (e) {
if (kDebugMode) {
print('⚠️ Seed realtime panen keys gagal: $e');
}
}
_recordsRealtimeSubscription = ref.onChildAdded.listen((event) async {
try {
final key = event.snapshot.key;
if (key == null || _knownRealtimeRecordKeys.contains(key)) return;
_knownRealtimeRecordKeys.add(key);
if (!event.snapshot.exists || event.snapshot.value is! Map) return;
final panenData =
Map<dynamic, dynamic>.from(event.snapshot.value as Map);
final tanggalRaw = panenData['tanggal_panen'];
final tanggal = DateTime.tryParse('${tanggalRaw ?? ''}');
if (tanggal == null) return;
final newPanen = Panen(
id: panenData['id'] ?? '',
kandangId: panenData['kandang_id'] ?? '',
kandangNama: panenData['kandang_nama'] ?? '',
jumlahTelur: panenData['jumlah_telur'] ?? 0,
tanggalPanen: tanggal,
jam: panenData['jam'] ?? '',
catatan: panenData['catatan'] ?? '',
jenisPanen: panenData['jenis_panen'],
sensorSnapshot: panenData['sensor_snapshot'],
panenSebelumnya: panenData['panen_sebelumnya'],
);
final exists =
_panens.any((p) => p.id == newPanen.id && p.id.isNotEmpty);
if (!exists) {
_panens.add(newPanen);
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
notifyListeners();
}
await _notifySummaryForJenis(newPanen);
} catch (e) {
if (kDebugMode) {
print('⚠️ Realtime panen notification error: $e');
}
}
});
_realtimeListenerStarted = true;
if (kDebugMode) {
print('✅ Realtime panen notification listener started');
}
}
Future<void> stopRealtimePanenNotifications() async {
await _recordsRealtimeSubscription?.cancel();
_recordsRealtimeSubscription = null;
_realtimeListenerStarted = false;
_knownRealtimeRecordKeys.clear();
}
List<Panen> getPanenByKandang(String kandangId) { List<Panen> getPanenByKandang(String kandangId) {
return _panens.where((p) => p.kandangId == kandangId).toList(); return _panens.where((p) => p.kandangId == kandangId).toList();
} }
@ -34,7 +175,7 @@ class PanenProvider extends ChangeNotifier {
}).toList(); }).toList();
} }
void addPanen( Panen addPanen(
String kandangId, String kandangId,
String kandangNama, String kandangNama,
int jumlahTelur, int jumlahTelur,
@ -55,6 +196,7 @@ class PanenProvider extends ChangeNotifier {
_panens.add(newPanen); _panens.add(newPanen);
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen)); _panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
notifyListeners(); notifyListeners();
return newPanen;
} }
void deletePanen(String id) { void deletePanen(String id) {
@ -109,7 +251,7 @@ class PanenProvider extends ChangeNotifier {
} }
/// Add panen dari sensor snapshot dengan tracking jenis panen (pagi/sore) /// Add panen dari sensor snapshot dengan tracking jenis panen (pagi/sore)
void addPanenFromSensor( Panen addPanenFromSensor(
String kandangId, String kandangId,
String kandangNama, String kandangNama,
int sensorValue, int sensorValue,
@ -141,6 +283,7 @@ class PanenProvider extends ChangeNotifier {
_panens.add(newPanen); _panens.add(newPanen);
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen)); _panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
notifyListeners(); notifyListeners();
return newPanen;
} }
// ============================================ // ============================================
@ -179,7 +322,7 @@ class PanenProvider extends ChangeNotifier {
/// AUTO-CAPTURE DARI SCHEDULER /// AUTO-CAPTURE DARI SCHEDULER
/// Panggil ini saat scheduler trigger jam 09:00 /// Panggil ini saat scheduler trigger jam 09:00
Future<void> captureScheduledPanenPagi( Future<Panen?> captureScheduledPanenPagi(
String kandangId, String kandangId,
String kandangNama, String kandangNama,
int sensorValue, int sensorValue,
@ -190,7 +333,7 @@ class PanenProvider extends ChangeNotifier {
if (kDebugMode) { if (kDebugMode) {
print('⚠️ Panen pagi untuk $kandangId sudah di-record hari ini'); print('⚠️ Panen pagi untuk $kandangId sudah di-record hari ini');
} }
return; return null;
} }
// Time-Window Pagi: Ambil nilai sensor sebagai total telur pagi // Time-Window Pagi: Ambil nilai sensor sebagai total telur pagi
@ -201,13 +344,16 @@ class PanenProvider extends ChangeNotifier {
_snapshotPagiHariIni[kandangId] = sensorValue; _snapshotPagiHariIni[kandangId] = sensorValue;
const uuid = Uuid(); const uuid = Uuid();
final now = DateTime.now();
final jamStr =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final newPanen = Panen( final newPanen = Panen(
id: uuid.v4(), id: uuid.v4(),
kandangId: kandangId, kandangId: kandangId,
kandangNama: kandangNama, kandangNama: kandangNama,
jumlahTelur: jumlahTelur, jumlahTelur: jumlahTelur,
tanggalPanen: DateTime.now(), tanggalPanen: now,
jam: '09:00', jam: jamStr,
catatan: 'Auto-capture PAGI dari scheduler', catatan: 'Auto-capture PAGI dari scheduler',
jenisPanen: 'pagi', jenisPanen: 'pagi',
sensorSnapshot: sensorValue, sensorSnapshot: sensorValue,
@ -227,17 +373,19 @@ class PanenProvider extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
return newPanen;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('❌ Error capturing panen pagi: $e'); print('❌ Error capturing panen pagi: $e');
} }
return null;
} }
} }
/// AUTO-CAPTURE DARI SCHEDULER /// AUTO-CAPTURE DARI SCHEDULER
/// Panggil ini saat scheduler trigger jam 15:00 /// Panggil ini saat scheduler trigger jam 15:00
/// Dengan delta calculation: nilai_sore - nilai_pagi /// Dengan delta calculation: nilai_sore - nilai_pagi
Future<void> captureScheduledPanenSore( Future<Panen?> captureScheduledPanenSore(
String kandangId, String kandangId,
String kandangNama, String kandangNama,
int sensorValue, int sensorValue,
@ -248,7 +396,7 @@ class PanenProvider extends ChangeNotifier {
if (kDebugMode) { if (kDebugMode) {
print('⚠️ Panen sore untuk $kandangId sudah di-record hari ini'); print('⚠️ Panen sore untuk $kandangId sudah di-record hari ini');
} }
return; return null;
} }
// Ambil nilai pagi hari ini // Ambil nilai pagi hari ini
@ -270,13 +418,16 @@ class PanenProvider extends ChangeNotifier {
_snapshotSoreHariIni[kandangId] = sensorValue; _snapshotSoreHariIni[kandangId] = sensorValue;
const uuid = Uuid(); const uuid = Uuid();
final now = DateTime.now();
final jamStr =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final newPanen = Panen( final newPanen = Panen(
id: uuid.v4(), id: uuid.v4(),
kandangId: kandangId, kandangId: kandangId,
kandangNama: kandangNama, kandangNama: kandangNama,
jumlahTelur: jumlahTelur, jumlahTelur: jumlahTelur,
tanggalPanen: DateTime.now(), tanggalPanen: now,
jam: '15:00', jam: jamStr,
catatan: catatan:
'Auto-capture SORE dari scheduler${nilaiPagi != null ? ' (delta: $sensorValue - $nilaiPagi)' : ''}', 'Auto-capture SORE dari scheduler${nilaiPagi != null ? ' (delta: $sensorValue - $nilaiPagi)' : ''}',
jenisPanen: 'sore', jenisPanen: 'sore',
@ -299,10 +450,12 @@ class PanenProvider extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
return newPanen;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('❌ Error capturing panen sore: $e'); print('❌ Error capturing panen sore: $e');
} }
return null;
} }
} }
@ -509,4 +662,10 @@ class PanenProvider extends ChangeNotifier {
} }
} }
} }
@override
void dispose() {
_recordsRealtimeSubscription?.cancel();
super.dispose();
}
} }

View File

@ -16,6 +16,7 @@ class PenjadwalanProvider extends ChangeNotifier {
List<Penjadwalan> get penjadwalans => _penjadwalans; List<Penjadwalan> get penjadwalans => _penjadwalans;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isMaximalPenjadwalan => _penjadwalans.length >= 2;
/// Initialize provider dengan user ID dan load data dari Firebase /// Initialize provider dengan user ID dan load data dari Firebase
Future<void> initializeWithUser(String userId) async { Future<void> initializeWithUser(String userId) async {
@ -94,20 +95,22 @@ class PenjadwalanProvider extends ChangeNotifier {
await _penjadwalanRef.set({ await _penjadwalanRef.set({
'penjadwalan1': { 'penjadwalan1': {
'kandangId': 'kandang1', 'kandangId': 'global',
'kandangNama': 'Kandang 1', 'kandangNama': 'Semua Kandang',
'jam': '09:00', 'jam': '09:00',
'durasi': '30 menit', 'durasi': '30 menit',
'keterangan': 'Panen pagi', 'keterangan': 'Panen pagi',
'aktif': true, 'aktif': true,
'tipeJadwal': 'pagi',
}, },
'penjadwalan2': { 'penjadwalan2': {
'kandangId': 'kandang2', 'kandangId': 'global',
'kandangNama': 'Kandang 2', 'kandangNama': 'Semua Kandang',
'jam': '15:00', 'jam': '15:00',
'durasi': '30 menit', 'durasi': '30 menit',
'keterangan': 'Panen sore', 'keterangan': 'Panen sore',
'aktif': true, 'aktif': true,
'tipeJadwal': 'sore',
}, },
}); });
} }
@ -142,9 +145,16 @@ class PenjadwalanProvider extends ChangeNotifier {
String kandangNama, String kandangNama,
String jam, String jam,
String durasi, String durasi,
String keterangan, String keterangan, {
) async { String? tipeJadwal,
}) async {
try { try {
// Cek apakah sudah mencapai batas maksimal 2 penjadwalan
if (_penjadwalans.length >= 2) {
print('Error: Maksimal penjadwalan adalah 2');
return;
}
final newId = _getNextPenjadwalanKey(); final newId = _getNextPenjadwalanKey();
final newPenjadwalan = Penjadwalan( final newPenjadwalan = Penjadwalan(
@ -155,6 +165,7 @@ class PenjadwalanProvider extends ChangeNotifier {
durasi: durasi, durasi: durasi,
keterangan: keterangan, keterangan: keterangan,
aktif: true, aktif: true,
tipeJadwal: tipeJadwal,
); );
await _penjadwalanRef.child(newId).set(newPenjadwalan.toJson()); await _penjadwalanRef.child(newId).set(newPenjadwalan.toJson());
@ -182,6 +193,7 @@ class PenjadwalanProvider extends ChangeNotifier {
durasi: durasi, durasi: durasi,
keterangan: keterangan, keterangan: keterangan,
aktif: aktif, aktif: aktif,
tipeJadwal: _penjadwalans[index].tipeJadwal,
); );
await _penjadwalanRef.child(id).set(updated.toJson()); await _penjadwalanRef.child(id).set(updated.toJson());
@ -213,6 +225,7 @@ class PenjadwalanProvider extends ChangeNotifier {
durasi: _penjadwalans[index].durasi, durasi: _penjadwalans[index].durasi,
keterangan: _penjadwalans[index].keterangan, keterangan: _penjadwalans[index].keterangan,
aktif: !_penjadwalans[index].aktif, aktif: !_penjadwalans[index].aktif,
tipeJadwal: _penjadwalans[index].tipeJadwal,
); );
await _penjadwalanRef.child(id).set(updated.toJson()); await _penjadwalanRef.child(id).set(updated.toJson());

View File

@ -23,10 +23,12 @@ class DashboardPage extends StatelessWidget {
kandangKey: 'kandang2', kandangKey: 'kandang2',
); );
final kandang1TodayDisplay = final kandang1TodayDisplay = telurProvider.kandang1HariIni != 0
telurProvider.kandang1HariIni != 0 ? telurProvider.kandang1HariIni : fallbackKandang1Today; ? telurProvider.kandang1HariIni
final kandang2TodayDisplay = : fallbackKandang1Today;
telurProvider.kandang2HariIni != 0 ? telurProvider.kandang2HariIni : fallbackKandang2Today; final kandang2TodayDisplay = telurProvider.kandang2HariIni != 0
? telurProvider.kandang2HariIni
: fallbackKandang2Today;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -559,15 +561,15 @@ class DashboardPage extends StatelessWidget {
final normalizedTarget = kandangKey.toLowerCase(); final normalizedTarget = kandangKey.toLowerCase();
return panenProvider.panens.where((panen) { return panenProvider.panens.where((panen) {
final sameDate = final sameDate = panen.tanggalPanen.year == now.year &&
panen.tanggalPanen.year == now.year &&
panen.tanggalPanen.month == now.month && panen.tanggalPanen.month == now.month &&
panen.tanggalPanen.day == now.day; panen.tanggalPanen.day == now.day;
if (!sameDate) return false; if (!sameDate) return false;
final idText = panen.kandangId.toLowerCase().replaceAll('_', ''); final idText = panen.kandangId.toLowerCase().replaceAll('_', '');
final namaText = panen.kandangNama.toLowerCase().replaceAll(' ', ''); final namaText = panen.kandangNama.toLowerCase().replaceAll(' ', '');
return idText.contains(normalizedTarget) || namaText.contains(normalizedTarget); return idText.contains(normalizedTarget) ||
namaText.contains(normalizedTarget);
}).fold<int>(0, (sum, panen) => sum + panen.jumlahTelur); }).fold<int>(0, (sum, panen) => sum + panen.jumlahTelur);
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/penjadwalan_provider.dart'; import '../providers/penjadwalan_provider.dart';
@ -37,7 +38,7 @@ class _HomePageState extends State<HomePage> {
child: Image.asset('assets/icons/telurku.png'), child: Image.asset('assets/icons/telurku.png'),
), ),
title: Text( title: Text(
'TelurKu', 'TEFA Unggas Polije',
style: TextStyle( style: TextStyle(
color: Colors.grey.shade800, color: Colors.grey.shade800,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -120,9 +121,81 @@ class _HomePageState extends State<HomePage> {
], ],
), ),
); );
} else if (value == 1) {
Navigator.pushNamed(context, '/debug/panen');
} else if (value == 2) {
Navigator.pushNamed(context, '/debug/notifikasi');
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
if (kDebugMode)
PopupMenuItem(
value: 1,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.blue.shade400,
Colors.blue.shade600,
],
),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.science,
color: Colors.white,
size: 18,
),
),
const SizedBox(width: 12),
Text(
'Debug Panen',
style: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
if (kDebugMode)
PopupMenuItem(
value: 2,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.green.shade400,
Colors.green.shade600,
],
),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.notifications,
color: Colors.white,
size: 18,
),
),
const SizedBox(width: 12),
Text(
'Debug Notifikasi',
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
PopupMenuItem( PopupMenuItem(
value: 0, value: 0,
child: Row( child: Row(

View File

@ -145,6 +145,7 @@ class _KontrolPageState extends State<KontrolPage> {
itemCount: penjadwalanProvider.penjadwalans.length, itemCount: penjadwalanProvider.penjadwalans.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final jadwal = penjadwalanProvider.penjadwalans[index]; final jadwal = penjadwalanProvider.penjadwalans[index];
final isSore = jadwal.tipeJadwal == 'sore';
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@ -170,12 +171,39 @@ class _KontrolPageState extends State<KontrolPage> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
jadwal.jam, children: [
style: const TextStyle( Text(
fontSize: 18, jadwal.jam,
fontWeight: FontWeight.bold, style: const TextStyle(
), fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: isSore
? Colors.deepOrange.shade300
: Colors.amber.shade300,
borderRadius: BorderRadius.circular(6),
),
child: Text(
isSore ? 'Sore' : 'Pagi',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isSore
? Colors.white
: Colors.brown.shade700,
),
),
),
],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@ -262,15 +290,25 @@ class _KontrolPageState extends State<KontrolPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: Consumer<PenjadwalanProvider>(
onPressed: () => _showTambahJadwalBaru(context), builder: (context, penjadwalanProvider, child) {
icon: const Icon(Icons.add), final isDisabled = penjadwalanProvider.isMaximalPenjadwalan;
label: const Text('Tambah Jadwal Baru'), return ElevatedButton.icon(
style: ElevatedButton.styleFrom( onPressed: isDisabled
backgroundColor: Colors.green.shade600, ? () => _showPenjadwalanDisabledDialog(context)
foregroundColor: Colors.white, : () => _showTambahJadwalBaru(context),
padding: const EdgeInsets.symmetric(vertical: 12), icon: const Icon(Icons.add),
), label: const Text('Tambah Jadwal Baru'),
style: ElevatedButton.styleFrom(
backgroundColor: isDisabled
? Colors.grey.shade400
: Colors.green.shade600,
foregroundColor:
isDisabled ? Colors.grey.shade700 : Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
);
},
), ),
), ),
], ],
@ -394,15 +432,25 @@ class _KontrolPageState extends State<KontrolPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: Consumer<KandangProvider>(
onPressed: () => _showTambahKandangDialog(context), builder: (context, kandangProvider, child) {
icon: const Icon(Icons.add), final isDisabled = kandangProvider.kandangs.length >= 2;
label: const Text('Tambah Kandang Baru'), return ElevatedButton.icon(
style: ElevatedButton.styleFrom( onPressed: isDisabled
backgroundColor: Colors.green.shade600, ? () => _showKandangDisabledDialog(context)
foregroundColor: Colors.white, : () => _showTambahKandangDialog(context),
padding: const EdgeInsets.symmetric(vertical: 12), icon: const Icon(Icons.add),
), label: const Text('Tambah Kandang Baru'),
style: ElevatedButton.styleFrom(
backgroundColor: isDisabled
? Colors.grey.shade400
: Colors.green.shade600,
foregroundColor:
isDisabled ? Colors.grey.shade700 : Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
);
},
), ),
), ),
], ],
@ -475,10 +523,20 @@ class _KontrolPageState extends State<KontrolPage> {
} }
void _showTambahJadwalBaru(BuildContext context) { void _showTambahJadwalBaru(BuildContext context) {
final penjadwalanProvider = context.read<PenjadwalanProvider>();
final jamController = TextEditingController(); final jamController = TextEditingController();
final durasiController = TextEditingController(); final durasiController = TextEditingController();
final keteranganController = TextEditingController(); final keteranganController = TextEditingController();
// Tentukan tipe jadwal berdasarkan jumlah jadwal yang sudah ada
String tipeJadwal = 'pagi'; // Default pagi
String judulTipe = 'Pagi';
if (penjadwalanProvider.penjadwalans.isNotEmpty) {
tipeJadwal = 'sore';
judulTipe = 'Sore';
}
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -486,6 +544,57 @@ class _KontrolPageState extends State<KontrolPage> {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tipeJadwal == 'sore'
? Colors.deepOrange.shade50
: Colors.amber.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
tipeJadwal == 'sore' ? Icons.nights_stay : Icons.wb_sunny,
color: tipeJadwal == 'sore'
? Colors.deepOrange.shade600
: Colors.amber.shade600,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Jadwal Panen $judulTipe',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: tipeJadwal == 'sore'
? Colors.deepOrange.shade700
: Colors.amber.shade700,
),
),
),
],
),
),
const SizedBox(height: 16),
if (tipeJadwal == 'sore') ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.shade50,
border: Border.all(color: Colors.blue.shade200),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Info: Hasil panen sore akan otomatis direset setiap hari',
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade700,
),
),
),
const SizedBox(height: 12),
],
TextField( TextField(
controller: jamController, controller: jamController,
decoration: const InputDecoration( decoration: const InputDecoration(
@ -527,6 +636,7 @@ class _KontrolPageState extends State<KontrolPage> {
jamController.text, jamController.text,
durasiController.text, durasiController.text,
keteranganController.text, keteranganController.text,
tipeJadwal: tipeJadwal,
); );
Navigator.pop(context); Navigator.pop(context);
} }
@ -538,6 +648,44 @@ class _KontrolPageState extends State<KontrolPage> {
); );
} }
void _showPenjadwalanDisabledDialog(BuildContext context) {
final penjadwalanProvider = context.read<PenjadwalanProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Batas Penjadwalan Tercapai'),
content: Text(
'Anda sudah memiliki ${penjadwalanProvider.penjadwalans.length} jadwal panen. Batas maksimal adalah 2 jadwal (pagi dan sore). Silahkan hapus salah satu jadwal terlebih dahulu sebelum menambahkan jadwal baru.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
void _showKandangDisabledDialog(BuildContext context) {
final kandangProvider = context.read<KandangProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Batas Kandang Tercapai'),
content: Text(
'Anda sudah memiliki ${kandangProvider.kandangs.length} kandang. Batas maksimal kandang adalah 2. Silahkan hubungi pembuat aplikasi untuk menambahkan kandang lebih dari 2.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
void _showTambahKandangDialog(BuildContext context) { void _showTambahKandangDialog(BuildContext context) {
final namaController = TextEditingController(); final namaController = TextEditingController();
final jumlahAyamController = TextEditingController(); final jumlahAyamController = TextEditingController();

View File

@ -64,12 +64,19 @@ class LandingPage extends StatelessWidget {
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
// App Name // App Name
Text( SizedBox(
'TelurKu', width: double.infinity,
style: TextStyle( child: FittedBox(
fontSize: 48, fit: BoxFit.scaleDown,
fontWeight: FontWeight.bold, child: Text(
color: Colors.orange.shade700, 'TEFA UNGGAS POLIJE',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),

View File

@ -200,31 +200,6 @@ class _LoginPageState extends State<LoginPage> {
), ),
), ),
), ),
const SizedBox(height: 12),
// Forget Password Link
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () {
// TODO: Navigate to forgot password page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Fitur ini akan segera tersedia',
),
),
);
},
child: Text(
'Lupa Password?',
style: TextStyle(
fontSize: 14,
color: Colors.orange.shade600,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 30), const SizedBox(height: 30),
// Login Button // Login Button
Consumer<AuthProvider>( Consumer<AuthProvider>(

View File

@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../services/notification_service.dart';
class NotificationDebugScreen extends StatefulWidget {
const NotificationDebugScreen({super.key});
@override
State<NotificationDebugScreen> createState() =>
_NotificationDebugScreenState();
}
class _NotificationDebugScreenState extends State<NotificationDebugScreen> {
bool _isLoading = false;
bool? _canScheduleExactNotifications;
String? _lastStatus;
List<PendingNotificationRequest> _pendingNotifications = [];
@override
void initState() {
super.initState();
_loadPendingNotifications();
}
Future<void> _setStatus(String message) async {
if (!mounted) return;
setState(() {
_lastStatus = message;
});
}
Future<void> _loadPendingNotifications() async {
setState(() {
_isLoading = true;
});
try {
await NotificationService.instance.initialize();
final canScheduleExact =
await NotificationService.instance.canScheduleExactNotifications();
final pending = await NotificationService.instance.pendingNotifications();
if (!mounted) return;
setState(() {
_pendingNotifications = pending;
_canScheduleExactNotifications = canScheduleExact;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_lastStatus = 'Gagal memuat notifikasi tertunda: $e';
});
}
}
Future<void> _sendSamplePanenPagi() async {
await _runAction(() async {
await NotificationService.instance.showPanenSummaryNotification(
jenisPanen: 'pagi',
kandangTotals: const {
'kandang 1': 15,
'kandang 2': 15,
},
);
}, 'Notif panen pagi terkirim');
}
Future<void> _sendSamplePanenSore() async {
await _runAction(() async {
await NotificationService.instance.showPanenSummaryNotification(
jenisPanen: 'sore',
kandangTotals: const {
'kandang 1': 12,
'kandang 2': 8,
},
);
}, 'Notif panen sore terkirim');
}
Future<void> _sendImmediateSimple() async {
await _runAction(() async {
await NotificationService.instance.showInstantNotification(
title: 'Tes Notifikasi',
body: 'Notifikasi lokal berhasil dikirim dari halaman debug.',
payload: 'debug:immediate',
);
}, 'Notif langsung terkirim');
}
Future<void> _scheduleThirtySecondNotification() async {
await _runAction(() async {
await NotificationService.instance.scheduleNotification(
title: 'Tes Notifikasi Terjadwal',
body:
'Notif ini dijadwalkan 30 detik sebelumnya. Coba minimize atau tutup app setelah dijadwalkan.',
delay: const Duration(seconds: 30),
prioritizeBackgroundDelivery: true,
payload: 'debug:scheduled_30s',
);
}, 'Notif 30 detik dijadwalkan');
await _loadPendingNotifications();
}
Future<void> _requestExactAlarmPermission() async {
await _runAction(() async {
final granted =
await NotificationService.instance.requestExactAlarmsPermission();
if (!granted) {
throw 'Izin exact alarm belum diberikan. Aktifkan dari settings aplikasi.';
}
}, 'Izin exact alarm berhasil diminta');
await _loadPendingNotifications();
}
Future<void> _cancelAllNotifications() async {
await _runAction(() async {
await NotificationService.instance.cancelAll();
}, 'Semua notifikasi dibatalkan');
await _loadPendingNotifications();
}
Future<void> _runAction(
Future<void> Function() action, String successText) async {
setState(() {
_isLoading = true;
});
try {
await NotificationService.instance.initialize();
await action();
await _setStatus(successText);
} catch (e) {
await _setStatus('Error: $e');
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug Notifikasi'),
backgroundColor: Colors.orange,
actions: [
IconButton(
onPressed: _isLoading ? null : _loadPendingNotifications,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInfoCard(),
const SizedBox(height: 16),
_buildActionCard(),
const SizedBox(height: 16),
_buildPendingCard(),
if (_lastStatus != null) ...[
const SizedBox(height: 16),
_buildStatusCard(),
],
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
elevation: 2,
color: Colors.blue.shade50,
child: const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cara kerja notifikasi',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'• Notifikasi langsung akan muncul saat app masih aktif atau berjalan di background.\n'
'• Notifikasi terjadwal tetap bisa muncul walaupun app diminimize atau ditutup, selama notif sudah dijadwalkan sebelum app berhenti.\n'
'• Kalau exact alarm tidak aktif, Android bisa menunda jadwal lokal. Gunakan tombol Aktifkan Exact Alarm agar notif 30 detik lebih presisi.\n'
'• Jika yang diminta adalah notif dari event server saat app benar-benar mati total, itu perlu push notification dari backend/FCM.',
style: TextStyle(fontSize: 13, height: 1.4),
),
],
),
),
);
}
Widget _buildActionCard() {
return Card(
elevation: 3,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Aksi Debug',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _isLoading ? null : _sendSamplePanenPagi,
icon: const Icon(Icons.wb_sunny),
label: const Text('Simulasi Panen Pagi'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _isLoading ? null : _sendSamplePanenSore,
icon: const Icon(Icons.wb_twilight),
label: const Text('Simulasi Panen Sore'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _isLoading ? null : _sendImmediateSimple,
icon: const Icon(Icons.notifications_active),
label: const Text('Kirim Notifikasi Langsung'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _isLoading ? null : _scheduleThirtySecondNotification,
icon: const Icon(Icons.schedule),
label: const Text('Jadwalkan Notif 30 Detik'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _isLoading ? null : _requestExactAlarmPermission,
icon: const Icon(Icons.alarm_on),
label: const Text('Aktifkan Exact Alarm'),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _isLoading ? null : _cancelAllNotifications,
icon: const Icon(Icons.cancel),
label: const Text('Batalkan Semua Notifikasi'),
),
],
),
),
);
}
Widget _buildPendingCard() {
return Card(
elevation: 3,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Notifikasi Tertunda',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Exact alarm: ${_canScheduleExactNotifications == null ? 'memuat...' : (_canScheduleExactNotifications! ? 'aktif' : 'belum aktif')}',
style: TextStyle(
fontSize: 13,
color: _canScheduleExactNotifications == true
? Colors.green.shade700
: Colors.red.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
if (_pendingNotifications.isEmpty)
const Text('Belum ada notifikasi terjadwal.')
else
..._pendingNotifications.map(
(notification) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(notification.title ?? '(tanpa judul)'),
const SizedBox(height: 4),
Text(notification.body ?? '(tanpa isi)'),
const SizedBox(height: 4),
Text(
'ID: ${notification.id}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
),
),
],
),
),
);
}
Widget _buildStatusCard() {
final success = _lastStatus!.contains('terkirim') ||
_lastStatus!.contains('dijadwalkan') ||
_lastStatus!.contains('dibatalkan');
return Card(
color: success ? Colors.green.shade50 : Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_lastStatus!,
style: TextStyle(
fontWeight: FontWeight.w600,
color: success ? Colors.green.shade800 : Colors.red.shade800,
),
),
),
);
}
}

View File

@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/kandang_provider.dart';
import '../providers/panen_provider.dart';
import '../services/panen_auto_capture_service.dart';
import '../services/panen_scheduler.dart'; import '../services/panen_scheduler.dart';
/// Debug Screen untuk testing Panen Scheduler /// Debug Screen untuk testing Panen Scheduler
@ -57,21 +61,55 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
}); });
try { try {
final result = await PanenScheduler.triggerPanenPagiManual(); final panenProvider = context.read<PanenProvider>();
final kandangProvider = context.read<KandangProvider>();
await PanenAutoCaptureService.triggerMorningCapture(
panenProvider,
kandangProvider,
);
final today = DateTime.now();
final todayPagi = panenProvider.panens.where((p) {
return p.jenisPanen == 'pagi' &&
p.tanggalPanen.year == today.year &&
p.tanggalPanen.month == today.month &&
p.tanggalPanen.day == today.day;
}).toList();
final total = todayPagi.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
final k1 = todayPagi
.where(
(p) =>
p.kandangId.toLowerCase() == 'kandang_1' ||
p.kandangId.toLowerCase() == 'kandang1',
)
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
final k2 = todayPagi
.where(
(p) =>
p.kandangId.toLowerCase() == 'kandang_2' ||
p.kandangId.toLowerCase() == 'kandang2',
)
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
final bool success = todayPagi.isNotEmpty;
final String message = success
? 'Panen Pagi berhasil diproses (total $total | kandang 1: $k1 | kandang 2: $k2)'
: 'Belum ada data panen pagi yang tersimpan';
await _loadData(); // Reload snapshot await _loadData(); // Reload snapshot
setState(() { setState(() {
_lastResult = result['success'] _lastResult = success ? '$message' : '$message';
? '${result['message']}'
: '${result['message']}';
_isLoading = false; _isLoading = false;
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(result['message']), content: Text(message),
backgroundColor: result['success'] ? Colors.green : Colors.red, backgroundColor: success ? Colors.green : Colors.red,
), ),
); );
} }
@ -90,21 +128,55 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
}); });
try { try {
final result = await PanenScheduler.triggerPanenSoreManual(); final panenProvider = context.read<PanenProvider>();
final kandangProvider = context.read<KandangProvider>();
await PanenAutoCaptureService.triggerAfternoonCapture(
panenProvider,
kandangProvider,
);
final today = DateTime.now();
final todaySore = panenProvider.panens.where((p) {
return p.jenisPanen == 'sore' &&
p.tanggalPanen.year == today.year &&
p.tanggalPanen.month == today.month &&
p.tanggalPanen.day == today.day;
}).toList();
final total = todaySore.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
final k1 = todaySore
.where(
(p) =>
p.kandangId.toLowerCase() == 'kandang_1' ||
p.kandangId.toLowerCase() == 'kandang1',
)
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
final k2 = todaySore
.where(
(p) =>
p.kandangId.toLowerCase() == 'kandang_2' ||
p.kandangId.toLowerCase() == 'kandang2',
)
.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
final bool success = todaySore.isNotEmpty;
final String message = success
? 'Panen Sore berhasil diproses (total $total | kandang 1: $k1 | kandang 2: $k2)'
: 'Belum ada data panen sore yang tersimpan';
await _loadData(); // Reload snapshot await _loadData(); // Reload snapshot
setState(() { setState(() {
_lastResult = result['success'] _lastResult = success ? '$message' : '$message';
? '${result['message']}'
: '${result['message']}';
_isLoading = false; _isLoading = false;
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(result['message']), content: Text(message),
backgroundColor: result['success'] ? Colors.green : Colors.red, backgroundColor: success ? Colors.green : Colors.red,
), ),
); );
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/panen_model.dart';
import '../providers/panen_provider.dart'; import '../providers/panen_provider.dart';
import '../services/excel_export_service.dart';
class RiwayatPage extends StatefulWidget { class RiwayatPage extends StatefulWidget {
const RiwayatPage({super.key}); const RiwayatPage({super.key});
@ -169,24 +171,71 @@ class _RiwayatPageState extends State<RiwayatPage> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( LayoutBuilder(
mainAxisAlignment: MainAxisAlignment.spaceBetween, builder: (context, constraints) {
children: [ final compact = constraints.maxWidth < 520;
Text( final updateText = _lastRefreshAt != null
_lastRefreshAt != null ? 'Update: ${DateFormat('dd/MM/yyyy HH:mm').format(_lastRefreshAt!)}'
? 'Update: ${DateFormat('dd/MM/yyyy HH:mm').format(_lastRefreshAt!)}' : 'Belum pernah refresh';
: 'Belum pernah refresh',
style: TextStyle( final actionButtons = Wrap(
fontSize: 12, spacing: 8,
color: Colors.grey.shade600, runSpacing: 8,
), children: [
), TextButton.icon(
TextButton.icon( onPressed: _handleRefresh,
onPressed: _handleRefresh, icon: const Icon(Icons.refresh, size: 16),
icon: const Icon(Icons.refresh, size: 16), label: const Text('Refresh'),
label: const Text('Refresh'), ),
), ElevatedButton.icon(
], onPressed: _downloadExcelReport,
icon: const Icon(Icons.download, size: 16),
label: const Text('Download Excel'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
foregroundColor: Colors.white,
),
),
],
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
updateText,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
actionButtons,
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
updateText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
),
const SizedBox(width: 8),
actionButtons,
],
);
},
), ),
], ],
), ),
@ -204,7 +253,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
final panenProvider = context.watch<PanenProvider>(); final panenProvider = context.watch<PanenProvider>();
final panens = _getFilteredPanens(panenProvider); final panens = _getFilteredPanens(panenProvider);
final groupedPanens = <DateTime, List<dynamic>>{}; final groupedPanens = <DateTime, List<Panen>>{};
for (final panen in panens) { for (final panen in panens) {
final key = DateTime( final key = DateTime(
panen.tanggalPanen.year, panen.tanggalPanen.year,
@ -221,8 +270,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen)); .sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
} }
final totalTelur = final totalTelur = panens.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
panens.fold<int>(0, (sum, p) => sum + (p.jumlahTelur as int));
final listChildren = <Widget>[ final listChildren = <Widget>[
Container( Container(
@ -328,8 +376,7 @@ class _RiwayatPageState extends State<RiwayatPage> {
} else { } else {
for (final date in sortedDates) { for (final date in sortedDates) {
final items = groupedPanens[date]!; final items = groupedPanens[date]!;
final dailyTotal = final dailyTotal = items.fold<int>(0, (sum, p) => sum + p.jumlahTelur);
items.fold<int>(0, (sum, p) => sum + (p.jumlahTelur as int));
listChildren.add( listChildren.add(
Container( Container(
@ -350,25 +397,12 @@ class _RiwayatPageState extends State<RiwayatPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween, DateFormat('dd MMMM yyyy').format(date),
children: [ style: const TextStyle(
Text( fontSize: 15,
DateFormat('dd MMMM yyyy').format(date), fontWeight: FontWeight.bold,
style: const TextStyle( ),
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
Text(
'$dailyTotal telur',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
...items.map( ...items.map(
@ -415,6 +449,27 @@ class _RiwayatPageState extends State<RiwayatPage> {
), ),
), ),
), ),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Total telur hari ini: $dailyTotal',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.orange.shade800,
),
),
),
], ],
), ),
), ),
@ -433,8 +488,8 @@ class _RiwayatPageState extends State<RiwayatPage> {
); );
} }
List<dynamic> _getFilteredPanens(PanenProvider panenProvider) { List<Panen> _getFilteredPanens(PanenProvider panenProvider) {
final allPanens = List<dynamic>.from(panenProvider.panens); final allPanens = List<Panen>.from(panenProvider.panens);
final startDate = _selectedStartDate; final startDate = _selectedStartDate;
final endDate = _selectedEndDate; final endDate = _selectedEndDate;
@ -504,4 +559,95 @@ class _RiwayatPageState extends State<RiwayatPage> {
}); });
} }
} }
Future<void> _downloadExcelReport() async {
if (_selectedStartDate == null || _selectedEndDate == null) {
_showErrorMessage('Pilih tanggal mulai dan akhir terlebih dahulu');
return;
}
if (_selectedStartDate!.isAfter(_selectedEndDate!)) {
_showErrorMessage(
'Tanggal mulai tidak boleh lebih besar dari tanggal akhir');
return;
}
try {
_showLoadingDialog('Membuat laporan Excel...');
final panenProvider = context.read<PanenProvider>();
final panens = _getFilteredPanens(panenProvider);
final filePath = await ExcelExportService.exportWeeklyReport(
panens: panens,
startDate: _selectedStartDate!,
endDate: _selectedEndDate!,
kandangFilter: _selectedKandang,
);
if (!mounted) return;
Navigator.of(context).pop(); // Close loading dialog
_showSuccessMessage('Laporan berhasil dibuat:\n$filePath');
} catch (e) {
if (!mounted) return;
Navigator.of(context).pop(); // Close loading dialog
_showErrorMessage('Gagal membuat laporan: $e');
}
}
void _showLoadingDialog(String message) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message),
],
),
);
},
);
}
void _showSuccessMessage(String message) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('✓ Sukses'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
},
);
}
void _showErrorMessage(String message) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('✗ Error'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
},
);
}
} }

View File

@ -0,0 +1,212 @@
import 'package:excel/excel.dart';
import 'package:intl/intl.dart';
import '../models/panen_model.dart';
import 'excel_save_helper.dart';
class ExcelExportService {
static Future<String> exportWeeklyReport({
required List<Panen> panens,
required DateTime startDate,
required DateTime endDate,
required String kandangFilter,
}) async {
try {
print('[ExcelExportService] Starting export...');
final bytes = _generateExcelContent(
panens,
startDate,
endDate,
kandangFilter,
);
final now = DateTime.now();
final fileName = 'Laporan_Telur_${_formatDate(now)}.xlsx';
final savedLocation = await saveReportFile(
fileName: fileName,
bytes: bytes,
mimeType:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
print('[ExcelExportService] Export completed successfully');
return savedLocation;
} catch (e, stackTrace) {
print('[ExcelExportService] ERROR: $e');
print('[ExcelExportService] Stack trace: $stackTrace');
throw Exception('Error creating report: $e');
}
}
static String _formatDate(DateTime date) {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final year = date.year;
return '$day-$month-$year';
}
static List<int> _generateExcelContent(
List<Panen> panens,
DateTime startDate,
DateTime endDate,
String kandangFilter,
) {
final excel = Excel.createExcel();
const sheetName = 'Laporan';
final defaultSheet = excel.getDefaultSheet();
if (defaultSheet != null && defaultSheet != sheetName) {
excel.rename(defaultSheet, sheetName);
}
final sheet = excel[sheetName];
final boldStyle = CellStyle(bold: true);
CellIndex idx(int row, int col) =>
CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row);
void writeText(int row, int col, String value, {bool bold = false}) {
sheet.updateCell(
idx(row, col),
TextCellValue(value),
cellStyle: bold ? boldStyle : null,
);
}
void writeInt(int row, int col, int value, {bool bold = false}) {
sheet.updateCell(
idx(row, col),
IntCellValue(value),
cellStyle: bold ? boldStyle : null,
);
}
int totalKandang1Pagi = 0;
int totalKandang1Sore = 0;
int totalKandang2Pagi = 0;
int totalKandang2Sore = 0;
int totalOverall = 0;
final perHari = <DateTime, _DailyReport>{};
for (final panen in panens) {
final dateKey = DateTime(
panen.tanggalPanen.year,
panen.tanggalPanen.month,
panen.tanggalPanen.day,
);
final daily = perHari.putIfAbsent(dateKey, () => _DailyReport());
final jumlah = panen.jumlahTelur;
final normalized = _normalizeKandang(panen.kandangId, panen.kandangNama);
final jenis = _normalizeJenisPanen(panen);
if (normalized.contains('kandang1')) {
if (jenis == 'pagi') {
totalKandang1Pagi += jumlah;
daily.kandang1Pagi += jumlah;
} else if (jenis == 'sore') {
totalKandang1Sore += jumlah;
daily.kandang1Sore += jumlah;
}
} else if (normalized.contains('kandang2')) {
if (jenis == 'pagi') {
totalKandang2Pagi += jumlah;
daily.kandang2Pagi += jumlah;
} else if (jenis == 'sore') {
totalKandang2Sore += jumlah;
daily.kandang2Sore += jumlah;
}
}
totalOverall += jumlah;
daily.total += jumlah;
}
final periodText =
'${DateFormat('dd/MM/yyyy').format(startDate)} - ${DateFormat('dd/MM/yyyy').format(endDate)}';
final filterText =
kandangFilter == 'semua' ? 'Semua Kandang' : kandangFilter;
writeText(0, 0, 'LAPORAN PRODUKSI TELUR', bold: true);
writeText(1, 0, 'Periode', bold: true);
writeText(1, 1, periodText);
writeText(2, 0, 'Filter Kandang', bold: true);
writeText(2, 1, filterText);
final totalKandang1 = totalKandang1Pagi + totalKandang1Sore;
final totalKandang2 = totalKandang2Pagi + totalKandang2Sore;
writeText(4, 0, 'Jumlah total telur kandang 1 (pagi)', bold: true);
writeInt(4, 1, totalKandang1Pagi, bold: true);
writeText(5, 0, 'Jumlah total telur kandang 1 (sore)', bold: true);
writeInt(5, 1, totalKandang1Sore, bold: true);
writeText(6, 0, 'Jumlah total telur kandang 1', bold: true);
writeInt(6, 1, totalKandang1, bold: true);
writeText(8, 0, 'Jumlah total telur kandang 2 (pagi)', bold: true);
writeInt(8, 1, totalKandang2Pagi, bold: true);
writeText(9, 0, 'Jumlah total telur kandang 2 (sore)', bold: true);
writeInt(9, 1, totalKandang2Sore, bold: true);
writeText(10, 0, 'Jumlah total telur kandang 2', bold: true);
writeInt(10, 1, totalKandang2, bold: true);
writeText(12, 0, 'Total keseluruhan', bold: true);
writeInt(12, 1, totalOverall, bold: true);
writeText(14, 0, 'Tanggal', bold: true);
writeText(14, 1, 'Kandang 1 Pagi', bold: true);
writeText(14, 2, 'Kandang 1 Sore', bold: true);
writeText(14, 3, 'Kandang 2 Pagi', bold: true);
writeText(14, 4, 'Kandang 2 Sore', bold: true);
writeText(14, 5, 'Total Telur Per Hari', bold: true);
final sortedDates = perHari.keys.toList()..sort((a, b) => a.compareTo(b));
var row = 15;
for (final date in sortedDates) {
final daily = perHari[date]!;
writeText(row, 0, DateFormat('dd/MM/yyyy').format(date));
writeInt(row, 1, daily.kandang1Pagi);
writeInt(row, 2, daily.kandang1Sore);
writeInt(row, 3, daily.kandang2Pagi);
writeInt(row, 4, daily.kandang2Sore);
writeInt(row, 5, daily.total);
row++;
}
final encoded = excel.encode();
if (encoded == null) {
throw Exception('Gagal membuat file Excel');
}
return encoded;
}
static String _normalizeKandang(String kandangId, String kandangNama) {
return '${kandangId.toLowerCase()} ${kandangNama.toLowerCase()}'
.replaceAll('_', '')
.replaceAll(' ', '');
}
static String _normalizeJenisPanen(Panen panen) {
final explicit = (panen.jenisPanen ?? '').trim().toLowerCase();
if (explicit == 'pagi' || explicit == 'sore') {
return explicit;
}
final match = RegExp(r'^(\d{1,2})[:.]').firstMatch(panen.jam.trim());
final hour = match != null ? int.tryParse(match.group(1)!) : null;
if (hour != null) {
return hour < 12 ? 'pagi' : 'sore';
}
return 'sore';
}
}
class _DailyReport {
int kandang1Pagi = 0;
int kandang1Sore = 0;
int kandang2Pagi = 0;
int kandang2Sore = 0;
int total = 0;
}

View File

@ -0,0 +1,2 @@
export 'excel_save_helper_io.dart'
if (dart.library.html) 'excel_save_helper_web.dart';

View File

@ -0,0 +1,35 @@
import 'dart:io';
import 'package:media_store_plus/media_store_plus.dart';
Future<String> saveReportFile({
required String fileName,
required List<int> bytes,
String? mimeType,
}) async {
final tempDir = Directory.systemTemp;
final tempFilePath = '${tempDir.path}/$fileName';
final tempFile = File(tempFilePath);
await tempFile.writeAsBytes(bytes, flush: true);
try {
if (Platform.isAndroid) {
await MediaStore.ensureInitialized();
MediaStore.appFolder = 'TelurKu';
final mediaStore = MediaStore();
final saveInfo = await mediaStore.saveFile(
tempFilePath: tempFilePath,
dirType: DirType.download,
dirName: DirName.download,
);
if (saveInfo != null) {
return 'Download/TelurKu/${saveInfo.name}';
}
}
} catch (_) {
// Fallback to temp file when direct save is not available.
}
return tempFilePath;
}

View File

@ -0,0 +1,23 @@
import 'package:universal_html/html.dart' as html;
Future<String> saveReportFile({
required String fileName,
required List<int> bytes,
String? mimeType,
}) async {
final blob = html.Blob([
bytes,
], mimeType ?? 'application/octet-stream');
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..setAttribute('download', fileName)
..style.display = 'none';
html.document.body?.append(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
return 'Download dimulai di browser ($fileName)';
}

View File

@ -0,0 +1,428 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
class NotificationService {
NotificationService._();
static final NotificationService instance = NotificationService._();
static const String _channelId = 'telurku_panen_channel';
static const String _channelName = 'Panen TelurKu';
static const String _channelDescription =
'Notifikasi panen dan debug TelurKu';
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
static const int _maxAndroidNotificationId = 2147483647;
bool _isInitialized = false;
int _notificationCounter = 0;
int _stableIdFromKey(String key) {
var hash = 0;
for (final codeUnit in key.codeUnits) {
hash = 0x1fffffff & (hash + codeUnit);
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
hash ^= (hash >> 6);
}
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
hash ^= (hash >> 11);
hash = 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
return hash % _maxAndroidNotificationId;
}
Future<void> initialize() async {
if (_isInitialized) return;
if (kIsWeb) return;
try {
tz.initializeTimeZones();
final localTimeZone = await FlutterTimezone.getLocalTimezone();
tz.setLocalLocation(tz.getLocation(localTimeZone));
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _plugin.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
await _setupNotificationChannel();
await _requestPermissions();
_isInitialized = true;
if (kDebugMode) {
print('✅ NotificationService initialized dengan channel panen');
}
} catch (e) {
if (kDebugMode) {
print('❌ NotificationService init failed: $e');
}
rethrow;
}
}
Future<void> _setupNotificationChannel() async {
try {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_channelId,
_channelName,
description: _channelDescription,
importance: Importance.max,
playSound: true,
enableLights: true,
enableVibration: true,
);
await androidPlugin?.createNotificationChannel(channel);
if (kDebugMode) {
print('✅ Notification channel telurku_panen_channel created');
}
} catch (e) {
if (kDebugMode) {
print('⚠️ Error setting notification channel: $e');
}
}
}
Future<void> _requestPermissions() async {
try {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final notifPermission =
await androidPlugin?.requestNotificationsPermission() ?? false;
if (kDebugMode) {
print(
notifPermission
? '✅ Notification permission granted'
: '⚠️ Notification permission not granted',
);
}
final exactAlarmPermission =
await androidPlugin?.requestExactAlarmsPermission() ?? false;
if (kDebugMode) {
print(exactAlarmPermission
? '✅ Exact alarm permission granted'
: '⚠️ Exact alarm permission not granted');
}
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
} catch (e) {
if (kDebugMode) {
print('⚠️ Error requesting permissions: $e');
}
}
}
NotificationDetails _buildNotificationDetails() {
return const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.max,
priority: Priority.high,
playSound: true,
enableVibration: true,
icon: '@mipmap/ic_launcher',
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
);
}
int _nextId() {
// Android mewajibkan ID notifikasi dalam rentang signed 32-bit.
_notificationCounter = (_notificationCounter + 1) % 100000;
final nowPart = DateTime.now().millisecondsSinceEpoch % 2000000000;
final id = nowPart + _notificationCounter;
return id > _maxAndroidNotificationId ? id - _maxAndroidNotificationId : id;
}
Future<void> showInstantNotification({
required String title,
required String body,
String? payload,
}) async {
if (kIsWeb) return;
await initialize();
await _plugin.show(
_nextId(),
title,
body,
_buildNotificationDetails(),
payload: payload,
);
}
Future<void> scheduleNotification({
required String title,
required String body,
required Duration delay,
bool prioritizeBackgroundDelivery = false,
String? payload,
}) async {
if (kIsWeb) return;
await initialize();
final scheduledDate = tz.TZDateTime.now(tz.local).add(delay);
final notificationId = _nextId();
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final canScheduleExact =
await androidPlugin?.canScheduleExactNotifications() ?? true;
if (!canScheduleExact) {
final permissionGranted =
await androidPlugin?.requestExactAlarmsPermission() ?? false;
if (kDebugMode) {
print(
permissionGranted
? '✅ Exact alarm permission granted'
: '⚠️ Exact alarm permission not granted, using inexact fallback',
);
}
}
final useExactSchedule =
await androidPlugin?.canScheduleExactNotifications() ?? true;
final preferredAndroidScheduleMode =
prioritizeBackgroundDelivery && useExactSchedule
? AndroidScheduleMode.alarmClock
: (useExactSchedule
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle);
try {
await _plugin.zonedSchedule(
notificationId,
title,
body,
scheduledDate,
_buildNotificationDetails(),
androidScheduleMode: preferredAndroidScheduleMode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
payload: payload,
);
} on PlatformException catch (e) {
if (e.code != 'exact_alarms_not_permitted') {
rethrow;
}
if (kDebugMode) {
print('⚠️ Exact alarm tidak diizinkan, fallback ke inexact alarm.');
}
await _plugin.zonedSchedule(
notificationId,
title,
body,
scheduledDate,
_buildNotificationDetails(),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
payload: payload,
);
}
}
Future<void> scheduleDailyNotificationAtTime({
required String scheduleKey,
required String title,
required String body,
required int hour,
required int minute,
String? payload,
}) async {
if (kIsWeb) return;
await initialize();
final scheduledDate = _nextTimeAt(hour, minute);
final notificationId = _stableIdFromKey(scheduleKey);
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final canScheduleExactBeforeRequest =
await androidPlugin?.canScheduleExactNotifications() ?? true;
if (!canScheduleExactBeforeRequest) {
await androidPlugin?.requestExactAlarmsPermission();
}
final canScheduleExact =
await androidPlugin?.canScheduleExactNotifications() ?? true;
final preferredMode = canScheduleExact
? AndroidScheduleMode.alarmClock
: AndroidScheduleMode.inexactAllowWhileIdle;
if (kDebugMode) {
print(
'⏰ Daily reminder [$scheduleKey] -> ${scheduledDate.toLocal()} (mode: $preferredMode)');
}
try {
await _plugin.zonedSchedule(
notificationId,
title,
body,
scheduledDate,
_buildNotificationDetails(),
androidScheduleMode: preferredMode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time,
payload: payload,
);
} on PlatformException catch (e) {
if (e.code != 'exact_alarms_not_permitted') {
if (kDebugMode) {
print('⚠️ Daily reminder primary mode failed: $e');
}
}
try {
await _plugin.zonedSchedule(
notificationId,
title,
body,
scheduledDate,
_buildNotificationDetails(),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time,
payload: payload,
);
} on PlatformException {
await _plugin.zonedSchedule(
notificationId,
title,
body,
scheduledDate,
_buildNotificationDetails(),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time,
payload: payload,
);
}
}
}
Future<void> cancelScheduledNotification(String scheduleKey) async {
if (kIsWeb) return;
try {
final notificationId = _stableIdFromKey(scheduleKey);
await _plugin.cancel(notificationId);
if (kDebugMode) {
print(
'✅ Cancelled scheduled notification: $scheduleKey (ID: $notificationId)');
}
} catch (e) {
if (kDebugMode) {
print('⚠️ Error cancelling notification $scheduleKey: $e');
}
}
}
tz.TZDateTime _nextTimeAt(int hour, int minute) {
final now = tz.TZDateTime.now(tz.local);
var scheduled =
tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
if (scheduled.isBefore(now)) {
scheduled = scheduled.add(const Duration(days: 1));
}
return scheduled;
}
Future<bool> canScheduleExactNotifications() async {
if (kIsWeb) return false;
await initialize();
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
return await androidPlugin?.canScheduleExactNotifications() ?? false;
}
Future<bool> requestExactAlarmsPermission() async {
if (kIsWeb) return false;
await initialize();
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
return await androidPlugin?.requestExactAlarmsPermission() ?? false;
}
Future<void> cancelAll() async {
if (kIsWeb) return;
await _plugin.cancelAll();
}
Future<List<PendingNotificationRequest>> pendingNotifications() async {
if (kIsWeb) return <PendingNotificationRequest>[];
return _plugin.pendingNotificationRequests();
}
Future<void> showPanenSummaryNotification({
required String jenisPanen,
required Map<String, int> kandangTotals,
}) async {
final normalizedJenis = jenisPanen.trim().toLowerCase();
final total =
kandangTotals.values.fold<int>(0, (sum, value) => sum + value);
final detailText = kandangTotals.entries
.map((entry) => '${entry.key} = ${entry.value}')
.join(', ');
final title = 'Panen ${_capitalize(normalizedJenis)}';
final body =
'Panen ${normalizedJenis.isEmpty ? jenisPanen : normalizedJenis} telah dilakukan, didapatkan total telur $total${detailText.isNotEmpty ? ', $detailText' : ''}';
await showInstantNotification(
title: title,
body: body,
payload: 'panen:$normalizedJenis',
);
}
static String _capitalize(String value) {
if (value.isEmpty) return value;
return value[0].toUpperCase() + value.substring(1);
}
static void _onNotificationResponse(NotificationResponse response) {
if (kDebugMode) {
print('🔔 Notification tapped: ${response.payload ?? ''}');
}
}
}

View File

@ -2,12 +2,26 @@ import 'package:flutter/foundation.dart';
import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/firebase_database.dart';
import '../providers/panen_provider.dart'; import '../providers/panen_provider.dart';
import '../providers/kandang_provider.dart'; import '../providers/kandang_provider.dart';
import 'notification_service.dart';
/// Service untuk menghubungkan PanenScheduler dengan PanenProvider /// Service untuk menghubungkan PanenScheduler dengan PanenProvider
/// Bertanggung jawab untuk trigger capture otomatis /// Bertanggung jawab untuk trigger capture otomatis
class PanenAutoCaptureService { class PanenAutoCaptureService {
static final FirebaseDatabase _database = FirebaseDatabase.instance; static final FirebaseDatabase _database = FirebaseDatabase.instance;
static String _fallbackKandangName(String kandangId) {
if (kandangId == 'kandang_1') return 'Kandang 1';
if (kandangId == 'kandang_2') return 'Kandang 2';
return kandangId;
}
static String _displayNameForNotification(String kandangId, String name) {
final lower = kandangId.toLowerCase();
if (lower == 'kandang_1' || lower == 'kandang1') return 'kandang 1';
if (lower == 'kandang_2' || lower == 'kandang2') return 'kandang 2';
return name.toLowerCase();
}
/// Trigger capture panen pagi (biasanya dipanggil scheduler jam 09:00) /// Trigger capture panen pagi (biasanya dipanggil scheduler jam 09:00)
/// Membaca sensor value untuk SETIAP kandang dan trigger capture /// Membaca sensor value untuk SETIAP kandang dan trigger capture
static Future<void> triggerMorningCapture( static Future<void> triggerMorningCapture(
@ -34,25 +48,46 @@ class PanenAutoCaptureService {
// Tambah kandang lain sesuai kebutuhan // Tambah kandang lain sesuai kebutuhan
}; };
final capturedTotals = <String, int>{};
// Trigger capture untuk setiap kandang // Trigger capture untuk setiap kandang
sensorMap.forEach((kandangId, sensorValue) { for (final entry in sensorMap.entries) {
final kandangId = entry.key;
final sensorValue = entry.value;
// Get kandang nama dari KandangProvider // Get kandang nama dari KandangProvider
try { try {
final kandang = kandangProvider.kandangs.firstWhere( final matched = kandangProvider.kandangs
(k) => k.id == kandangId, .where((k) => k.id == kandangId)
); .toList();
final kandangNama = matched.isNotEmpty
? matched.first.nama
: _fallbackKandangName(kandangId);
panenProvider.captureScheduledPanenPagi( final panen = await panenProvider.captureScheduledPanenPagi(
kandangId, kandangId,
kandang.nama, kandangNama,
sensorValue as int, sensorValue as int,
); );
if (panen != null) {
capturedTotals[
_displayNameForNotification(kandangId, kandangNama)] =
panen.jumlahTelur;
}
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('⚠️ Kandang $kandangId tidak found: $e'); print('⚠️ Kandang $kandangId tidak found: $e');
} }
} }
}); }
if (capturedTotals.isNotEmpty) {
await NotificationService.instance.showPanenSummaryNotification(
jenisPanen: 'pagi',
kandangTotals: capturedTotals,
);
}
if (kDebugMode) { if (kDebugMode) {
print('✅ Morning capture triggered for all kandang'); print('✅ Morning capture triggered for all kandang');
@ -65,15 +100,18 @@ class PanenAutoCaptureService {
} }
} }
/// Trigger capture panen sore (biasanya dipanggil scheduler jam 15:00) /// Trigger capture panen sore (biasanya dipanggil scheduler jam sore)
/// Membaca sensor value untuk SETIAP kandang dan hitung delta /// Membaca sensor value untuk SETIAP kandang dan hitung delta
/// Setelah panen sore selesai, hasil akan otomatis direset
static Future<void> triggerAfternoonCapture( static Future<void> triggerAfternoonCapture(
PanenProvider panenProvider, PanenProvider panenProvider,
KandangProvider kandangProvider, KandangProvider kandangProvider, {
) async { bool isLastSchedule = false,
}) async {
try { try {
if (kDebugMode) { if (kDebugMode) {
print('🟡 Triggering Afternoon Panen Capture'); print(
'🟡 Triggering Afternoon Panen Capture (isLastSchedule=$isLastSchedule)');
} }
// Baca sensor data dari Firebase // Baca sensor data dari Firebase
@ -90,36 +128,58 @@ class PanenAutoCaptureService {
// Tambah kandang lain sesuai kebutuhan // Tambah kandang lain sesuai kebutuhan
}; };
final capturedTotals = <String, int>{};
// Trigger capture untuk setiap kandang (dengan delta) // Trigger capture untuk setiap kandang (dengan delta)
sensorMap.forEach((kandangId, sensorValue) { for (final entry in sensorMap.entries) {
final kandangId = entry.key;
final sensorValue = entry.value;
// Get kandang nama dari KandangProvider // Get kandang nama dari KandangProvider
try { try {
final kandang = kandangProvider.kandangs.firstWhere( final matched = kandangProvider.kandangs
(k) => k.id == kandangId, .where((k) => k.id == kandangId)
); .toList();
final kandangNama = matched.isNotEmpty
? matched.first.nama
: _fallbackKandangName(kandangId);
panenProvider.captureScheduledPanenSore( final panen = await panenProvider.captureScheduledPanenSore(
kandangId, kandangId,
kandang.nama, kandangNama,
sensorValue as int, sensorValue as int,
); );
if (panen != null) {
capturedTotals[
_displayNameForNotification(kandangId, kandangNama)] =
panen.jumlahTelur;
}
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('⚠️ Kandang $kandangId tidak found: $e'); print('⚠️ Kandang $kandangId tidak found: $e');
} }
} }
}); }
if (capturedTotals.isNotEmpty) {
await NotificationService.instance.showPanenSummaryNotification(
jenisPanen: 'sore',
kandangTotals: capturedTotals,
);
}
if (kDebugMode) { if (kDebugMode) {
print('✅ Afternoon capture triggered for all kandang'); print('✅ Afternoon capture triggered for all kandang');
} }
// Reset snapshot harian setelah panen sore selesai // Reset snapshot harian setelah panen sore selesai
// (Persiapan untuk hari berikutnya) // Ini memastikan bahwa hasil panen sore akan otomatis direset untuk hari berikutnya
Future.delayed(const Duration(seconds: 5), () { Future.delayed(const Duration(seconds: 5), () {
panenProvider.resetDailySnapshots(); panenProvider.resetDailySnapshots();
if (kDebugMode) { if (kDebugMode) {
print('🔄 Daily snapshots reset after afternoon capture'); print(
'🔄 Daily snapshots reset after afternoon capture (panen sore)');
} }
}); });
} }

View File

@ -0,0 +1,181 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../providers/kandang_provider.dart';
import '../providers/panen_provider.dart';
import '../providers/penjadwalan_provider.dart';
import 'panen_auto_capture_service.dart';
/// Scheduler ringan di level aplikasi untuk memicu panen otomatis pagi/sore.
///
/// Catatan: ini berjalan selama proses app masih hidup (foreground/background).
/// Jika proses dibunuh OS, trigger akan berhenti sampai app dibuka lagi.
class PanenRuntimeSchedulerService {
PanenRuntimeSchedulerService._();
static Timer? _timer;
static bool _isRunning = false;
static bool _isProcessing = false;
static String? _lastPagiTriggerDate;
static final Map<int, String> _lastSoreTriggerDateByIndex =
{}; // Track per jadwal sore
static Future<void> start({
required PanenProvider panenProvider,
required KandangProvider kandangProvider,
required PenjadwalanProvider penjadwalanProvider,
}) async {
stop();
_isRunning = true;
_timer = Timer.periodic(const Duration(seconds: 30), (_) async {
await _tick(
panenProvider: panenProvider,
kandangProvider: kandangProvider,
penjadwalanProvider: penjadwalanProvider,
);
});
await _tick(
panenProvider: panenProvider,
kandangProvider: kandangProvider,
penjadwalanProvider: penjadwalanProvider,
);
if (kDebugMode) {
print('✅ PanenRuntimeSchedulerService started (30s tick)');
}
}
static void stop() {
_isRunning = false;
_timer?.cancel();
_timer = null;
}
static Future<void> _tick({
required PanenProvider panenProvider,
required KandangProvider kandangProvider,
required PenjadwalanProvider penjadwalanProvider,
}) async {
if (!_isRunning || _isProcessing) return;
_isProcessing = true;
try {
final now = DateTime.now();
final dateKey =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final activeSchedules =
penjadwalanProvider.penjadwalans.where((j) => j.aktif).toList();
// Sort by ID urutan untuk memastikan penjadwalan1, penjadwalan2, dst
activeSchedules.sort((a, b) {
int numA = int.tryParse(a.id.replaceAll('penjadwalan', '')) ?? 999;
int numB = int.tryParse(b.id.replaceAll('penjadwalan', '')) ?? 999;
return numA.compareTo(numB);
});
int? morningHour;
int? morningMinute;
final afternoonSchedules = <(int index, int hour, int minute)>[];
// Jadwal pertama (penjadwalan1) = PAGI
// Jadwal ke-2+ (penjadwalan2, penjadwalan3, dst) = SORE
for (int i = 0; i < activeSchedules.length; i++) {
final schedule = activeSchedules[i];
final parsed = _parseJam(schedule.jam);
if (parsed == null) continue;
final hour = parsed.$1;
final minute = parsed.$2;
if (i == 0) {
// Jadwal pertama = PAGI
morningHour = hour;
morningMinute = minute;
} else {
// Jadwal ke-2+ = SORE (simpan semua jadwal sore)
afternoonSchedules.add((i, hour, minute));
}
}
// Trigger pagi hanya jika ada jadwal pagi yang aktif
if (morningHour != null && morningMinute != null) {
final morningStart = DateTime(
now.year,
now.month,
now.day,
morningHour,
morningMinute,
);
final morningEnd = morningStart.add(const Duration(minutes: 10));
final isMorningWindow =
!now.isBefore(morningStart) && !now.isAfter(morningEnd);
if (isMorningWindow && _lastPagiTriggerDate != dateKey) {
if (kDebugMode) {
print(
'🕘 Trigger runtime panen pagi ($dateKey @ $morningHour:${morningMinute.toString().padLeft(2, '0')})');
}
await PanenAutoCaptureService.triggerMorningCapture(
panenProvider,
kandangProvider,
);
_lastPagiTriggerDate = dateKey;
}
}
// Trigger semua jadwal sore yang waktunya match
for (int schedIdx = 0; schedIdx < afternoonSchedules.length; schedIdx++) {
final (index, afternoonHour, afternoonMinute) =
afternoonSchedules[schedIdx];
final isLastSchedule = schedIdx == afternoonSchedules.length - 1;
final afternoonStart = DateTime(
now.year,
now.month,
now.day,
afternoonHour,
afternoonMinute,
);
final afternoonEnd = afternoonStart.add(const Duration(minutes: 10));
final isAfternoonWindow =
!now.isBefore(afternoonStart) && !now.isAfter(afternoonEnd);
final lastTriggerDate = _lastSoreTriggerDateByIndex[index];
final shouldTrigger = isAfternoonWindow &&
(lastTriggerDate == null || lastTriggerDate != dateKey);
if (shouldTrigger) {
if (kDebugMode) {
print(
'🕒 Trigger runtime panen sore #${index} ($dateKey @ $afternoonHour:${afternoonMinute.toString().padLeft(2, '0')})');
}
await PanenAutoCaptureService.triggerAfternoonCapture(
panenProvider,
kandangProvider,
isLastSchedule: isLastSchedule,
);
_lastSoreTriggerDateByIndex[index] = dateKey;
}
}
} catch (e) {
if (kDebugMode) {
print('❌ PanenRuntimeSchedulerService tick error: $e');
}
} finally {
_isProcessing = false;
}
}
static (int, int)? _parseJam(String jam) {
final parts = jam.split(':');
if (parts.length < 2) return null;
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
}

View File

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <flutter_timezone/flutter_timezone_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_timezone_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin");
flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar);
} }

View File

@ -3,9 +3,11 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View File

@ -9,12 +9,14 @@ import cloud_firestore
import firebase_auth import firebase_auth
import firebase_core import firebase_core
import firebase_database import firebase_database
import path_provider_foundation import flutter_local_notifications
import flutter_timezone
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
} }

View File

@ -13,10 +13,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.9" version: "3.6.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -29,10 +29,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -49,14 +49,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
name: checked_yaml name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.4"
cli_util: cli_util:
dependency: transitive dependency: transitive
description: description:
@ -97,6 +105,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.12" version: "4.4.12"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@ -113,22 +129,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_icons name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
excel:
dependency: "direct main"
description:
name: excel
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@ -137,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_auth: firebase_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@ -173,10 +229,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: firebase_core_platform_interface
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.2" version: "6.0.3"
firebase_core_web: firebase_core_web:
dependency: transitive dependency: transitive
description: description:
@ -246,24 +302,80 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
url: "https://pub.dev"
source: hosted
version: "4.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_timezone:
dependency: "direct main"
description:
name: flutter_timezone
sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_fonts name: google_fonts
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.2" version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -284,10 +396,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.0" version: "4.3.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -296,38 +408,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.19.0"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.0" version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@ -336,6 +464,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -352,14 +488,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.11.1"
media_store_plus:
dependency: "direct main"
description:
name: media_store_plus
sha256: "4b4971365e00a4ed9fde14abf40d7c27475b66b8bba9bf43478ae2ecb449df20"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -368,6 +520,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -388,18 +556,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.19" version: "2.3.1"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -428,10 +596,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -448,14 +616,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -464,6 +624,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -473,10 +641,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -513,10 +681,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.7"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -525,30 +701,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
universal_html:
dependency: "direct main"
description:
name: universal_html
sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4
url: "https://pub.dev"
source: hosted
version: "2.3.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.2" version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "15.0.2"
web: web:
dependency: transitive dependency: transitive
description: description:
@ -569,10 +761,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@ -582,5 +774,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.38.4"

View File

@ -33,24 +33,30 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.9
# Firebase # Firebase
firebase_core: ^3.3.0 firebase_core: ^3.15.2
firebase_auth: ^5.1.4 firebase_auth: ^5.7.0
cloud_firestore: ^5.1.0 cloud_firestore: ^5.6.12
firebase_database: ^11.0.0 firebase_database: ^11.3.10
# State Management # State Management
provider: ^6.1.0 provider: ^6.1.5+1
# Utilities # Utilities
uuid: ^4.0.0 uuid: ^4.5.3
intl: ^0.19.0 intl: ^0.19.0
# UI/UX # UI/UX
google_fonts: ^6.2.1 google_fonts: ^6.3.3
flutter_dotenv: ^6.0.0 flutter_dotenv: ^6.0.0
flutter_local_notifications: ^17.2.4
timezone: ^0.9.4
flutter_timezone: ^4.1.1
universal_html: ^2.3.0
excel: ^4.0.6
media_store_plus: ^0.1.3
# Scheduler & Background Tasks # Scheduler & Background Tasks
# TEMPORARILY DISABLED - Compatibility issues with Android SDK 36 # TEMPORARILY DISABLED - Compatibility issues with Android SDK 36
@ -61,7 +67,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.4
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is

View File

@ -1,3 +1,4 @@
<<<<<<< HEAD
# TelurKu Railway Scheduler # TelurKu Railway Scheduler
Worker Node.js untuk menjalankan penjadwalan panen otomatis di Railway. Worker Node.js untuk menjalankan penjadwalan panen otomatis di Railway.
@ -87,3 +88,7 @@ Setelah jadwal sore berhasil dieksekusi, worker akan me-reset:
- `data/infra2 = 0` - `data/infra2 = 0`
Ringkasan harian di `riwayat/summary` tetap disimpan untuk kebutuhan tampilan Flutter. Ringkasan harian di `riwayat/summary` tetap disimpan untuk kebutuhan tampilan Flutter.
=======
# lurtelurku_backend
Deployment project for Railway
>>>>>>> 6d5419d62de4e76250a0a5160a8d0820de478a16

View File

@ -135,14 +135,18 @@ function resolveTargetKandangIds(jadwal, kandangMap) {
return [kandangId]; return [kandangId];
} }
function detectJenisPanen(jadwal) { function detectJenisPanen(jadwal, jadwalOrder) {
// jadwalOrder 0 = urutan pertama = PAGI
// jadwalOrder 1+ = urutan selanjutnya = SORE (atau sesuai jam)
if (jadwalOrder === 0) {
return 'pagi';
}
// Untuk jadwal ke-2+, check jam: < 12 = pagi, >= 12 = sore
const jam = String(jadwal.jam || '09:00'); const jam = String(jadwal.jam || '09:00');
const hour = Number(jam.split(':')[0] || 9); const hour = Number(jam.split(':')[0] || 9);
const ket = String(jadwal.keterangan || '').toLowerCase();
return hour < 12 ? 'pagi' : 'sore';
if (ket.includes('sore')) return 'sore';
if (ket.includes('pagi')) return 'pagi';
return hour >= 12 ? 'sore' : 'pagi';
} }
function resolveInfraPath(kandangId, kandangData) { function resolveInfraPath(kandangId, kandangData) {
@ -377,7 +381,7 @@ async function migrateLegacyRiwayatData(kandangMap) {
cleanupUpdates[legacyKey] = null; cleanupUpdates[legacyKey] = null;
continue; continue;
} }
const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap); const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap);
if (inferredKandangId) { if (inferredKandangId) {
await recordsRef.push().set({ await recordsRef.push().set({
@ -500,9 +504,9 @@ async function runCompactMaintenance() {
await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS); await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS);
} }
async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey) { async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrder, totalJadwal) {
const jam = String(jadwal.jam || '09:00'); const jam = String(jadwal.jam || '09:00');
const jenisPanen = detectJenisPanen(jadwal); const jenisPanen = detectJenisPanen(jadwal, jadwalOrder);
const durasiMs = parseDurasiMs(jadwal.durasi); const durasiMs = parseDurasiMs(jadwal.durasi);
console.log( console.log(
@ -519,10 +523,10 @@ async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey
} }
for (const kandangId of targetKandangIds) { for (const kandangId of targetKandangIds) {
const lockKey = `${jadwalId}_${jenisPanen}_${kandangId}`; const lockKey = `${jenisPanen}_${kandangId}`;
const gotLock = await acquireRunLock(todayKey, lockKey); const gotLock = await acquireRunLock(todayKey, lockKey);
if (!gotLock) { if (!gotLock) {
console.log(`[skip] ${jadwalId}/${kandangId}: sudah dieksekusi hari ini (${jenisPanen})`); console.log(`[skip] ${jadwalId}/${kandangId}: slot ${jenisPanen} sudah dieksekusi hari ini`);
continue; continue;
} }
@ -580,6 +584,13 @@ async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey
console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`); console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`);
} }
// Jika ini jadwal terakhir, reset snapshot untuk hari ini
if (jadwalOrder === totalJadwal - 1) {
const snapshotRef = admin.database().ref(`panen_snapshot/${todayKey}`);
await snapshotRef.remove();
console.log(`[ok] Reset snapshot panen untuk hari ${todayKey} (jadwal terakhir selesai)`);
}
} }
async function runTick() { async function runTick() {
@ -603,7 +614,22 @@ async function runTick() {
const dataSensor = dataSnap.exists() ? dataSnap.val() : {}; const dataSensor = dataSnap.exists() ? dataSnap.val() : {};
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {}; const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
const aktifSekarang = Object.entries(jadwalMap).filter(([_, jadwal]) => { // Sort jadwal by ID untuk menentukan urutan: penjadwalan1 = pagi, penjadwalan2+ = sore
const sortedJadwals = Object.entries(jadwalMap)
.map(([id, jadwal]) => [id, jadwal])
.sort((a, b) => {
const numA = parseInt(a[0].replace('penjadwalan', '')) || 999;
const numB = parseInt(b[0].replace('penjadwalan', '')) || 999;
return numA - numB;
});
// Map untuk deteksi urutan
const jadwalOrderMap = {};
sortedJadwals.forEach(([id, jadwal], index) => {
jadwalOrderMap[id] = index;
});
const aktifSekarang = sortedJadwals.filter(([_, jadwal]) => {
if (!jadwal || jadwal.aktif !== true) return false; if (!jadwal || jadwal.aktif !== true) return false;
const jam = String(jadwal.jam || '').slice(0, 5); const jam = String(jadwal.jam || '').slice(0, 5);
return jam === nowHHMM; return jam === nowHHMM;
@ -619,8 +645,8 @@ async function runTick() {
for (const [jadwalId, jadwal] of aktifSekarang) { for (const [jadwalId, jadwal] of aktifSekarang) {
try { try {
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey); await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrderMap[jadwalId], sortedJadwals.length);
if (detectJenisPanen(jadwal) === 'sore') { if (detectJenisPanen(jadwal, jadwalOrderMap[jadwalId]) === 'sore') {
hasEveningRun = true; hasEveningRun = true;
} }
} catch (e) { } catch (e) {

View File

@ -9,6 +9,7 @@
#include <cloud_firestore/cloud_firestore_plugin_c_api.h> #include <cloud_firestore/cloud_firestore_plugin_c_api.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h> #include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
CloudFirestorePluginCApiRegisterWithRegistrar( CloudFirestorePluginCApiRegisterWithRegistrar(
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterTimezonePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
} }

View File

@ -6,9 +6,11 @@ list(APPEND FLUTTER_PLUGIN_LIST
cloud_firestore cloud_firestore
firebase_auth firebase_auth
firebase_core firebase_core
flutter_timezone
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)