@@ -0,0 +1,248 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:rental_income_tracker/services/time_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
const String yesActionId = 'rent_yes';
|
||||
const String noActionId = 'rent_no';
|
||||
const String _pendingActionKey = 'pending_notification_actions';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void notificationTapBackground(
|
||||
NotificationResponse notificationResponse,
|
||||
) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(_pendingActionKey) ?? <String>[];
|
||||
list.add(
|
||||
jsonEncode(<String, String?>{
|
||||
'actionId': notificationResponse.actionId,
|
||||
'payload': notificationResponse.payload,
|
||||
}),
|
||||
);
|
||||
await prefs.setStringList(_pendingActionKey, list);
|
||||
}
|
||||
|
||||
class NotificationActionEvent {
|
||||
NotificationActionEvent({
|
||||
required this.year,
|
||||
required this.month,
|
||||
required this.isYes,
|
||||
required this.isRetry,
|
||||
});
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final bool isYes;
|
||||
final bool isRetry;
|
||||
}
|
||||
|
||||
typedef NotificationActionHandler =
|
||||
Future<void> Function(NotificationActionEvent event);
|
||||
|
||||
class NotificationService {
|
||||
NotificationService(this._onAction);
|
||||
|
||||
final NotificationActionHandler _onAction;
|
||||
final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> initialize() async {
|
||||
final darwinCategory = DarwinNotificationCategory(
|
||||
'rent_actions',
|
||||
actions: <DarwinNotificationAction>[
|
||||
DarwinNotificationAction.plain(yesActionId, 'Yes'),
|
||||
DarwinNotificationAction.plain(noActionId, 'No'),
|
||||
],
|
||||
);
|
||||
|
||||
final initSettings = InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
notificationCategories: <DarwinNotificationCategory>[darwinCategory],
|
||||
),
|
||||
);
|
||||
|
||||
await _plugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _handleNotificationResponse,
|
||||
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||
);
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
}
|
||||
|
||||
Future<void> processPendingActions() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(_pendingActionKey) ?? <String>[];
|
||||
if (list.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prefs.remove(_pendingActionKey);
|
||||
|
||||
for (final item in list) {
|
||||
final decoded = jsonDecode(item) as Map<String, dynamic>;
|
||||
final actionId = decoded['actionId'] as String?;
|
||||
final payload = decoded['payload'] as String?;
|
||||
await _dispatchAction(actionId: actionId, payload: payload);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scheduleForMonth({
|
||||
required int year,
|
||||
required int month,
|
||||
required int quietHourStart,
|
||||
required int fallbackHour,
|
||||
}) async {
|
||||
final firstId = _firstNotificationId(year, month);
|
||||
final retryId = _retryNotificationId(year, month);
|
||||
|
||||
await _plugin.cancel(firstId);
|
||||
await _plugin.cancel(retryId);
|
||||
|
||||
final estFirst = TimeService.estFirstReminderAt(year, month);
|
||||
final localFirst = tz.TZDateTime.from(estFirst, tz.local);
|
||||
final firstDate = TimeService.nextEligibleLocal(
|
||||
candidate: TimeService.applyQuietHours(
|
||||
localTime: localFirst,
|
||||
quietHourStart: quietHourStart,
|
||||
fallbackHour: fallbackHour,
|
||||
),
|
||||
quietHourStart: quietHourStart,
|
||||
fallbackHour: fallbackHour,
|
||||
);
|
||||
|
||||
final retryDate = TimeService.nextEligibleLocal(
|
||||
candidate: DateTime(
|
||||
firstDate.year,
|
||||
firstDate.month,
|
||||
firstDate.day + 1,
|
||||
firstDate.hour,
|
||||
firstDate.minute,
|
||||
),
|
||||
quietHourStart: quietHourStart,
|
||||
fallbackHour: fallbackHour,
|
||||
);
|
||||
|
||||
await _schedule(
|
||||
id: firstId,
|
||||
title: 'Rent check ($month/$year)',
|
||||
body: 'Was rent paid this month?',
|
||||
date: firstDate,
|
||||
payload: jsonEncode(<String, dynamic>{
|
||||
'year': year,
|
||||
'month': month,
|
||||
'isRetry': false,
|
||||
}),
|
||||
);
|
||||
|
||||
await _schedule(
|
||||
id: retryId,
|
||||
title: 'Rent check retry ($month/$year)',
|
||||
body: 'Please confirm rent payment status.',
|
||||
date: retryDate,
|
||||
payload: jsonEncode(<String, dynamic>{
|
||||
'year': year,
|
||||
'month': month,
|
||||
'isRetry': true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelForMonth(int year, int month) async {
|
||||
await _plugin.cancel(_firstNotificationId(year, month));
|
||||
await _plugin.cancel(_retryNotificationId(year, month));
|
||||
}
|
||||
|
||||
Future<void> cancelAll() => _plugin.cancelAll();
|
||||
|
||||
Future<void> _schedule({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime date,
|
||||
required String payload,
|
||||
}) async {
|
||||
final tzDate = tz.TZDateTime.from(date, tz.local);
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tzDate,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'rent_channel',
|
||||
'Rent reminders',
|
||||
channelDescription: 'Monthly rent confirmation reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
actions: <AndroidNotificationAction>[
|
||||
AndroidNotificationAction(yesActionId, 'Yes'),
|
||||
AndroidNotificationAction(noActionId, 'No'),
|
||||
],
|
||||
),
|
||||
iOS: DarwinNotificationDetails(categoryIdentifier: 'rent_actions'),
|
||||
),
|
||||
payload: payload,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleNotificationResponse(NotificationResponse response) {
|
||||
return _dispatchAction(
|
||||
actionId: response.actionId,
|
||||
payload: response.payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dispatchAction({
|
||||
required String? actionId,
|
||||
required String? payload,
|
||||
}) async {
|
||||
if (actionId != yesActionId && actionId != noActionId) {
|
||||
return;
|
||||
}
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final year = decoded['year'] as int;
|
||||
final month = decoded['month'] as int;
|
||||
final isRetry = decoded['isRetry'] as bool? ?? false;
|
||||
|
||||
await _onAction(
|
||||
NotificationActionEvent(
|
||||
year: year,
|
||||
month: month,
|
||||
isYes: actionId == yesActionId,
|
||||
isRetry: isRetry,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _firstNotificationId(int year, int month) {
|
||||
return year * 100 + month;
|
||||
}
|
||||
|
||||
int _retryNotificationId(int year, int month) {
|
||||
return 100000 + year * 100 + month;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user