e10cde6fb9
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
251 lines
7.1 KiB
Dart
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;
|
|
}
|
|
}
|