@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class OpenExchangeService {
|
||||
OpenExchangeService({required this.httpClient, required this.apiKey});
|
||||
|
||||
final http.Client httpClient;
|
||||
final String apiKey;
|
||||
|
||||
Future<double> fetchUsdToSekRate({required DateTime estDate}) async {
|
||||
if (apiKey == 'REPLACE_WITH_YOUR_OPENEXCHANGE_API_KEY') {
|
||||
throw StateError('OpenExchange API key is not configured.');
|
||||
}
|
||||
|
||||
final date = DateFormat('yyyy-MM-dd').format(estDate);
|
||||
final uri = Uri.https(
|
||||
'openexchangerates.org',
|
||||
'/api/historical/$date.json',
|
||||
<String, String>{'app_id': apiKey, 'symbols': 'SEK'},
|
||||
);
|
||||
|
||||
final response = await httpClient.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OpenExchange request failed (${response.statusCode}).');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final rates = json['rates'] as Map<String, dynamic>?;
|
||||
final sek = rates?['SEK'];
|
||||
if (sek == null) {
|
||||
throw StateError('SEK rate was missing in API response.');
|
||||
}
|
||||
|
||||
return (sek as num).toDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:rental_income_tracker/models/app_settings.dart';
|
||||
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class StorageService {
|
||||
static const _settingsKey = 'app_settings';
|
||||
static const _recordsKey = 'monthly_records';
|
||||
|
||||
Future<AppSettings> loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_settingsKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return AppSettings.defaults();
|
||||
}
|
||||
|
||||
return AppSettings.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> saveSettings(AppSettings settings) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_settingsKey, jsonEncode(settings.toJson()));
|
||||
}
|
||||
|
||||
Future<Map<String, MonthlyRentRecord>> loadRecords() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_recordsKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return <String, MonthlyRentRecord>{};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) {
|
||||
return MapEntry(
|
||||
key,
|
||||
MonthlyRentRecord.fromJson(value as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> saveRecords(Map<String, MonthlyRentRecord> records) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final encoded = records.map((key, value) => MapEntry(key, value.toJson()));
|
||||
await prefs.setString(_recordsKey, jsonEncode(encoded));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
class TimeService {
|
||||
static const String estLocationName = 'America/New_York';
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
tzdata.initializeTimeZones();
|
||||
final localName = await FlutterTimezone.getLocalTimezone();
|
||||
tz.setLocalLocation(tz.getLocation(localName));
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static tz.Location get est => tz.getLocation(estLocationName);
|
||||
|
||||
static tz.TZDateTime estNow() => tz.TZDateTime.now(est);
|
||||
|
||||
static DateTime localNow() => tz.TZDateTime.now(tz.local);
|
||||
|
||||
static tz.TZDateTime estDeadlinePassedAt(int year, int month) {
|
||||
return tz.TZDateTime(est, year, month, 2, 0, 0);
|
||||
}
|
||||
|
||||
static tz.TZDateTime estFirstReminderAt(int year, int month) {
|
||||
return tz.TZDateTime(est, year, month, 2, 0, 5);
|
||||
}
|
||||
|
||||
static bool isOnTimeByDeadline(DateTime paidAtUtc, int year, int month) {
|
||||
final estPaidAt = tz.TZDateTime.from(paidAtUtc, est);
|
||||
final cutoff = tz.TZDateTime(est, year, month, 1, 23, 59, 59, 999);
|
||||
return !estPaidAt.isAfter(cutoff);
|
||||
}
|
||||
|
||||
static DateTime applyQuietHours({
|
||||
required DateTime localTime,
|
||||
required int quietHourStart,
|
||||
required int fallbackHour,
|
||||
}) {
|
||||
if (localTime.hour >= quietHourStart) {
|
||||
return DateTime(
|
||||
localTime.year,
|
||||
localTime.month,
|
||||
localTime.day + 1,
|
||||
fallbackHour,
|
||||
);
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
static DateTime nextEligibleLocal({
|
||||
required DateTime candidate,
|
||||
required int quietHourStart,
|
||||
required int fallbackHour,
|
||||
}) {
|
||||
final now = localNow();
|
||||
var result = candidate;
|
||||
|
||||
if (result.isBefore(now)) {
|
||||
result = now.add(const Duration(minutes: 1));
|
||||
}
|
||||
|
||||
if (result.hour >= quietHourStart) {
|
||||
result = DateTime(
|
||||
result.year,
|
||||
result.month,
|
||||
result.day + 1,
|
||||
fallbackHour,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool monthAtOrAfter({
|
||||
required int year,
|
||||
required int month,
|
||||
required int pivotYear,
|
||||
required int pivotMonth,
|
||||
}) {
|
||||
return year > pivotYear || (year == pivotYear && month >= pivotMonth);
|
||||
}
|
||||
|
||||
static bool monthBefore({
|
||||
required int year,
|
||||
required int month,
|
||||
required int otherYear,
|
||||
required int otherMonth,
|
||||
}) {
|
||||
return year < otherYear || (year == otherYear && month < otherMonth);
|
||||
}
|
||||
|
||||
static String monthKey(int year, int month) {
|
||||
return '$year-${month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user