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
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:rental_income_tracker/screens/home_screen.dart';
import 'package:rental_income_tracker/state/rent_controller.dart';
void main() {
runApp(const RentalIncomeTrackerApp());
}
class RentalIncomeTrackerApp extends StatelessWidget {
const RentalIncomeTrackerApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<RentController>(
create: (_) => RentController()..initialize(),
child: MaterialApp(
title: 'Rental Income Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomeScreen(),
),
);
}
}
+58
View File
@@ -0,0 +1,58 @@
class AppSettings {
AppSettings({
required this.rentUsd,
required this.occupied,
this.notOccupiedFromYearMonth,
this.localFallbackHour = 9,
this.localQuietHourStart = 23,
});
final double rentUsd;
final bool occupied;
final String? notOccupiedFromYearMonth;
final int localFallbackHour;
final int localQuietHourStart;
factory AppSettings.defaults() {
return AppSettings(rentUsd: 0, occupied: true);
}
AppSettings copyWith({
double? rentUsd,
bool? occupied,
String? notOccupiedFromYearMonth,
bool clearNotOccupiedFromYearMonth = false,
int? localFallbackHour,
int? localQuietHourStart,
}) {
return AppSettings(
rentUsd: rentUsd ?? this.rentUsd,
occupied: occupied ?? this.occupied,
notOccupiedFromYearMonth: clearNotOccupiedFromYearMonth
? null
: (notOccupiedFromYearMonth ?? this.notOccupiedFromYearMonth),
localFallbackHour: localFallbackHour ?? this.localFallbackHour,
localQuietHourStart: localQuietHourStart ?? this.localQuietHourStart,
);
}
Map<String, dynamic> toJson() {
return {
'rentUsd': rentUsd,
'occupied': occupied,
'notOccupiedFromYearMonth': notOccupiedFromYearMonth,
'localFallbackHour': localFallbackHour,
'localQuietHourStart': localQuietHourStart,
};
}
factory AppSettings.fromJson(Map<String, dynamic> json) {
return AppSettings(
rentUsd: (json['rentUsd'] ?? 0).toDouble(),
occupied: json['occupied'] ?? true,
notOccupiedFromYearMonth: json['notOccupiedFromYearMonth'] as String?,
localFallbackHour: json['localFallbackHour'] ?? 9,
localQuietHourStart: json['localQuietHourStart'] ?? 23,
);
}
}
+51
View File
@@ -0,0 +1,51 @@
enum PaymentStatus { onTime, late, notPaid, notOccupied, pending }
class MonthlyRentRecord {
MonthlyRentRecord({
required this.year,
required this.month,
required this.usdAmount,
required this.sekAmount,
required this.usdToSekRate,
required this.paidAtUtc,
required this.onTime,
required this.source,
});
final int year;
final int month;
final double usdAmount;
final double sekAmount;
final double usdToSekRate;
final DateTime paidAtUtc;
final bool onTime;
final String source;
String get key => '$year-${month.toString().padLeft(2, '0')}';
Map<String, dynamic> toJson() {
return {
'year': year,
'month': month,
'usdAmount': usdAmount,
'sekAmount': sekAmount,
'usdToSekRate': usdToSekRate,
'paidAtUtc': paidAtUtc.toIso8601String(),
'onTime': onTime,
'source': source,
};
}
factory MonthlyRentRecord.fromJson(Map<String, dynamic> json) {
return MonthlyRentRecord(
year: json['year'] as int,
month: json['month'] as int,
usdAmount: (json['usdAmount'] as num).toDouble(),
sekAmount: (json['sekAmount'] as num).toDouble(),
usdToSekRate: (json['usdToSekRate'] as num).toDouble(),
paidAtUtc: DateTime.parse(json['paidAtUtc'] as String).toUtc(),
onTime: json['onTime'] as bool,
source: (json['source'] as String?) ?? 'manual',
);
}
}
+199
View File
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
import 'package:rental_income_tracker/screens/settings_screen.dart';
import 'package:rental_income_tracker/state/rent_controller.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Consumer<RentController>(
builder: (context, controller, _) {
final rows = controller.buildRowsForSelectedYear();
final usdCurrency = NumberFormat.currency(
symbol: r'$',
decimalDigits: 2,
);
final sekCurrency = NumberFormat.currency(
symbol: 'SEK ',
decimalDigits: 2,
);
final totalUsd = rows.isEmpty ? 0.0 : rows.last.runningUsd;
final totalSek = rows.isEmpty ? 0.0 : rows.last.runningSek;
return Scaffold(
appBar: AppBar(
title: const Text('Rental Income Tracker'),
actions: <Widget>[
IconButton(
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const SettingsScreen(),
),
);
},
icon: const Icon(Icons.settings),
),
],
),
body: controller.isLoading
? const Center(child: CircularProgressIndicator())
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_YearSwitcher(
year: controller.selectedYear,
onPrevious: () =>
controller.selectYear(controller.selectedYear - 1),
onNext: controller.selectedYear < DateTime.now().year
? () => controller.selectYear(
controller.selectedYear + 1,
)
: null,
),
const SizedBox(height: 12),
if (controller.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
controller.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(
child: DataTable(
columns: const <DataColumn>[
DataColumn(label: Text('Month')),
DataColumn(label: Text('Status')),
DataColumn(label: Text('USD')),
DataColumn(label: Text('SEK')),
DataColumn(label: Text('Running USD')),
DataColumn(label: Text('Running SEK')),
],
rows: rows.map((row) {
return DataRow(
cells: <DataCell>[
DataCell(Text(row.monthLabel)),
DataCell(
Row(
children: <Widget>[
_statusIcon(row.status),
const SizedBox(width: 8),
Text(_statusLabel(row.status)),
],
),
),
DataCell(
Text(
row.usdAmount == null
? '-'
: usdCurrency.format(row.usdAmount),
),
),
DataCell(
Text(
row.sekAmount == null
? '-'
: sekCurrency.format(row.sekAmount),
),
),
DataCell(
Text(usdCurrency.format(row.runningUsd)),
),
DataCell(
Text(sekCurrency.format(row.runningSek)),
),
],
);
}).toList(),
),
),
),
),
const SizedBox(height: 12),
Text(
'Total: ${usdCurrency.format(totalUsd)} | ${sekCurrency.format(totalSek)}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () async {
await controller.markCurrentMonthPaidManually();
},
icon: const Icon(Icons.check_circle_outline),
label: const Text('Mark current month as paid'),
),
],
),
),
);
},
);
}
Widget _statusIcon(PaymentStatus status) {
switch (status) {
case PaymentStatus.onTime:
return const Icon(Icons.check_circle, color: Colors.green);
case PaymentStatus.late:
return const Icon(Icons.schedule, color: Colors.orange);
case PaymentStatus.notPaid:
return const Icon(Icons.cancel, color: Colors.red);
case PaymentStatus.notOccupied:
return const SizedBox.shrink();
case PaymentStatus.pending:
return const Icon(Icons.hourglass_bottom, color: Colors.grey);
}
}
String _statusLabel(PaymentStatus status) {
switch (status) {
case PaymentStatus.onTime:
return 'Paid on time';
case PaymentStatus.late:
return 'Paid late';
case PaymentStatus.notPaid:
return 'Not paid';
case PaymentStatus.notOccupied:
return 'Not occupied';
case PaymentStatus.pending:
return 'Pending';
}
}
}
class _YearSwitcher extends StatelessWidget {
const _YearSwitcher({
required this.year,
required this.onPrevious,
required this.onNext,
});
final int year;
final VoidCallback onPrevious;
final VoidCallback? onNext;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
IconButton(onPressed: onPrevious, icon: const Icon(Icons.chevron_left)),
Text('$year', style: Theme.of(context).textTheme.titleLarge),
IconButton(onPressed: onNext, icon: const Icon(Icons.chevron_right)),
],
);
}
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:rental_income_tracker/state/rent_controller.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _rentController = TextEditingController();
@override
void didChangeDependencies() {
super.didChangeDependencies();
final controller = context.read<RentController>();
_rentController.text = controller.settings.rentUsd.toStringAsFixed(2);
}
@override
void dispose() {
_rentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<RentController>(
builder: (context, controller, _) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
TextField(
controller: _rentController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Monthly rent (USD)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () async {
final value = double.tryParse(_rentController.text.trim());
if (value == null || value <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Enter a valid rent amount.'),
),
);
return;
}
await controller.updateRentUsd(value);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Rent updated.')),
);
},
child: const Text('Save rent amount'),
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Unit occupied'),
subtitle: const Text(
'When turned off, current and future months are marked Not occupied and reminders stop.',
),
value: controller.settings.occupied,
onChanged: (value) async {
await controller.setOccupied(value);
},
),
const Divider(height: 24),
FilledButton.icon(
onPressed: () async {
await controller.backfillLastYear();
if (!context.mounted) {
return;
}
if (controller.errorMessage == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Backfill finished for last year.'),
),
);
}
},
icon: const Icon(Icons.history),
label: const Text('Backfill last year (one click)'),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () async {
final path = await controller.exportJson();
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Exported to $path')));
},
icon: const Icon(Icons.download),
label: const Text('Export JSON'),
),
const SizedBox(height: 16),
const Text(
'OpenExchange API key is configured in code constant openExchangeApiKey.',
),
],
),
);
},
);
}
}
+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')}';
}
}
+384
View File
@@ -0,0 +1,384 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rental_income_tracker/models/app_settings.dart';
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
import 'package:rental_income_tracker/services/notification_service.dart';
import 'package:rental_income_tracker/services/open_exchange_service.dart';
import 'package:rental_income_tracker/services/storage_service.dart';
import 'package:rental_income_tracker/services/time_service.dart';
import 'package:timezone/timezone.dart' as tz;
const String openExchangeApiKey = 'REPLACE_WITH_YOUR_OPENEXCHANGE_API_KEY';
class RentTableRow {
RentTableRow({
required this.year,
required this.month,
required this.monthLabel,
required this.status,
required this.usdAmount,
required this.sekAmount,
required this.runningUsd,
required this.runningSek,
});
final int year;
final int month;
final String monthLabel;
final PaymentStatus status;
final double? usdAmount;
final double? sekAmount;
final double runningUsd;
final double runningSek;
}
class RentController extends ChangeNotifier {
RentController({
StorageService? storageService,
OpenExchangeService? exchangeService,
}) : _storageService = storageService ?? StorageService(),
_exchangeService =
exchangeService ??
OpenExchangeService(
httpClient: http.Client(),
apiKey: openExchangeApiKey,
) {
_notificationService = NotificationService(_onNotificationAction);
}
final StorageService _storageService;
final OpenExchangeService _exchangeService;
late NotificationService _notificationService;
AppSettings _settings = AppSettings.defaults();
Map<String, MonthlyRentRecord> _records = <String, MonthlyRentRecord>{};
bool _isLoading = false;
String? _errorMessage;
int _selectedYear = DateTime.now().year;
AppSettings get settings => _settings;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
int get selectedYear => _selectedYear;
Future<void> initialize() async {
_setLoading(true);
_errorMessage = null;
try {
await TimeService.initialize();
await _notificationService.initialize();
_settings = await _storageService.loadSettings();
_records = await _storageService.loadRecords();
await _notificationService.processPendingActions();
await _syncNotificationScheduleForCurrentMonth();
} catch (err) {
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
void selectYear(int year) {
_selectedYear = year;
notifyListeners();
}
Future<void> updateRentUsd(double value) async {
_settings = _settings.copyWith(rentUsd: value);
await _storageService.saveSettings(_settings);
notifyListeners();
}
Future<void> setOccupied(bool occupied) async {
final now = DateTime.now();
if (occupied) {
_settings = _settings.copyWith(
occupied: true,
clearNotOccupiedFromYearMonth: true,
);
await _notificationService.cancelAll();
await _syncNotificationScheduleForCurrentMonth();
} else {
_settings = _settings.copyWith(
occupied: false,
notOccupiedFromYearMonth: TimeService.monthKey(now.year, now.month),
);
await _notificationService.cancelAll();
}
await _storageService.saveSettings(_settings);
notifyListeners();
}
Future<void> markCurrentMonthPaidManually() async {
final now = DateTime.now();
await markMonthPaid(
year: now.year,
month: now.month,
assumeOnTime: false,
source: 'manual',
);
}
Future<void> markMonthPaid({
required int year,
required int month,
required bool assumeOnTime,
required String source,
}) async {
_errorMessage = null;
_setLoading(true);
try {
if (_isNotOccupiedMonth(year, month)) {
throw StateError('Cannot mark a non-occupied month as paid.');
}
if (_settings.rentUsd <= 0) {
throw StateError('Set rent amount in Settings before marking paid.');
}
final paidAt = DateTime.now().toUtc();
final estPaidAt = tz.TZDateTime.from(paidAt, TimeService.est);
final estDate = DateTime(estPaidAt.year, estPaidAt.month, estPaidAt.day);
final rate = await _exchangeService.fetchUsdToSekRate(estDate: estDate);
final usd = _settings.rentUsd;
final sek = usd * rate;
final onTime =
assumeOnTime || TimeService.isOnTimeByDeadline(paidAt, year, month);
final record = MonthlyRentRecord(
year: year,
month: month,
usdAmount: usd,
sekAmount: sek,
usdToSekRate: rate,
paidAtUtc: paidAt,
onTime: onTime,
source: source,
);
_records[record.key] = record;
await _storageService.saveRecords(_records);
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
} catch (err) {
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
Future<void> backfillLastYear() async {
_errorMessage = null;
_setLoading(true);
try {
if (_settings.rentUsd <= 0) {
throw StateError('Set rent amount in Settings before backfill.');
}
final year = DateTime.now().year - 1;
for (var month = 1; month <= 12; month++) {
final key = TimeService.monthKey(year, month);
if (_records.containsKey(key)) {
continue;
}
final estDate = DateTime(year, month, 1);
final rate = await _exchangeService.fetchUsdToSekRate(estDate: estDate);
final usd = _settings.rentUsd;
final paidAtUtc = tz.TZDateTime(
TimeService.est,
year,
month,
1,
12,
).toUtc();
_records[key] = MonthlyRentRecord(
year: year,
month: month,
usdAmount: usd,
sekAmount: usd * rate,
usdToSekRate: rate,
paidAtUtc: paidAtUtc,
onTime: true,
source: 'backfill',
);
}
await _storageService.saveRecords(_records);
notifyListeners();
} catch (err) {
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
Future<String> exportJson() async {
final records = _records.values.toList()
..sort((a, b) {
final byYear = a.year.compareTo(b.year);
if (byYear != 0) {
return byYear;
}
return a.month.compareTo(b.month);
});
final payload = <String, dynamic>{
'exportedAtUtc': DateTime.now().toUtc().toIso8601String(),
'settings': _settings.toJson(),
'records': records.map((e) => e.toJson()).toList(),
};
final jsonString = const JsonEncoder.withIndent(' ').convert(payload);
final dir = await getApplicationDocumentsDirectory();
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final file = File('${dir.path}/rent_tracker_export_$timestamp.json');
await file.writeAsString(jsonString);
return file.path;
}
List<RentTableRow> buildRowsForSelectedYear() {
final now = DateTime.now();
final maxMonth = _selectedYear < now.year
? 12
: (_selectedYear == now.year ? now.month : 0);
final rows = <RentTableRow>[];
var runningUsd = 0.0;
var runningSek = 0.0;
for (var month = 1; month <= maxMonth; month++) {
final key = TimeService.monthKey(_selectedYear, month);
final record = _records[key];
final status = _statusForMonth(_selectedYear, month, record);
double? usd;
double? sek;
if (record != null) {
usd = record.usdAmount;
sek = record.sekAmount;
runningUsd += usd;
runningSek += sek;
}
rows.add(
RentTableRow(
year: _selectedYear,
month: month,
monthLabel: DateFormat.MMMM().format(DateTime(_selectedYear, month)),
status: status,
usdAmount: usd,
sekAmount: sek,
runningUsd: runningUsd,
runningSek: runningSek,
),
);
}
return rows;
}
Future<void> _onNotificationAction(NotificationActionEvent event) async {
if (event.isYes) {
await markMonthPaid(
year: event.year,
month: event.month,
assumeOnTime: !event.isRetry,
source: 'notification',
);
return;
}
await _syncNotificationScheduleForCurrentMonth();
}
PaymentStatus _statusForMonth(
int year,
int month,
MonthlyRentRecord? record,
) {
if (_isNotOccupiedMonth(year, month)) {
return PaymentStatus.notOccupied;
}
if (record != null) {
return record.onTime ? PaymentStatus.onTime : PaymentStatus.late;
}
final now = DateTime.now();
if (TimeService.monthBefore(
year: year,
month: month,
otherYear: now.year,
otherMonth: now.month,
)) {
return PaymentStatus.notPaid;
}
return PaymentStatus.pending;
}
bool _isNotOccupiedMonth(int year, int month) {
if (_settings.occupied || _settings.notOccupiedFromYearMonth == null) {
return false;
}
final parts = _settings.notOccupiedFromYearMonth!.split('-');
if (parts.length != 2) {
return false;
}
final pivotYear = int.tryParse(parts[0]);
final pivotMonth = int.tryParse(parts[1]);
if (pivotYear == null || pivotMonth == null) {
return false;
}
return TimeService.monthAtOrAfter(
year: year,
month: month,
pivotYear: pivotYear,
pivotMonth: pivotMonth,
);
}
Future<void> _syncNotificationScheduleForCurrentMonth() async {
final now = DateTime.now();
final key = TimeService.monthKey(now.year, now.month);
final isPaid = _records.containsKey(key);
if (!_settings.occupied ||
_isNotOccupiedMonth(now.year, now.month) ||
isPaid) {
await _notificationService.cancelForMonth(now.year, now.month);
return;
}
await _notificationService.scheduleForMonth(
year: now.year,
month: now.month,
quietHourStart: _settings.localQuietHourStart,
fallbackHour: _settings.localFallbackHour,
);
}
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
}