Files

251 lines
7.1 KiB
Dart

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,
// Best-effort delivery avoids the exact alarm permission requirement on
// recent Android versions while still allowing reminders during idle.
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
}
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;
}
}