From fb7e3216f17a401deb4e0cbb0236c28c44f79910 Mon Sep 17 00:00:00 2001 From: Wizznu Date: Fri, 10 Apr 2026 20:03:25 +0700 Subject: [PATCH] fix: remove deprecated Flutter notification API parameters and unnecessary imports --- lib/services/notification_service.dart | 674 +++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 lib/services/notification_service.dart diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..f3e9807 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,674 @@ +import 'dart:convert'; +import 'dart:async'; + +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; + +/// 🔔 NOTIFICATION SERVICE v2.0 - WITH BACKGROUND SUPPORT +/// +/// 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 +class NotificationService { + NotificationService._(); + + static final NotificationService instance = NotificationService._(); + + static const String topicName = 'apsgo_notifications'; + static const String wateringChannelId = 'apsgo_watering_channel'; + static const String alertChannelId = 'apsgo_alert_channel'; + static const String infoChannelId = 'apsgo_info_channel'; + static const int _maxAndroidNotificationId = 2147483647; + + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + bool _isInitialized = false; + int _notificationCounter = 0; + + /// ==================== INITIALIZATION ==================== + + /// Initialize notification service dengan complete setup + Future 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: _onNotificationResponse, + ); + + // Setup channels & permissions + await _setupNotificationChannels(); + await _requestAllPermissions(); + + // 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!'); + } + + /// Setup notification channels + Future _setupNotificationChannels() async { + final androidPlugin = + _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + if (androidPlugin == null) return; + + try { + // Watering channel - PRIORITY TINGGI + const wateringChannel = AndroidNotificationChannel( + wateringChannelId, + 'ApsGo Penyiraman', + description: 'Notifikasi penyiraman - selalu muncul!', + importance: Importance.max, + playSound: true, + enableLights: true, + enableVibration: true, + ); + + // Alert channel + const alertChannel = AndroidNotificationChannel( + alertChannelId, + 'ApsGo Alert', + description: 'Alert dan warning dari sistem', + importance: Importance.high, + playSound: true, + enableLights: true, + enableVibration: true, + ); + + // Info channel + const infoChannel = AndroidNotificationChannel( + infoChannelId, + 'ApsGo Informasi', + description: 'Informasi sistem ApsGo', + 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 + Future _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'); + } catch (e) { + debugPrint('⚠️ Permission error: $e'); + } + } + + /// 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) + NotificationDetails _buildWateringNotificationDetails() { + return NotificationDetails( + android: AndroidNotificationDetails( + wateringChannelId, + 'ApsGo Penyiraman', + channelDescription: 'Notifikasi penyiraman', + importance: Importance.max, + priority: Priority.high, + playSound: true, + enableVibration: true, + 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 alert notification details + NotificationDetails _buildAlertNotificationDetails() { + return NotificationDetails( + android: AndroidNotificationDetails( + alertChannelId, + 'ApsGo 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, + 'ApsGo 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, + ), + ); + } + + /// Show instant notification + Future showInstantNotification({ + required String title, + required String body, + String? payload, + bool isWatering = false, + }) async { + if (kIsWeb) return; + if (!_isInitialized) await initialize(); + + 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 showWateringNotification({ + required String title, + required String body, + Map? payload, + }) async { + if (kIsWeb) return; + if (!_isInitialized) await initialize(); + + 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 showAlertNotification({ + required String title, + required String body, + String? payload, + }) async { + if (kIsWeb) return; + if (!_isInitialized) await initialize(); + + try { + await _plugin.show( + _nextId(), + title, + body, + _buildAlertNotificationDetails(), + payload: payload, + ); + + debugPrint('✅ Alert notification: "$title"'); + } catch (e) { + debugPrint('❌ Error: $e'); + } + } + + /// Show mode changed notification + Future 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}), + ); + } + + /// ==================== SCHEDULING ==================== + + /// Schedule notification dengan retry logic + Future scheduleNotification({ + required String title, + required String body, + required Duration delay, + String? payload, + int retryCount = 3, + }) async { + if (kIsWeb) return; + if (!_isInitialized) await initialize(); + + 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; + final scheduleMode = + canScheduleExact + ? AndroidScheduleMode.exactAllowWhileIdle + : AndroidScheduleMode.inexactAllowWhileIdle; + + await _plugin.zonedSchedule( + _nextId(), + title, + body, + scheduledDate, + _buildWateringNotificationDetails(), + androidScheduleMode: scheduleMode, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + payload: payload, + ); + + debugPrint('✅ Scheduled: "$title" in ${delay.inSeconds}s'); + return; + } catch (e) { + if (attempt < retryCount) { + await Future.delayed(Duration(milliseconds: 500 * attempt)); + } else { + debugPrint('❌ Failed after $retryCount attempts: $e'); + } + } + } + } + + /// Schedule daily notification + Future 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; + final scheduleMode = + canScheduleExact + ? AndroidScheduleMode.exactAllowWhileIdle + : AndroidScheduleMode.inexactAllowWhileIdle; + + await _plugin.zonedSchedule( + _stableIdFromKey(scheduleKey), + title, + body, + scheduled, + _buildWateringNotificationDetails(), + androidScheduleMode: scheduleMode, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.time, + payload: payload, + ); + + debugPrint( + '✅ Daily scheduled at $hour:${minute.toString().padLeft(2, '0')}', + ); + } catch (e) { + debugPrint('❌ Error: $e'); + } + } + + /// Cancel notification + Future 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 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'); + } + + Future _syncFromKontrolNode( + DatabaseReference ref, + String nodeName, + ) async { + try { + final snapshot = await ref.get(); + if (!snapshot.exists || snapshot.value is! Map) { + return; + } + + final data = Map.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.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: 'ApsGo - 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 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 _showRemoteMessage(RemoteMessage message) async { + final notification = message.notification; + final title = notification?.title ?? 'ApsGo'; + final body = notification?.body ?? ''; + + if (body.isEmpty) return; + + await showInstantNotification( + title: title, + body: body, + payload: jsonEncode(message.data), + isWatering: body.toLowerCase().contains('penyiraman'), + ); + } + + /// ==================== UTILITIES ==================== + + Future> pendingNotifications() async { + if (kIsWeb) return []; + if (!_isInitialized) await initialize(); + + try { + return await _plugin.pendingNotificationRequests(); + } catch (e) { + debugPrint('⚠️ Error: $e'); + return []; + } + } + + Future cancelAll() async { + if (kIsWeb) return; + try { + await _plugin.cancelAll(); + debugPrint('✅ All notifications cancelled'); + } catch (e) { + debugPrint('⚠️ Error: $e'); + } + } + + Future canScheduleExactNotifications() async { + if (kIsWeb) return false; + if (!_isInitialized) await initialize(); + + final androidPlugin = + _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + return await androidPlugin?.canScheduleExactNotifications() ?? false; + } + + Future 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'); + } + } +}