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) ?? []; list.add( jsonEncode({ '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 Function(NotificationActionEvent event); class NotificationService { NotificationService(this._onAction); final NotificationActionHandler _onAction; final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); Future initialize() async { final darwinCategory = DarwinNotificationCategory( 'rent_actions', actions: [ 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: [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 processPendingActions() async { final prefs = await SharedPreferences.getInstance(); final list = prefs.getStringList(_pendingActionKey) ?? []; if (list.isEmpty) { return; } await prefs.remove(_pendingActionKey); for (final item in list) { final decoded = jsonDecode(item) as Map; final actionId = decoded['actionId'] as String?; final payload = decoded['payload'] as String?; await _dispatchAction(actionId: actionId, payload: payload); } } Future 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({ '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({ 'year': year, 'month': month, 'isRetry': true, }), ); } Future cancelForMonth(int year, int month) async { await _plugin.cancel(_firstNotificationId(year, month)); await _plugin.cancel(_retryNotificationId(year, month)); } Future cancelAll() => _plugin.cancelAll(); Future _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(yesActionId, 'Yes'), AndroidNotificationAction(noActionId, 'No'), ], ), iOS: DarwinNotificationDetails(categoryIdentifier: 'rent_actions'), ), payload: payload, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } Future _handleNotificationResponse(NotificationResponse response) { return _dispatchAction( actionId: response.actionId, payload: response.payload, ); } Future _dispatchAction({ required String? actionId, required String? payload, }) async { if (actionId != yesActionId && actionId != noActionId) { return; } if (payload == null) { return; } final decoded = jsonDecode(payload) as Map; 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; } }