TKK_E32230814/lib/services/notification_service.dart

1837 lines
60 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
}
}