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