1837 lines
60 KiB
Dart
1837 lines
60 KiB
Dart
import 'dart:convert';
|
||
import 'dart:async';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||
import 'package:firebase_database/firebase_database.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||
import 'package:timezone/data/latest.dart' as tz;
|
||
import 'package:timezone/timezone.dart' as tz;
|
||
|
||
/// Background notification handler - MUST be at top level
|
||
/// Called when notification received while app is backgrounded/killed
|
||
@pragma('vm:entry-point')
|
||
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||
debugPrint(
|
||
'🔔 [BACKGROUND] Notification tapped: payload length=${notificationResponse.payload?.length ?? 0}',
|
||
);
|
||
|
||
// Parse dan handle watering execution
|
||
if (notificationResponse.payload != null) {
|
||
try {
|
||
final data =
|
||
jsonDecode(notificationResponse.payload!) as Map<String, dynamic>;
|
||
final type = data['type'] as String?;
|
||
|
||
if (type == 'watering_execution') {
|
||
debugPrint(
|
||
'💧 [BACKGROUND] Watering execution triggered from notification tap',
|
||
);
|
||
// Call static method untuk handle watering
|
||
NotificationService.handleWateringNotificationTap(data);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('⚠️ [BACKGROUND] Error parsing notification: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 🔔 NOTIFICATION SERVICE v2.0 - WITH BACKGROUND SUPPORT & WATERING SCHEDULING
|
||
///
|
||
/// Fitur Utama:
|
||
/// ✅ Exact alarm scheduling untuk precision timing
|
||
/// ✅ Background notifications (muncul bahkan saat app minimized!)
|
||
/// ✅ Retry logic untuk reliability
|
||
/// ✅ Multiple notification channels (watering, alert, info)
|
||
/// ✅ Full Firebase Cloud Messaging support
|
||
/// ✅ Proper permission handling
|
||
/// ✅ NEW: Watering schedule execution tanpa worker!
|
||
///
|
||
/// 💧 PENJADWALAN PENYIRAMAN (Watering Scheduling):
|
||
/// - Uses exact alarm (AndroidScheduleMode.exactAllowWhileIdle)
|
||
/// - Schedule watering execution saat alarm fire (bukan hanya notifikasi)
|
||
/// - Works even when app is minimized/killed
|
||
/// - Eliminates dependency to background worker (railway-worker, etc)
|
||
///
|
||
/// Cara Penggunaan:
|
||
/// 1. Saat app starts: NotificationService.initialize()
|
||
/// 2. Setup callback: NotificationService.setOnWateringScheduleCallback(callback)
|
||
/// 3. Schedule watering: NotificationService.scheduleAllWateringExecutions()
|
||
/// 4. Callback akan dipanggil saat alarm fire untuk eksekusi penyiraman
|
||
///
|
||
/// Integrasi dengan KontrolAutomationService:
|
||
/// - startWaktuMode() akan otomatis schedule semua jadwal via notification service
|
||
/// - Jadwal changes akan otomatis re-sync ke scheduler via JadwalService
|
||
/// - stopWaktuMode() akan cancel semua scheduled watering executions
|
||
class NotificationService {
|
||
NotificationService._();
|
||
|
||
static final NotificationService instance = NotificationService._();
|
||
|
||
static const String topicName = 'irrigo_notifications';
|
||
|
||
// Callback untuk handle watering execution dari scheduled alarm
|
||
// ⚠️ IMPORTANT: Must be public static for background notification handler
|
||
static Function(dynamic)? _onWateringScheduleCallback;
|
||
static const String wateringChannelId = 'irrigo_watering_channel';
|
||
static const String alertChannelId = 'irrigo_alert_channel';
|
||
static const String infoChannelId = 'irrigo_info_channel';
|
||
static const int _maxAndroidNotificationId = 2147483647;
|
||
|
||
final FlutterLocalNotificationsPlugin _plugin =
|
||
FlutterLocalNotificationsPlugin();
|
||
|
||
bool _isInitialized = false;
|
||
bool _hasNotificationPermission =
|
||
false; // CRITICAL: Track if user granted permission!
|
||
int _notificationCounter = 0;
|
||
|
||
/// ==================== INITIALIZATION ====================
|
||
|
||
/// Initialize notification service dengan complete setup
|
||
Future<void> initialize() async {
|
||
if (_isInitialized || kIsWeb) return;
|
||
|
||
debugPrint('🔔 Initializing Notification Service v2.0...');
|
||
|
||
// Setup timezones
|
||
tz.initializeTimeZones();
|
||
try {
|
||
tz.setLocalLocation(tz.getLocation('Asia/Jakarta'));
|
||
} catch (_) {
|
||
tz.setLocalLocation(tz.UTC);
|
||
}
|
||
|
||
// Android & iOS initialization
|
||
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,
|
||
);
|
||
|
||
// Initialize dengan background response support
|
||
await _plugin.initialize(
|
||
initSettings,
|
||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||
onDidReceiveBackgroundNotificationResponse:
|
||
notificationTapBackground, // ← Background handler untuk minimize/kill
|
||
);
|
||
|
||
// Setup channels & permissions
|
||
await _setupNotificationChannels();
|
||
_hasNotificationPermission = await _requestAllPermissions();
|
||
debugPrint(
|
||
_hasNotificationPermission
|
||
? '✅ POST_NOTIFICATIONS permission granted'
|
||
: '⚠️ POST_NOTIFICATIONS permission DENIED - notifications will not show!',
|
||
);
|
||
|
||
// Firebase setup
|
||
try {
|
||
await FirebaseMessaging.instance.subscribeToTopic(topicName);
|
||
debugPrint('✅ Subscribed to Firebase topic: $topicName');
|
||
} catch (e) {
|
||
debugPrint('⚠️ Firebase subscription failed: $e');
|
||
}
|
||
|
||
_setupFCMHandlers();
|
||
_isInitialized = true;
|
||
debugPrint('✅ Notification Service v2.0 ready!');
|
||
}
|
||
|
||
/// Set callback untuk handle watering execution saat scheduled alarm fire
|
||
/// Callback akan menerima schedule data (scheduleId, durasi, pots, dll)
|
||
static void setOnWateringScheduleCallback(Function(dynamic)? callback) {
|
||
_onWateringScheduleCallback = callback;
|
||
debugPrint(
|
||
callback != null
|
||
? '✅ Watering schedule callback registered'
|
||
: '⚠️ Watering schedule callback cleared',
|
||
);
|
||
}
|
||
|
||
/// ⭐ Static method untuk handle watering notification tap (from background)
|
||
/// Called dari background notification handler
|
||
static Future<void> handleWateringNotificationTap(
|
||
Map<String, dynamic> data,
|
||
) async {
|
||
try {
|
||
debugPrint(
|
||
'💧 [STATIC] Handling watering from background notification tap',
|
||
);
|
||
|
||
// Invoke callback jika registered
|
||
if (_onWateringScheduleCallback != null) {
|
||
debugPrint('💧 [STATIC] Invoking callback dengan data: ${data.keys}');
|
||
await Future.sync(() => _onWateringScheduleCallback?.call(data));
|
||
} else {
|
||
debugPrint('⚠️ [STATIC] No callback registered for watering execution');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [STATIC] Error handling watering notification: $e');
|
||
}
|
||
}
|
||
|
||
/// Re-validate permissions (not cached) - call before critical operations
|
||
Future<bool> revalidatePermissions() async {
|
||
if (kIsWeb) return false;
|
||
|
||
try {
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
|
||
// Fresh check of POST_NOTIFICATIONS
|
||
bool postNotifGranted = false;
|
||
try {
|
||
postNotifGranted =
|
||
await androidPlugin?.requestNotificationsPermission() ?? false;
|
||
} catch (_) {
|
||
postNotifGranted = false;
|
||
}
|
||
|
||
_hasNotificationPermission = postNotifGranted;
|
||
return postNotifGranted;
|
||
} catch (e) {
|
||
debugPrint('❌ Permission revalidation error: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Setup notification channels
|
||
Future<void> _setupNotificationChannels() async {
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
|
||
if (androidPlugin == null) return;
|
||
|
||
try {
|
||
// Watering channel - PRIORITY TINGGI
|
||
const wateringChannel = AndroidNotificationChannel(
|
||
wateringChannelId,
|
||
'IrriGo Penyiraman',
|
||
description: 'Notifikasi penyiraman - selalu muncul!',
|
||
importance: Importance.max,
|
||
playSound: true,
|
||
enableLights: true,
|
||
enableVibration: true,
|
||
);
|
||
|
||
// Alert channel
|
||
const alertChannel = AndroidNotificationChannel(
|
||
alertChannelId,
|
||
'IrriGo Alert',
|
||
description: 'Alert dan warning dari sistem',
|
||
importance: Importance.high,
|
||
playSound: true,
|
||
enableLights: true,
|
||
enableVibration: true,
|
||
);
|
||
|
||
// Info channel
|
||
const infoChannel = AndroidNotificationChannel(
|
||
infoChannelId,
|
||
'IrriGo Informasi',
|
||
description: 'Informasi sistem IrriGo',
|
||
importance: Importance.low,
|
||
playSound: false,
|
||
enableLights: false,
|
||
enableVibration: false,
|
||
);
|
||
|
||
await Future.wait([
|
||
androidPlugin.createNotificationChannel(wateringChannel),
|
||
androidPlugin.createNotificationChannel(alertChannel),
|
||
androidPlugin.createNotificationChannel(infoChannel),
|
||
]);
|
||
debugPrint('✅ Notification channels created');
|
||
} catch (e) {
|
||
debugPrint('❌ Error creating channels: $e');
|
||
}
|
||
}
|
||
|
||
/// Request all permissions - returns true if POST_NOTIFICATIONS permission likely granted
|
||
Future<bool> _requestAllPermissions() async {
|
||
try {
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
|
||
// Notifications permission
|
||
await androidPlugin?.requestNotificationsPermission();
|
||
|
||
// Exact alarms permission
|
||
final canScheduleExact =
|
||
await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
if (!canScheduleExact) {
|
||
await androidPlugin?.requestExactAlarmsPermission();
|
||
}
|
||
|
||
// iOS permissions
|
||
await _plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
IOSFlutterLocalNotificationsPlugin
|
||
>()
|
||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||
|
||
// Firebase permissions
|
||
await FirebaseMessaging.instance.requestPermission(
|
||
alert: true,
|
||
badge: true,
|
||
sound: true,
|
||
provisional: false,
|
||
);
|
||
|
||
debugPrint('✅ All permissions requested');
|
||
return true; // ← Assume granted if no error thrown
|
||
} catch (e) {
|
||
debugPrint('⚠️ Permission error: $e');
|
||
return false; // ← Permission request failed
|
||
}
|
||
}
|
||
|
||
/// Setup FCM handlers
|
||
void _setupFCMHandlers() {
|
||
FirebaseMessaging.onMessage.listen((message) {
|
||
debugPrint('📨 FCM onMessage: ${message.messageId}');
|
||
_showRemoteMessage(message);
|
||
});
|
||
|
||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||
debugPrint('📨 FCM onMessageOpenedApp: ${message.messageId}');
|
||
});
|
||
}
|
||
|
||
/// ==================== NOTIFICATION DISPLAY ====================
|
||
|
||
/// Build watering notification details (HIGH PRIORITY) - FOR IMMEDIATE DISPLAY
|
||
NotificationDetails _buildWateringNotificationDetails() {
|
||
return NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
wateringChannelId,
|
||
'IrriGo Penyiraman',
|
||
channelDescription: 'Notifikasi penyiraman',
|
||
importance: Importance.max,
|
||
priority: Priority.high,
|
||
playSound: true,
|
||
enableVibration: true,
|
||
vibrationPattern: Int64List.fromList([0, 500, 200, 500]),
|
||
enableLights: true,
|
||
icon: '@mipmap/ic_launcher',
|
||
largeIcon: const DrawableResourceAndroidBitmap('@mipmap/ic_launcher'),
|
||
autoCancel: true,
|
||
showWhen: true,
|
||
fullScreenIntent: true,
|
||
tag: 'watering',
|
||
),
|
||
iOS: const DarwinNotificationDetails(
|
||
presentAlert: true,
|
||
presentBadge: true,
|
||
presentSound: true,
|
||
categoryIdentifier: 'watering',
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Build watering notification details FOR SCHEDULED (handles background firing)
|
||
NotificationDetails _buildScheduledWateringNotificationDetails() {
|
||
return NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
wateringChannelId,
|
||
'IrriGo Penyiraman',
|
||
channelDescription: 'Notifikasi penyiraman',
|
||
importance: Importance.max,
|
||
priority: Priority.high,
|
||
playSound: true,
|
||
enableVibration: true,
|
||
vibrationPattern: Int64List.fromList([0, 500, 200, 500]),
|
||
enableLights: true,
|
||
icon: '@mipmap/ic_launcher',
|
||
largeIcon: const DrawableResourceAndroidBitmap('@mipmap/ic_launcher'),
|
||
autoCancel: true,
|
||
showWhen: true,
|
||
tag: 'watering',
|
||
),
|
||
iOS: const DarwinNotificationDetails(
|
||
presentAlert: true,
|
||
presentBadge: true,
|
||
presentSound: true,
|
||
categoryIdentifier: 'watering',
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Build alert notification details
|
||
NotificationDetails _buildAlertNotificationDetails() {
|
||
return NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
alertChannelId,
|
||
'IrriGo Alert',
|
||
channelDescription: 'Alert dan warning',
|
||
importance: Importance.high,
|
||
priority: Priority.high,
|
||
playSound: true,
|
||
enableVibration: true,
|
||
enableLights: true,
|
||
icon: '@mipmap/ic_launcher',
|
||
autoCancel: true,
|
||
showWhen: true,
|
||
),
|
||
iOS: const DarwinNotificationDetails(
|
||
presentAlert: true,
|
||
presentBadge: true,
|
||
presentSound: true,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Build basic notification details
|
||
NotificationDetails _buildBasicNotificationDetails() {
|
||
return NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
infoChannelId,
|
||
'IrriGo Informasi',
|
||
channelDescription: 'Informasi sistem',
|
||
importance: Importance.low,
|
||
priority: Priority.low,
|
||
playSound: false,
|
||
enableVibration: false,
|
||
icon: '@mipmap/ic_launcher',
|
||
autoCancel: true,
|
||
),
|
||
iOS: const DarwinNotificationDetails(
|
||
presentAlert: false,
|
||
presentBadge: true,
|
||
presentSound: false,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Build automation notification details (WITH SOUND!) - untuk notifikasi otomasi penyiraman
|
||
NotificationDetails _buildAutomationNotificationDetails() {
|
||
return NotificationDetails(
|
||
android: AndroidNotificationDetails(
|
||
wateringChannelId,
|
||
'IrriGo Penyiraman',
|
||
channelDescription: 'Notifikasi penyiraman otomatis',
|
||
importance: Importance.high,
|
||
priority: Priority.high,
|
||
playSound: true,
|
||
enableVibration: true,
|
||
vibrationPattern: Int64List.fromList([0, 500, 200, 500]),
|
||
enableLights: true,
|
||
icon: '@mipmap/ic_launcher',
|
||
largeIcon: const DrawableResourceAndroidBitmap('@mipmap/ic_launcher'),
|
||
autoCancel: true,
|
||
showWhen: true,
|
||
tag: 'automation',
|
||
),
|
||
iOS: const DarwinNotificationDetails(
|
||
presentAlert: true,
|
||
presentBadge: true,
|
||
presentSound: true,
|
||
categoryIdentifier: 'automation',
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Show instant notification
|
||
Future<void> showInstantNotification({
|
||
required String title,
|
||
required String body,
|
||
String? payload,
|
||
bool isWatering = false,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
// Check permission before showing
|
||
if (!_hasNotificationPermission) {
|
||
debugPrint(
|
||
'⚠️ Cannot show notification - POST_NOTIFICATIONS permission not granted',
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final details =
|
||
isWatering
|
||
? _buildWateringNotificationDetails()
|
||
: _buildBasicNotificationDetails();
|
||
|
||
await _plugin.show(_nextId(), title, body, details, payload: payload);
|
||
|
||
debugPrint('✅ Notification: "$title"');
|
||
} catch (e) {
|
||
debugPrint('❌ Error: $e');
|
||
}
|
||
}
|
||
|
||
/// Show watering notification (PENTING UNTUK PENYIRAMAN!)
|
||
Future<void> showWateringNotification({
|
||
required String title,
|
||
required String body,
|
||
Map<String, dynamic>? payload,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
// 🔴 CRITICAL CHECK: Don't continue if permission denied!
|
||
if (!_hasNotificationPermission) {
|
||
debugPrint(
|
||
'🔴 BLOCKED: Cannot show watering notification - POST_NOTIFICATIONS permission not granted!',
|
||
);
|
||
debugPrint(
|
||
' User may have denied permission when prompted at startup.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _plugin.show(
|
||
_nextId(),
|
||
title,
|
||
body,
|
||
_buildWateringNotificationDetails(),
|
||
payload: payload == null ? null : jsonEncode(payload),
|
||
);
|
||
|
||
debugPrint('✅ Watering notification: "$title" - "$body"');
|
||
} catch (e) {
|
||
debugPrint('❌ Error showing watering notification: $e');
|
||
}
|
||
}
|
||
|
||
/// Show alert notification
|
||
Future<void> showAlertNotification({
|
||
required String title,
|
||
required String body,
|
||
String? payload,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
// Check permission before showing
|
||
if (!_hasNotificationPermission) {
|
||
debugPrint(
|
||
'⚠️ Cannot show alert notification - POST_NOTIFICATIONS permission not granted',
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _plugin.show(
|
||
_nextId(),
|
||
title,
|
||
body,
|
||
_buildAlertNotificationDetails(),
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint('✅ Alert notification: "$title"');
|
||
} catch (e) {
|
||
debugPrint('❌ Error: $e');
|
||
}
|
||
}
|
||
|
||
/// Show mode changed notification
|
||
Future<void> showModeChangedNotification({
|
||
required String modeLabel,
|
||
required bool enabled,
|
||
}) async {
|
||
final body =
|
||
enabled
|
||
? 'Mode diubah menjadi penyiraman $modeLabel.'
|
||
: 'Mode penyiraman $modeLabel dinonaktifkan.';
|
||
|
||
await showInstantNotification(
|
||
title: 'Mode Penyiraman',
|
||
body: body,
|
||
payload: jsonEncode({'type': 'mode_change', 'mode': modeLabel}),
|
||
);
|
||
}
|
||
|
||
/// 🆕 Show automation notification dengan deskripsi lengkap (DENGAN BUNYI!)
|
||
/// Digunakan untuk notifikasi otomasi penyiraman sensor
|
||
/// Format sesuai dengan gambar: Title + Full Description (2-3 baris)
|
||
Future<void> showAutomationNotification({
|
||
required String title,
|
||
required String description,
|
||
Map<String, dynamic>? payload,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
// Check permission before showing
|
||
if (!_hasNotificationPermission) {
|
||
debugPrint(
|
||
'⚠️ Cannot show automation notification - POST_NOTIFICATIONS permission not granted',
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _plugin.show(
|
||
_nextId(),
|
||
title,
|
||
description,
|
||
_buildAutomationNotificationDetails(),
|
||
payload: payload == null ? null : jsonEncode(payload),
|
||
);
|
||
|
||
debugPrint('✅ Automation notification: "$title" - "$description"');
|
||
} catch (e) {
|
||
debugPrint('❌ Error showing automation notification: $e');
|
||
}
|
||
}
|
||
|
||
/// 🆕 Show schedule changed notification dengan bunyi
|
||
/// Digunakan ketika jadwal penyiraman berubah
|
||
Future<void> showScheduleChangedNotification({
|
||
required String scheduleId,
|
||
required String jadwalWaktu,
|
||
required bool enabled,
|
||
List<int>? potAktif,
|
||
}) async {
|
||
final status = enabled ? 'aktif' : 'nonaktif';
|
||
final potList =
|
||
potAktif != null && potAktif.isNotEmpty
|
||
? potAktif.join(', ')
|
||
: 'semua pot';
|
||
|
||
final title = 'Jadwal Penyiraman Diubah';
|
||
final body =
|
||
'Jadwal $scheduleId ($jadwalWaktu) sekarang $status\nPot: $potList';
|
||
|
||
await showAutomationNotification(
|
||
title: title,
|
||
description: body,
|
||
payload: {
|
||
'type': 'schedule_change',
|
||
'scheduleId': scheduleId,
|
||
'enabled': enabled,
|
||
'jadwalWaktu': jadwalWaktu,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 🆕 Show sensor triggered watering notification
|
||
/// Digunakan untuk notifikasi penyiraman otomatis yang dipicu sensor
|
||
Future<void> showSensorWateringNotification({
|
||
required String potName,
|
||
required int currentMoisture,
|
||
required int thresholdMin,
|
||
required int thresholdMax,
|
||
String? additionalInfo,
|
||
}) async {
|
||
final title = 'IrriGo - Penyiraman Otomatis';
|
||
final body =
|
||
StringBuffer()
|
||
..write('Penyiraman dilakukan pada $potName\n')
|
||
..write('Kelembaban: $currentMoisture% ');
|
||
|
||
if (currentMoisture < thresholdMin) {
|
||
body.write('(di bawah ambang batas $thresholdMin%)');
|
||
} else if (currentMoisture > thresholdMax) {
|
||
body.write('(melebihi ambang batas maksimal $thresholdMax%)');
|
||
}
|
||
|
||
if (additionalInfo != null && additionalInfo.isNotEmpty) {
|
||
body.write('\n$additionalInfo');
|
||
}
|
||
|
||
await showAutomationNotification(
|
||
title: title,
|
||
description: body.toString(),
|
||
payload: {
|
||
'type': 'sensor_triggered',
|
||
'potName': potName,
|
||
'moisture': currentMoisture,
|
||
'thresholdMin': thresholdMin,
|
||
'thresholdMax': thresholdMax,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 🆕 Show watering execution notification
|
||
/// Digunakan untuk notifikasi saat penyiraman sedang berjalan
|
||
Future<void> showWateringExecutionNotification({
|
||
required String scheduleId,
|
||
required String scheduleWaktu,
|
||
required List<int> potsAktif,
|
||
int? durationSeconds,
|
||
}) async {
|
||
final potList = potsAktif.join(', ');
|
||
final durationText =
|
||
durationSeconds != null ? ' ($durationSeconds detik)' : '';
|
||
|
||
final title = 'Penyiraman Dimulai';
|
||
final body =
|
||
'Jadwal: $scheduleId ($scheduleWaktu)\nPot aktif: $potList$durationText';
|
||
|
||
await showWateringNotification(
|
||
title: title,
|
||
body: body,
|
||
payload: {
|
||
'type': 'watering_execution',
|
||
'scheduleId': scheduleId,
|
||
'scheduleWaktu': scheduleWaktu,
|
||
'potsAktif': potsAktif,
|
||
'durationSeconds': durationSeconds,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 🆕 Show mode automation enabled notification
|
||
/// Digunakan saat automatic mode diaktifkan
|
||
Future<void> showModeAutomationEnabledNotification({
|
||
required String modeType,
|
||
required String modeDescription,
|
||
}) async {
|
||
final title = 'Mode Penyiraman';
|
||
final body =
|
||
'Mode diubah menjadi penyiraman $modeType berbasis $modeDescription.';
|
||
|
||
await showAutomationNotification(
|
||
title: title,
|
||
description: body,
|
||
payload: {
|
||
'type': 'mode_automation',
|
||
'modeType': modeType,
|
||
'modeDescription': modeDescription,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// ==================== SCHEDULING ====================
|
||
|
||
/// Schedule notification dengan retry logic
|
||
Future<void> scheduleNotification({
|
||
required String title,
|
||
required String body,
|
||
required Duration delay,
|
||
String? payload,
|
||
int retryCount = 3,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
// 🔴 CRITICAL CHECK: Don't continue if permission denied!
|
||
if (!_hasNotificationPermission) {
|
||
debugPrint(
|
||
'🔴 BLOCKED: Cannot schedule notification - POST_NOTIFICATIONS permission not granted!',
|
||
);
|
||
return;
|
||
}
|
||
|
||
for (int attempt = 1; attempt <= retryCount; attempt++) {
|
||
try {
|
||
final scheduledDate = tz.TZDateTime.now(tz.local).add(delay);
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
|
||
final canScheduleExact =
|
||
await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
|
||
// 🔥 3-TIER FALLBACK (from working project):
|
||
// Priority 1: alarmClock (best for background) ← NEW!
|
||
// Priority 2: exactAllowWhileIdle (standard)
|
||
// Priority 3: inexactAllowWhileIdle (fallback)
|
||
final primaryMode =
|
||
canScheduleExact
|
||
? AndroidScheduleMode
|
||
.alarmClock // ✅ UPGRADED
|
||
: AndroidScheduleMode.inexactAllowWhileIdle;
|
||
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
_nextId(),
|
||
title,
|
||
body,
|
||
scheduledDate,
|
||
_buildWateringNotificationDetails(),
|
||
androidScheduleMode: primaryMode,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint(
|
||
'✅ Scheduled: "$title" in ${delay.inSeconds}s (${primaryMode.toString().split('.').last} mode)',
|
||
);
|
||
return;
|
||
} on PlatformException catch (e) {
|
||
// Fallback to exactAllowWhileIdle
|
||
if (e.code == 'exact_alarms_not_permitted') {
|
||
debugPrint(' ⚠️ Fallback #1: Trying exactAllowWhileIdle...');
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
_nextId(),
|
||
title,
|
||
body,
|
||
scheduledDate,
|
||
_buildWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
payload: payload,
|
||
);
|
||
debugPrint('✅ Scheduled with exactAllowWhileIdle fallback');
|
||
return;
|
||
} on PlatformException catch (_) {
|
||
// Final fallback to inexactAllowWhileIdle
|
||
await _plugin.zonedSchedule(
|
||
_nextId(),
|
||
title,
|
||
body,
|
||
scheduledDate,
|
||
_buildWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
payload: payload,
|
||
);
|
||
debugPrint('✅ Scheduled with inexactAllowWhileIdle fallback');
|
||
return;
|
||
}
|
||
}
|
||
rethrow;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('⚠️ Attempt $attempt/$retryCount failed: $e');
|
||
if (attempt < retryCount) {
|
||
await Future.delayed(Duration(milliseconds: 500 * attempt));
|
||
} else {
|
||
debugPrint('🔴 Failed after $retryCount attempts: $e');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// ⭐ STABLE 30-SECOND NOTIFICATION - GUARANTEED DELIVERY
|
||
/// Used for testing and reliable short-term notifications
|
||
/// Works even when app minimized or killed!
|
||
Future<bool> scheduleStable30SecondNotification({
|
||
required String title,
|
||
required String body,
|
||
String? payload,
|
||
}) async {
|
||
if (kIsWeb) return false;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
debugPrint('\n╔════════════════════════════════════════════╗');
|
||
debugPrint('║ 🔔 SCHEDULING 30-SECOND NOTIFICATION ║');
|
||
debugPrint('╚════════════════════════════════════════════╝\n');
|
||
|
||
// Pre-check: Verify permission with explicit check (not cached)
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
|
||
debugPrint('📋 STAGE 1: Permission Verification');
|
||
try {
|
||
// Request POST_NOTIFICATIONS if needed
|
||
final notifPermGranted =
|
||
await androidPlugin?.requestNotificationsPermission() ?? false;
|
||
debugPrint(
|
||
notifPermGranted
|
||
? ' ✅ POST_NOTIFICATIONS: GRANTED'
|
||
: ' ❌ POST_NOTIFICATIONS: DENIED (notifications won\'t show!)',
|
||
);
|
||
|
||
if (!notifPermGranted) {
|
||
debugPrint(
|
||
'🔴 ABORT: Cannot schedule - notification permission not granted',
|
||
);
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
debugPrint(' ⚠️ Permission check error: $e');
|
||
}
|
||
|
||
debugPrint('\n📋 STAGE 2: Exact Alarm Enforcement');
|
||
|
||
// Check and REQUEST exact alarm to maximize reliability
|
||
bool canScheduleExact =
|
||
await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
debugPrint(
|
||
' • Current exact alarm capability: ${canScheduleExact ? 'YES' : 'NO'}',
|
||
);
|
||
|
||
if (!canScheduleExact) {
|
||
debugPrint(
|
||
' 🔔 Attempting to request SCHEDULE_EXACT_ALARM permission...',
|
||
);
|
||
try {
|
||
canScheduleExact =
|
||
await androidPlugin?.requestExactAlarmsPermission() ?? false;
|
||
debugPrint(
|
||
' ${canScheduleExact ? '✅' : '⚠️'} After request: ${canScheduleExact ? 'GRANTED' : 'DENIED'}',
|
||
);
|
||
} catch (e) {
|
||
debugPrint(' ⚠️ Exact alarm request error: $e');
|
||
}
|
||
} else {
|
||
debugPrint(' ✅ Exact alarm already available');
|
||
}
|
||
|
||
debugPrint('\n📋 STAGE 3: Scheduling with 3-LEVEL FALLBACK (from TelurKu)');
|
||
|
||
// 🔥 3-LEVEL FALLBACK STRATEGY (adapted from working TelurKu app):
|
||
// Priority 1: alarmClock (best for background delivery on Android 12+)
|
||
// Priority 2: exactAllowWhileIdle (standard exact alarm)
|
||
// Priority 3: inexactAllowWhileIdle (fallback for devices without exact support)
|
||
|
||
final preferredScheduleMode =
|
||
canScheduleExact
|
||
? AndroidScheduleMode
|
||
.alarmClock // ← UPGRADED: Use alarmClock first!
|
||
: AndroidScheduleMode.inexactAllowWhileIdle;
|
||
|
||
debugPrint(' Primary schedule mode: ${preferredScheduleMode.toString()}');
|
||
|
||
// Attempt scheduling with exponential retry
|
||
for (int attempt = 1; attempt <= 5; attempt++) {
|
||
try {
|
||
final now = tz.TZDateTime.now(tz.local);
|
||
final scheduledDate = now.add(const Duration(seconds: 30));
|
||
|
||
debugPrint(
|
||
' Attempt $attempt: Scheduling for ${scheduledDate.hour}:${scheduledDate.minute}:${scheduledDate.second}',
|
||
);
|
||
|
||
final notificationId = _nextId();
|
||
|
||
// CRITICAL: Ensure notification channel exists BEFORE scheduling
|
||
await _setupNotificationChannels();
|
||
debugPrint(' 📢 Channel setup verification complete');
|
||
|
||
// Log pending before scheduling
|
||
final pendingBefore = await _plugin.pendingNotificationRequests();
|
||
debugPrint(' Pending BEFORE schedule: ${pendingBefore.length}');
|
||
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
notificationId,
|
||
title,
|
||
body,
|
||
scheduledDate,
|
||
_buildScheduledWateringNotificationDetails(),
|
||
androidScheduleMode: preferredScheduleMode,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint(
|
||
' ✅ SCHEDULED SUCCESSFULLY with ${preferredScheduleMode.toString()}',
|
||
);
|
||
} on PlatformException catch (e) {
|
||
// Handle exact alarm permission error with fallback
|
||
if (e.code == 'exact_alarms_not_permitted') {
|
||
debugPrint(
|
||
' ⚠️ Exact alarm not permitted, falling back to exactAllowWhileIdle...',
|
||
);
|
||
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
notificationId,
|
||
title,
|
||
body,
|
||
scheduledDate,
|
||
_buildScheduledWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint(' ✅ SCHEDULED SUCCESSFULLY with exactAllowWhileIdle');
|
||
} on PlatformException catch (e2) {
|
||
// Final fallback to inexact
|
||
debugPrint(
|
||
' ⚠️ exactAllowWhileIdle also failed ($e2), using inexactAllowWhileIdle...',
|
||
);
|
||
|
||
await _plugin.zonedSchedule(
|
||
notificationId,
|
||
title,
|
||
body,
|
||
scheduledDate,
|
||
_buildScheduledWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint(
|
||
' ✅ SCHEDULED SUCCESSFULLY with inexactAllowWhileIdle (final fallback)',
|
||
);
|
||
}
|
||
} else {
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
debugPrint(' Notification ID: $notificationId');
|
||
debugPrint(' Scheduled time: ${scheduledDate.toString()}');
|
||
debugPrint(
|
||
' Mode: ${canScheduleExact ? '⚡ ALARMCLOCK (best for background)' : '⏳ INEXACT (may delay on idle device)'}',
|
||
);
|
||
|
||
// Verify notification was added to pending list
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
final pending = await _plugin.pendingNotificationRequests();
|
||
final isInPending = pending.any((p) => p.id == notificationId);
|
||
debugPrint(
|
||
'\n✅ Verification: Notification ${isInPending ? 'IS' : 'NOT'} in pending list',
|
||
);
|
||
debugPrint(' Total pending notifications: ${pending.length}');
|
||
if (isInPending) {
|
||
final pendingNotif = pending.firstWhere(
|
||
(p) => p.id == notificationId,
|
||
);
|
||
debugPrint(
|
||
' Pending notif: ${pendingNotif.title} (ID: ${pendingNotif.id})',
|
||
);
|
||
}
|
||
|
||
debugPrint('\n💡 WHAT TO DO NEXT:');
|
||
debugPrint(' 1️⃣ Wait 30 seconds OR minimize app/go home/kill app');
|
||
debugPrint(' 2️⃣ Notification should appear from notification area');
|
||
debugPrint(' 3️⃣ If no notification: Check app notification settings');
|
||
debugPrint('\n💡 TROUBLESHOOTING:');
|
||
debugPrint(
|
||
' • If notification is in pending list but doesn\'t appear:',
|
||
);
|
||
debugPrint(' → Check MIUI/ColorOS/OneUI battery optimization');
|
||
debugPrint(
|
||
' → Settings → Battery → Background restrictions → IrriGo',
|
||
);
|
||
debugPrint(' → App should be in "Whitelist" or "Don\'t optimize"');
|
||
debugPrint('\n╚════════════════════════════════════════════╝\n');
|
||
|
||
return true;
|
||
} catch (e) {
|
||
debugPrint(' ❌ Attempt $attempt failed: $e');
|
||
if (attempt < 5) {
|
||
final delayMs = 200 * attempt;
|
||
debugPrint(' ⏳ Retrying in ${delayMs}ms...');
|
||
await Future.delayed(Duration(milliseconds: delayMs));
|
||
}
|
||
}
|
||
}
|
||
|
||
debugPrint('🔴 FAILED: Could not schedule notification after 5 attempts');
|
||
debugPrint('╚════════════════════════════════════════════╝\n');
|
||
return false;
|
||
}
|
||
|
||
/// Schedule daily notification
|
||
Future<void> scheduleDailyNotificationAtTime({
|
||
required String scheduleKey,
|
||
required String title,
|
||
required String body,
|
||
required int hour,
|
||
required int minute,
|
||
String? payload,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
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));
|
||
}
|
||
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
final canScheduleExact =
|
||
await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
|
||
// 🔥 3-TIER FALLBACK STRATEGY (From Working TelurKu):
|
||
// Priority 1: alarmClock (wakes device, fires even in deep sleep) ← CRITICAL!
|
||
// Priority 2: exactAllowWhileIdle (fires during doze window)
|
||
// Priority 3: inexactAllowWhileIdle (unreliable, major delay possible)
|
||
|
||
final primaryMode =
|
||
canScheduleExact
|
||
? AndroidScheduleMode
|
||
.alarmClock // ✅ UPGRADED: Primary for background
|
||
: AndroidScheduleMode.inexactAllowWhileIdle;
|
||
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
_stableIdFromKey(scheduleKey),
|
||
title,
|
||
body,
|
||
scheduled,
|
||
_buildWateringNotificationDetails(),
|
||
androidScheduleMode: primaryMode,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
matchDateTimeComponents: DateTimeComponents.time,
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint(
|
||
'✅ Daily scheduled at $hour:${minute.toString().padLeft(2, '0')} with ${primaryMode.toString().split('.').last}',
|
||
);
|
||
} on PlatformException catch (e) {
|
||
// Fallback to exactAllowWhileIdle if alarmClock fails
|
||
if (e.code == 'exact_alarms_not_permitted') {
|
||
debugPrint(
|
||
'⚠️ alarmClock not permitted, falling back to exactAllowWhileIdle...',
|
||
);
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
_stableIdFromKey(scheduleKey),
|
||
title,
|
||
body,
|
||
scheduled,
|
||
_buildWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
matchDateTimeComponents: DateTimeComponents.time,
|
||
payload: payload,
|
||
);
|
||
debugPrint('✅ Scheduled with exactAllowWhileIdle fallback');
|
||
} on PlatformException catch (_) {
|
||
// Final fallback to inexactAllowWhileIdle
|
||
await _plugin.zonedSchedule(
|
||
_stableIdFromKey(scheduleKey),
|
||
title,
|
||
body,
|
||
scheduled,
|
||
_buildWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
matchDateTimeComponents: DateTimeComponents.time,
|
||
payload: payload,
|
||
);
|
||
debugPrint('✅ Scheduled with inexactAllowWhileIdle final fallback');
|
||
}
|
||
} else {
|
||
rethrow;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ Error: $e');
|
||
}
|
||
}
|
||
|
||
/// Cancel notification
|
||
Future<void> cancelScheduledNotification(String scheduleKey) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
await _plugin.cancel(_stableIdFromKey(scheduleKey));
|
||
debugPrint('✅ Cancelled: $scheduleKey');
|
||
} catch (e) {
|
||
debugPrint('⚠️ Error: $e');
|
||
}
|
||
}
|
||
|
||
/// Sync schedule reminders from Firebase
|
||
Future<void> syncScheduleRemindersFromFirebase() async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
debugPrint('📡 Syncing schedules from Firebase...');
|
||
|
||
final db = FirebaseDatabase.instance.ref();
|
||
await _syncFromKontrolNode(db.child('kontrol'), 'kontrol');
|
||
await _syncFromKontrolNode(db.child('kontrol_1'), 'kontrol_1');
|
||
}
|
||
|
||
/// ⭐ NEW: Schedule watering execution for all active schedules
|
||
/// This is the main method to use for scheduling watering without worker
|
||
Future<void> scheduleAllWateringExecutions() async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
debugPrint('⏰ Scheduling all watering executions from Firebase...');
|
||
|
||
final db = FirebaseDatabase.instance.ref();
|
||
|
||
// Try both kontrol and kontrol_1 paths
|
||
for (final path in ['kontrol', 'kontrol_1']) {
|
||
await _scheduleWateringFromKontrolNode(db.child(path), path);
|
||
}
|
||
}
|
||
|
||
/// Helper to schedule watering from a kontrol node
|
||
Future<void> _scheduleWateringFromKontrolNode(
|
||
DatabaseReference ref,
|
||
String nodeName,
|
||
) async {
|
||
try {
|
||
final snapshot = await ref.get();
|
||
if (!snapshot.exists || snapshot.value is! Map) {
|
||
debugPrint('⏰ No data found for $nodeName');
|
||
return;
|
||
}
|
||
|
||
final data = Map<String, dynamic>.from(snapshot.value as Map);
|
||
final waktuEnabled = data['waktu'] == true;
|
||
|
||
if (!waktuEnabled) {
|
||
debugPrint('⏰ Waktu mode is disabled for $nodeName');
|
||
return;
|
||
}
|
||
|
||
final scheduleKeys =
|
||
data.keys.where((key) => key.startsWith('jadwal_')).toList()..sort();
|
||
|
||
debugPrint(
|
||
'⏰ Found ${scheduleKeys.length} jadwal candidates in $nodeName: $scheduleKeys',
|
||
);
|
||
|
||
int scheduledCount = 0;
|
||
for (final key in scheduleKeys) {
|
||
final scheduleDataRaw = data[key];
|
||
if (scheduleDataRaw is! Map) {
|
||
debugPrint('⏰ Skipping $key: not a Map');
|
||
continue;
|
||
}
|
||
|
||
final scheduleData = Map<String, dynamic>.from(scheduleDataRaw);
|
||
final aktif = scheduleData['aktif'] != false;
|
||
final waktu = (scheduleData['waktu'] ?? '').toString();
|
||
final isValidTime = RegExp(
|
||
r'^([01]\d|2[0-3]):[0-5]\d$',
|
||
).hasMatch(waktu);
|
||
|
||
debugPrint(
|
||
'⏰ $key: aktif=$aktif, waktu=$waktu, isValidTime=$isValidTime',
|
||
);
|
||
|
||
if (!aktif || !isValidTime) {
|
||
debugPrint('⏰ Skipping $key: aktif=$aktif, validTime=$isValidTime');
|
||
continue;
|
||
}
|
||
|
||
final parts = waktu.split(':');
|
||
final hour = int.parse(parts[0]);
|
||
final minute = int.parse(parts[1]);
|
||
|
||
// Schedule watering execution (not just notification reminder!)
|
||
await _scheduleWateringExecutionAtTime(
|
||
scheduleKey: '$nodeName:$key',
|
||
kontrolPath: nodeName,
|
||
scheduleId: key,
|
||
hour: hour,
|
||
minute: minute,
|
||
scheduleData: scheduleData,
|
||
);
|
||
scheduledCount++;
|
||
}
|
||
|
||
debugPrint(
|
||
'✅ Successfully scheduled $scheduledCount watering executions from $nodeName (total candidates: ${scheduleKeys.length})',
|
||
);
|
||
} catch (e) {
|
||
debugPrint('❌ Error scheduling watering from $nodeName: $e');
|
||
}
|
||
}
|
||
|
||
/// Schedule watering execution at specific time
|
||
/// This actually executes the watering, not just sends notification
|
||
Future<void> _scheduleWateringExecutionAtTime({
|
||
required String scheduleKey,
|
||
required String kontrolPath,
|
||
required String scheduleId,
|
||
required int hour,
|
||
required int minute,
|
||
required Map<String, dynamic> scheduleData,
|
||
}) async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
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));
|
||
}
|
||
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
final canScheduleExact =
|
||
await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
|
||
// Build payload with full schedule data for execution
|
||
final payload = jsonEncode({
|
||
'type': 'watering_execution',
|
||
'scheduleKey': scheduleKey,
|
||
'kontrolPath': kontrolPath,
|
||
'scheduleId': scheduleId,
|
||
'pompa_air': scheduleData['pompa_air'] ?? true,
|
||
'pompa_pupuk': scheduleData['pompa_pupuk'] ?? false,
|
||
'pompa_pengaduk': scheduleData['pompa_pengaduk'] ?? false,
|
||
'pot_aktif': scheduleData['pot_aktif'] ?? [1, 2, 3, 4, 5],
|
||
'durasi': scheduleData['durasi'] ?? 60,
|
||
});
|
||
|
||
// Build title dan body dengan format lengkap (SESUAI GAMBAR!)
|
||
final title = 'IrriGo - Penyiraman Terjadwal';
|
||
final potAktif =
|
||
(scheduleData['pot_aktif'] as List?)?.cast<int>().join(', ') ??
|
||
'semua pot';
|
||
final durasiDetik = scheduleData['durasi'] ?? 60;
|
||
final body =
|
||
'Jadwal: ${scheduleId.replaceFirst('jadwal_', '')}\n'
|
||
'Waktu: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}\n'
|
||
'Pot: $potAktif ($durasiDetik detik)';
|
||
|
||
final notifId = _stableIdFromKey(scheduleKey);
|
||
|
||
// 🔥 3-TIER FALLBACK STRATEGY:
|
||
// Priority 1: alarmClock (wakes device for watering!) ← CRITICAL!
|
||
// Priority 2: exactAllowWhileIdle (standard exact alarm)
|
||
// Priority 3: inexactAllowWhileIdle (fallback for devices without exact)
|
||
|
||
final primaryMode =
|
||
canScheduleExact
|
||
? AndroidScheduleMode
|
||
.alarmClock // ✅ UPGRADED: Primary
|
||
: AndroidScheduleMode.inexactAllowWhileIdle;
|
||
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
notifId,
|
||
title,
|
||
body,
|
||
scheduled,
|
||
_buildScheduledWateringNotificationDetails(),
|
||
androidScheduleMode: primaryMode,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
matchDateTimeComponents: DateTimeComponents.time,
|
||
payload: payload,
|
||
);
|
||
|
||
debugPrint(
|
||
'⏰ ✅ Scheduled $scheduleId: ID=$notifId, time=${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}, mode=${primaryMode.toString()}',
|
||
);
|
||
debugPrint(' Next fire: ${scheduled.toString()}');
|
||
debugPrint(' Payload keys: ${jsonDecode(payload).keys.toString()}');
|
||
} on PlatformException catch (e) {
|
||
// Fallback 1: exactAllowWhileIdle
|
||
if (e.code == 'exact_alarms_not_permitted') {
|
||
debugPrint(
|
||
'⚠️ alarmClock not permitted for $scheduleId, trying exactAllowWhileIdle...',
|
||
);
|
||
try {
|
||
await _plugin.zonedSchedule(
|
||
notifId,
|
||
title,
|
||
body,
|
||
scheduled,
|
||
_buildScheduledWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
matchDateTimeComponents: DateTimeComponents.time,
|
||
payload: payload,
|
||
);
|
||
debugPrint('⏰ ✅ Scheduled $scheduleId with exactAllowWhileIdle');
|
||
} on PlatformException catch (_) {
|
||
// Fallback 2: inexactAllowWhileIdle
|
||
await _plugin.zonedSchedule(
|
||
notifId,
|
||
title,
|
||
body,
|
||
scheduled,
|
||
_buildScheduledWateringNotificationDetails(),
|
||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||
uiLocalNotificationDateInterpretation:
|
||
UILocalNotificationDateInterpretation.absoluteTime,
|
||
matchDateTimeComponents: DateTimeComponents.time,
|
||
payload: payload,
|
||
);
|
||
debugPrint('⏰ ✅ Scheduled $scheduleId with inexactAllowWhileIdle');
|
||
}
|
||
} else {
|
||
rethrow;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ Error scheduling watering execution for $scheduleId: $e');
|
||
}
|
||
}
|
||
|
||
/// Cancel all watering schedule executions
|
||
Future<void> cancelAllWateringSchedules() async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
final pending = await _plugin.pendingNotificationRequests();
|
||
|
||
// Cancel only watering execution schedules (those that start with kontrol:jadwal)
|
||
final wateringSchedules =
|
||
pending.where((p) {
|
||
return p.payload != null &&
|
||
p.payload!.contains('watering_execution');
|
||
}).toList();
|
||
|
||
for (final schedule in wateringSchedules) {
|
||
await _plugin.cancel(schedule.id);
|
||
}
|
||
|
||
debugPrint('✅ Cancelled ${wateringSchedules.length} watering schedules');
|
||
} catch (e) {
|
||
debugPrint('❌ Error cancelling watering schedules: $e');
|
||
}
|
||
}
|
||
|
||
/// ⭐ Check and execute pending watering schedules
|
||
/// Call this when app resumes to ensure watering executes
|
||
/// even if user didn't tap notification while app was backgrounded
|
||
Future<void> checkAndExecutePendingWaterings() async {
|
||
if (kIsWeb) return;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
debugPrint(
|
||
'🔔 Checking pending watering schedules (call when app resumes)...',
|
||
);
|
||
|
||
final pending = await _plugin.pendingNotificationRequests();
|
||
debugPrint('📋 Total pending notifications: ${pending.length}');
|
||
|
||
// Find watering_execution type notifications
|
||
final wateringNotifications =
|
||
pending.where((p) {
|
||
if (p.payload == null) return false;
|
||
try {
|
||
final data = jsonDecode(p.payload!) as Map<String, dynamic>;
|
||
return data['type'] == 'watering_execution';
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}).toList();
|
||
|
||
debugPrint(
|
||
'💧 Found ${wateringNotifications.length} pending watering notifications',
|
||
);
|
||
|
||
// Execute each pending watering
|
||
for (final notif in wateringNotifications) {
|
||
try {
|
||
final data = jsonDecode(notif.payload!) as Map<String, dynamic>;
|
||
final scheduleId = (data['scheduleId'] ?? 'unknown') as String;
|
||
|
||
debugPrint(
|
||
' 📌 Executing pending watering: $scheduleId (notif ID: ${notif.id})',
|
||
);
|
||
|
||
// Execute via callback atau direct
|
||
if (_onWateringScheduleCallback != null) {
|
||
// Execute via callback
|
||
await Future.sync(() => _onWateringScheduleCallback?.call(data));
|
||
debugPrint(' ✅ Executed via callback');
|
||
} else {
|
||
debugPrint(' ⚠️ No callback registered, skipping execution');
|
||
}
|
||
|
||
// Cancel notification setelah execute (optional, untuk clean state)
|
||
// await _plugin.cancel(notif.id);
|
||
} catch (e) {
|
||
debugPrint(' ❌ Error executing watering: $e');
|
||
}
|
||
}
|
||
|
||
if (wateringNotifications.isNotEmpty) {
|
||
debugPrint(
|
||
'✅ Checked and processed ${wateringNotifications.length} pending watering notifications',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ Error checking pending waterings: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _syncFromKontrolNode(
|
||
DatabaseReference ref,
|
||
String nodeName,
|
||
) async {
|
||
try {
|
||
final snapshot = await ref.get();
|
||
if (!snapshot.exists || snapshot.value is! Map) {
|
||
return;
|
||
}
|
||
|
||
final data = Map<String, dynamic>.from(snapshot.value as Map);
|
||
final waktuEnabled = data['waktu'] == true;
|
||
|
||
final scheduleKeys =
|
||
data.keys.where((key) => key.startsWith('jadwal_')).toList()..sort();
|
||
|
||
for (final key in scheduleKeys) {
|
||
final scheduleKey = '$nodeName:$key';
|
||
final scheduleDataRaw = data[key];
|
||
if (scheduleDataRaw is! Map) {
|
||
await cancelScheduledNotification(scheduleKey);
|
||
continue;
|
||
}
|
||
|
||
final scheduleData = Map<String, dynamic>.from(scheduleDataRaw);
|
||
final aktif = scheduleData['aktif'] != false;
|
||
final waktu = (scheduleData['waktu'] ?? '').toString();
|
||
final isValidTime = RegExp(
|
||
r'^([01]\d|2[0-3]):[0-5]\d$',
|
||
).hasMatch(waktu);
|
||
|
||
if (!waktuEnabled || !aktif || !isValidTime) {
|
||
await cancelScheduledNotification(scheduleKey);
|
||
continue;
|
||
}
|
||
|
||
final parts = waktu.split(':');
|
||
final hour = int.parse(parts[0]);
|
||
final minute = int.parse(parts[1]);
|
||
|
||
await scheduleDailyNotificationAtTime(
|
||
scheduleKey: scheduleKey,
|
||
title: 'IrriGo - Jadwal Penyiraman',
|
||
body: 'Penyiraman akan dimulai pada jam $waktu',
|
||
hour: hour,
|
||
minute: minute,
|
||
payload: jsonEncode({
|
||
'type': 'local_schedule',
|
||
'key': key,
|
||
'time': waktu,
|
||
}),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ Error syncing $nodeName: $e');
|
||
}
|
||
}
|
||
|
||
/// ==================== FIREBASE & FCM ====================
|
||
|
||
Future<String?> getFcmToken() async {
|
||
if (kIsWeb) return null;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
final token = await FirebaseMessaging.instance.getToken();
|
||
return token;
|
||
} catch (e) {
|
||
debugPrint('❌ FCM token error: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> _showRemoteMessage(RemoteMessage message) async {
|
||
final notification = message.notification;
|
||
final title = notification?.title ?? 'IrriGo';
|
||
final body = notification?.body ?? '';
|
||
|
||
if (body.isEmpty) return;
|
||
|
||
await showInstantNotification(
|
||
title: title,
|
||
body: body,
|
||
payload: jsonEncode(message.data),
|
||
isWatering: body.toLowerCase().contains('penyiraman'),
|
||
);
|
||
}
|
||
|
||
/// ==================== UTILITIES ====================
|
||
|
||
/// Check if notification ID is still in pending list
|
||
Future<bool> isNotificationPending(int notificationId) async {
|
||
if (kIsWeb) return false;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
final pending = await _plugin.pendingNotificationRequests();
|
||
return pending.any((p) => p.id == notificationId);
|
||
} catch (e) {
|
||
debugPrint('⚠️ Error checking pending: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Get detailed diagnostic about a specific notification
|
||
Future<Map<String, dynamic>> getNotificationStatus(int notificationId) async {
|
||
if (kIsWeb) return {};
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
final pending = await _plugin.pendingNotificationRequests();
|
||
final matching = pending.where((p) => p.id == notificationId).toList();
|
||
|
||
return {
|
||
'id': notificationId,
|
||
'isPending': matching.isNotEmpty,
|
||
'title': matching.isNotEmpty ? matching.first.title : null,
|
||
'body': matching.isNotEmpty ? matching.first.body : null,
|
||
'totalPending': pending.length,
|
||
};
|
||
} catch (e) {
|
||
return {'error': e.toString()};
|
||
}
|
||
}
|
||
|
||
Future<List<PendingNotificationRequest>> pendingNotifications() async {
|
||
if (kIsWeb) return <PendingNotificationRequest>[];
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
return await _plugin.pendingNotificationRequests();
|
||
} catch (e) {
|
||
debugPrint('⚠️ Error: $e');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
Future<void> cancelAll() async {
|
||
if (kIsWeb) return;
|
||
try {
|
||
await _plugin.cancelAll();
|
||
debugPrint('✅ All notifications cancelled');
|
||
} catch (e) {
|
||
debugPrint('⚠️ Error: $e');
|
||
}
|
||
}
|
||
|
||
Future<bool> canScheduleExactNotifications() async {
|
||
if (kIsWeb) return false;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
return await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
}
|
||
|
||
Future<bool> requestExactAlarmsPermission() async {
|
||
if (kIsWeb) return false;
|
||
if (!_isInitialized) await initialize();
|
||
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
try {
|
||
return await androidPlugin?.requestExactAlarmsPermission() ?? false;
|
||
} on PlatformException {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==================== PRIVATE HELPERS ====================
|
||
|
||
int _nextId() {
|
||
_notificationCounter = (_notificationCounter + 1) % 100000;
|
||
final nowPart = DateTime.now().millisecondsSinceEpoch % 2000000000;
|
||
final id = nowPart + _notificationCounter;
|
||
return id > _maxAndroidNotificationId ? id - _maxAndroidNotificationId : id;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
static void _onNotificationResponse(NotificationResponse response) {
|
||
final payload = response.payload;
|
||
if (payload != null) {
|
||
debugPrint('🔔 Notification response payload: $payload');
|
||
|
||
try {
|
||
final data = jsonDecode(payload) as Map<String, dynamic>;
|
||
final type = data['type'] as String?;
|
||
|
||
switch (type) {
|
||
case 'sensor_triggered':
|
||
debugPrint('📊 Sensor triggered watering detected');
|
||
break;
|
||
case 'local_schedule':
|
||
debugPrint('🕐 Local schedule notification triggered');
|
||
// Trigger watering execution via callback
|
||
if (_onWateringScheduleCallback != null) {
|
||
debugPrint('🔔 Executing scheduled watering via callback...');
|
||
_onWateringScheduleCallback!(data);
|
||
}
|
||
break;
|
||
case 'watering_execution':
|
||
debugPrint('💧 Watering execution scheduled');
|
||
// Trigger watering execution via callback
|
||
if (_onWateringScheduleCallback != null) {
|
||
debugPrint('🔔 Executing watering via callback...');
|
||
_onWateringScheduleCallback!(data);
|
||
}
|
||
break;
|
||
case 'mode_change':
|
||
debugPrint('🔄 Mode change notification');
|
||
break;
|
||
default:
|
||
debugPrint('ℹ️ Generic notification: $type');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('⚠️ Error parsing payload: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// ==================== DIAGNOSTIC ====================
|
||
|
||
/// Get diagnostic status of notification system
|
||
Future<Map<String, dynamic>> getDiagnosticStatus() async {
|
||
if (!_isInitialized) await initialize();
|
||
|
||
try {
|
||
final androidPlugin =
|
||
_plugin
|
||
.resolvePlatformSpecificImplementation<
|
||
AndroidFlutterLocalNotificationsPlugin
|
||
>();
|
||
|
||
final canScheduleExact =
|
||
await androidPlugin?.canScheduleExactNotifications() ?? false;
|
||
final pending = await pendingNotifications();
|
||
final fcmToken = await getFcmToken();
|
||
|
||
final status = {
|
||
'initialized': _isInitialized,
|
||
'permission_granted': _hasNotificationPermission,
|
||
'can_schedule_exact': canScheduleExact,
|
||
'pending_notifications': pending.length,
|
||
'has_fcm_token': fcmToken != null && fcmToken.isNotEmpty,
|
||
'fcm_token_length': fcmToken?.length ?? 0,
|
||
'timezone': tz.local.name,
|
||
};
|
||
|
||
debugPrint(
|
||
'📊 Notification Diagnostic:\n${status.entries.map((e) => ' ${e.key}: ${e.value}').join('\n')}',
|
||
);
|
||
|
||
return status;
|
||
} catch (e) {
|
||
debugPrint('❌ Diagnostic failed: $e');
|
||
return {'error': e.toString()};
|
||
}
|
||
}
|
||
|
||
/// Print diagnostic info to logs
|
||
Future<void> printDiagnosticInfo() async {
|
||
if (!_isInitialized) await initialize();
|
||
|
||
debugPrint('\n╔════════════════════════════════════════════╗');
|
||
debugPrint('║ 🔔 NOTIFICATION SYSTEM DIAGNOSTIC ║');
|
||
debugPrint('╚════════════════════════════════════════════╝\n');
|
||
|
||
debugPrint('✅ Service initialized: $_isInitialized');
|
||
debugPrint(
|
||
_hasNotificationPermission
|
||
? '✅ Permission granted: YES'
|
||
: '❌ Permission granted: NO',
|
||
);
|
||
|
||
final exact = await canScheduleExactNotifications();
|
||
debugPrint(
|
||
exact ? '✅ Exact alarm: AVAILABLE' : '⚠️ Exact alarm: UNAVAILABLE',
|
||
);
|
||
|
||
final pending = await pendingNotifications();
|
||
debugPrint('📋 Pending notifications: ${pending.length}');
|
||
|
||
final token = await getFcmToken();
|
||
debugPrint(
|
||
token != null && token.isNotEmpty
|
||
? '✅ FCM Token: ${token.substring(0, 20)}...'
|
||
: '❌ FCM Token: NOT SET',
|
||
);
|
||
|
||
debugPrint('\n💡 TROUBLESHOOTING TIPS:');
|
||
if (!_hasNotificationPermission) {
|
||
debugPrint(
|
||
' - Permission NOT granted. User must grant POST_NOTIFICATIONS',
|
||
);
|
||
debugPrint(
|
||
' - Check: Settings → Apps → IrriGo → Notifications → Allow',
|
||
);
|
||
}
|
||
if (!exact) {
|
||
debugPrint(
|
||
' - Exact alarm not available. Scheduling will use inexact (could delay 15+ minutes)',
|
||
);
|
||
}
|
||
if (pending.isEmpty) {
|
||
debugPrint(' - No scheduled notifications pending');
|
||
}
|
||
|
||
debugPrint('\n╚════════════════════════════════════════════╝\n');
|
||
}
|
||
}
|