Initial commit

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 20:29:22 +01:00
commit f050d10a1d
137 changed files with 5877 additions and 0 deletions
+248
View File
@@ -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;
}
}
+38
View File
@@ -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();
}
}
+47
View File
@@ -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));
}
}
+103
View File
@@ -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')}';
}
}