Files
rental_income_tracker/lib/state/rent_controller.dart
T

809 lines
23 KiB
Dart

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: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;
class RentTableRow {
RentTableRow({
required this.year,
required this.month,
required this.monthLabel,
required this.status,
required this.usdAmount,
required this.sekAmount,
required this.effectiveUsdToSekRate,
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? effectiveUsdToSekRate;
final double runningUsd;
final double runningSek;
}
class LifetimeSummary {
LifetimeSummary({
required this.totalUsd,
required this.totalSek,
required this.totalTrackedMonths,
required this.occupiedMonths,
});
final double totalUsd;
final double totalSek;
final int totalTrackedMonths;
final int occupiedMonths;
double get occupancyPercent {
if (totalTrackedMonths == 0) {
return 0;
}
return (occupiedMonths / totalTrackedMonths) * 100;
}
}
class RentController extends ChangeNotifier {
static const int _notificationScheduleHorizonMonths = 24;
static const int _trackingStartYear = 2024;
static const int _trackingStartMonth = 5;
RentController({
StorageService? storageService,
FrankfurterApiService? exchangeService,
NotificationService? notificationService,
}) : _storageService = storageService ?? StorageService(),
_exchangeService =
exchangeService ?? FrankfurterApiService(httpClient: http.Client()) {
_notificationService =
notificationService ?? NotificationService(_onNotificationAction);
}
final StorageService _storageService;
final FrankfurterApiService _exchangeService;
late NotificationService _notificationService;
AppSettings _settings = AppSettings.defaults();
Map<String, MonthlyRentRecord> _records = <String, MonthlyRentRecord>{};
bool _isLoading = false;
String? _errorMessage;
int _selectedYear = DateTime.now().year;
double? _lookupRateOnOrBefore(
Map<String, double> rates,
DateTime targetDate,
) {
final target = DateTime(targetDate.year, targetDate.month, targetDate.day);
DateTime? nearestDate;
double? nearestRate;
for (final entry in rates.entries) {
final parsed = DateTime.tryParse(entry.key);
if (parsed == null) {
continue;
}
final day = DateTime(parsed.year, parsed.month, parsed.day);
if (day.isAfter(target)) {
continue;
}
if (nearestDate == null || day.isAfter(nearestDate)) {
nearestDate = day;
nearestRate = entry.value;
}
}
return nearestRate;
}
AppSettings get settings => _settings;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
int get selectedYear => _selectedYear;
int get trackingStartYear => _trackingStartYear;
int get trackingStartMonth => _trackingStartMonth;
bool get canSelectPreviousYear => _selectedYear > _trackingStartYear;
Future<void> initialize() async {
_setLoading(true);
_errorMessage = null;
try {
await TimeService.initialize();
await _notificationService.initialize();
_settings = await _storageService.loadSettings();
_records = await _storageService.loadRecords();
_records.removeWhere(
(_, record) => _isBeforeTrackingStart(record.year, record.month),
);
await _notificationService.processPendingActions();
await _syncNotificationScheduleForCurrentMonth();
} catch (err) {
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
void selectYear(int year) {
final nowYear = DateTime.now().year;
if (year < _trackingStartYear) {
_selectedYear = _trackingStartYear;
} else if (year > nowYear) {
_selectedYear = nowYear;
} else {
_selectedYear = year;
}
notifyListeners();
}
LifetimeSummary buildLifetimeSummary() {
final now = DateTime.now();
var totalTrackedMonths = 0;
var occupiedMonths = 0;
for (
var cursor = DateTime(_trackingStartYear, _trackingStartMonth, 1);
!cursor.isAfter(DateTime(now.year, now.month, 1));
cursor = DateTime(cursor.year, cursor.month + 1, 1)
) {
totalTrackedMonths++;
if (!_isNotOccupiedMonth(cursor.year, cursor.month)) {
occupiedMonths++;
}
}
var totalUsd = 0.0;
var totalSek = 0.0;
for (final record in _records.values) {
if (_isBeforeTrackingStart(record.year, record.month)) {
continue;
}
if (TimeService.monthBefore(
year: now.year,
month: now.month,
otherYear: record.year,
otherMonth: record.month,
)) {
continue;
}
totalUsd += record.usdAmount;
totalSek += record.sekAmount;
}
return LifetimeSummary(
totalUsd: totalUsd,
totalSek: totalSek,
totalTrackedMonths: totalTrackedMonths,
occupiedMonths: occupiedMonths,
);
}
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);
if (kDebugMode) {
debugPrint(
'[RentController] markMonthPaid start year=$year month=$month source=$source assumeOnTime=$assumeOnTime',
);
}
try {
if (_isBeforeTrackingStart(year, month)) {
throw StateError('Months before May 2024 are not tracked.');
}
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 key = TimeService.monthKey(year, month);
if (_records.containsKey(key)) {
if (kDebugMode) {
debugPrint(
'[RentController] markMonthPaid skipped API call; cached record exists for key=$key',
);
}
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
return;
}
final paidAt = DateTime.now().toUtc();
final usd = _settings.rentUsd;
final estPaidAt = tz.TZDateTime.from(paidAt, TimeService.est);
final estDate = DateTime(estPaidAt.year, estPaidAt.month, estPaidAt.day);
final conversion = await _exchangeService.convertUsdToSek(
estDate: estDate,
amount: usd,
);
if (kDebugMode) {
debugPrint(
'[RentController] markMonthPaid conversion success quote=${conversion.quote} result=${conversion.result} usd=$usd estDate=$estDate',
);
}
final onTime =
assumeOnTime || TimeService.isOnTimeByDeadline(paidAt, year, month);
final record = MonthlyRentRecord(
year: year,
month: month,
usdAmount: usd,
sekAmount: conversion.result,
usdToSekRate: conversion.quote,
paidAtUtc: paidAt,
onTime: onTime,
source: source,
);
_records[record.key] = record;
await _storageService.saveRecords(_records);
if (kDebugMode) {
debugPrint(
'[RentController] markMonthPaid stored record key=${record.key}',
);
}
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
} catch (err, stackTrace) {
if (kDebugMode) {
debugPrint('[RentController] markMonthPaid failed: $err');
debugPrint(stackTrace.toString());
}
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
Future<void> setMonthStatusManually({
required int year,
required int month,
required PaymentStatus status,
double? paidUsd,
}) async {
_errorMessage = null;
_setLoading(true);
try {
if (_isBeforeTrackingStart(year, month)) {
throw StateError('Months before May 2024 are not tracked.');
}
final key = TimeService.monthKey(year, month);
final notOccupiedMonths = Set<String>.from(_settings.notOccupiedMonths);
if (status == PaymentStatus.notOccupied) {
notOccupiedMonths.add(key);
_records.remove(key);
_settings = _settings.copyWith(
notOccupiedMonths: notOccupiedMonths.toList()..sort(),
);
await _storageService.saveSettings(_settings);
await _storageService.saveRecords(_records);
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
return;
}
if (status == PaymentStatus.notPaid || status == PaymentStatus.pending) {
notOccupiedMonths.remove(key);
_records.remove(key);
_settings = _settings.copyWith(
notOccupiedMonths: notOccupiedMonths.toList()..sort(),
);
await _storageService.saveSettings(_settings);
await _storageService.saveRecords(_records);
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
return;
}
// Changing to a paid status should clear any month-level not-occupied override.
notOccupiedMonths.remove(key);
_settings = _settings.copyWith(
notOccupiedMonths: notOccupiedMonths.toList()..sort(),
);
await _storageService.saveSettings(_settings);
if (_isNotOccupiedMonth(year, month)) {
throw StateError('Cannot mark a non-occupied month as paid.');
}
final usd = paidUsd ?? _settings.rentUsd;
if (usd <= 0) {
throw StateError('Enter a valid paid amount in USD.');
}
final estDate = DateTime(year, month, 1);
final conversion = await _exchangeService.convertUsdToSek(
estDate: estDate,
amount: usd,
);
_records[key] = MonthlyRentRecord(
year: year,
month: month,
usdAmount: usd,
sekAmount: conversion.result,
usdToSekRate: conversion.quote,
paidAtUtc: DateTime.now().toUtc(),
onTime: status == PaymentStatus.onTime,
source: 'manual-status',
);
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);
if (kDebugMode) {
debugPrint('[RentController] backfillLastYear start');
}
try {
if (_settings.rentUsd <= 0) {
throw StateError('Set rent amount in Settings before backfill.');
}
final year = DateTime.now().year - 1;
// Attempt a single timeseries call covering the whole year.
final batchRates = await _exchangeService.tryFetchYearRates(year);
if (kDebugMode) {
debugPrint(
batchRates != null
? '[RentController] backfill using batch timeseries (${batchRates.length} dates)'
: '[RentController] backfill falling back to individual daily requests',
);
}
var savedMonths = 0;
var pausedByRateLimit = false;
var isFirstIndividualFetch = true;
for (var month = 1; month <= 12; month++) {
if (_isBeforeTrackingStart(year, month)) {
continue;
}
final key = TimeService.monthKey(year, month);
if (_records.containsKey(key)) {
if (kDebugMode) {
debugPrint('[RentController] backfill skip cached key=$key');
}
continue;
}
try {
final usd = _settings.rentUsd;
final estDate = DateTime(year, month, 1);
final ConversionResult conversion;
if (batchRates != null) {
final quote = _lookupRateOnOrBefore(batchRates, estDate);
if (quote == null) {
throw StateError(
'Batch rates missing entry on or before ${DateFormat('yyyy-MM-dd').format(estDate)}.',
);
}
conversion = ConversionResult(quote: quote, result: quote * usd);
} else {
// Pace individual requests to avoid triggering the rate limit.
if (!isFirstIndividualFetch) {
await Future.delayed(const Duration(milliseconds: 700));
}
isFirstIndividualFetch = false;
final quote = await _exchangeService.fetchUsdToSekRate(
estDate: estDate,
);
conversion = ConversionResult(quote: quote, result: quote * usd);
}
if (kDebugMode) {
debugPrint(
'[RentController] backfill conversion year=$year month=$month quote=${conversion.quote} result=${conversion.result}',
);
}
final paidAtUtc = tz.TZDateTime(
TimeService.est,
year,
month,
1,
12,
).toUtc();
_records[key] = MonthlyRentRecord(
year: year,
month: month,
usdAmount: usd,
sekAmount: conversion.result,
usdToSekRate: conversion.quote,
paidAtUtc: paidAtUtc,
onTime: true,
source: 'backfill',
);
await _storageService.saveRecords(_records);
savedMonths++;
} catch (err) {
final errorText = err.toString();
if (errorText.contains('429')) {
pausedByRateLimit = true;
_errorMessage =
'Backfill paused by API rate limits after $savedMonths new month(s). Run backfill again in a minute to continue.';
if (kDebugMode) {
debugPrint(
'[RentController] backfill paused by rate limit after $savedMonths month(s)',
);
}
break;
}
rethrow;
}
}
if (kDebugMode) {
debugPrint(
pausedByRateLimit
? '[RentController] backfillLastYear paused by rate limit'
: '[RentController] backfillLastYear completed',
);
}
notifyListeners();
} catch (err, stackTrace) {
if (kDebugMode) {
debugPrint('[RentController] backfillLastYear failed: $err');
debugPrint(stackTrace.toString());
}
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
String _buildBackupJson() {
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(),
};
return const JsonEncoder.withIndent(' ').convert(payload);
}
Future<String> exportBackupToDirectory(String directoryPath) async {
final jsonString = _buildBackupJson();
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final normalizedDirectory = directoryPath.endsWith(Platform.pathSeparator)
? directoryPath.substring(0, directoryPath.length - 1)
: directoryPath;
final file = File(
'$normalizedDirectory${Platform.pathSeparator}rent_tracker_backup_$timestamp.json',
);
await file.writeAsString(jsonString);
return file.path;
}
Future<void> _restoreFromPayload(Map<String, dynamic> payload) async {
final settingsJson = payload['settings'] as Map<String, dynamic>?;
final recordsJson = payload['records'] as List<dynamic>?;
if (settingsJson == null || recordsJson == null) {
throw StateError('Backup file is missing settings or records.');
}
final parsedSettings = AppSettings.fromJson(settingsJson);
final filteredNotOccupiedMonths = parsedSettings.notOccupiedMonths.where((
key,
) {
final parts = key.split('-');
if (parts.length != 2) {
return false;
}
final year = int.tryParse(parts[0]);
final month = int.tryParse(parts[1]);
if (year == null || month == null) {
return false;
}
return !_isBeforeTrackingStart(year, month);
}).toList()..sort();
_settings = parsedSettings.copyWith(
notOccupiedMonths: filteredNotOccupiedMonths,
);
final restoredRecords = <String, MonthlyRentRecord>{};
for (final item in recordsJson) {
final record = MonthlyRentRecord.fromJson(item as Map<String, dynamic>);
if (_isBeforeTrackingStart(record.year, record.month)) {
continue;
}
restoredRecords[record.key] = record;
}
_records = restoredRecords;
await _storageService.saveSettings(_settings);
await _storageService.saveRecords(_records);
await _notificationService.cancelAll();
await _syncNotificationScheduleForCurrentMonth();
notifyListeners();
}
Future<String> importBackupFromFile(String filePath) async {
_errorMessage = null;
_setLoading(true);
try {
final file = File(filePath);
if (!await file.exists()) {
throw StateError('Selected backup file could not be found.');
}
final raw = await file.readAsString();
final payload = jsonDecode(raw) as Map<String, dynamic>;
await _restoreFromPayload(payload);
return file.path;
} catch (err, stackTrace) {
if (kDebugMode) {
debugPrint('[RentController] importBackupFromFile failed: $err');
debugPrint(stackTrace.toString());
}
_errorMessage = err.toString();
rethrow;
} finally {
_setLoading(false);
}
}
List<RentTableRow> buildRowsForSelectedYear() {
final now = DateTime.now();
if (_selectedYear < _trackingStartYear) {
return <RentTableRow>[];
}
final minMonth = _selectedYear == _trackingStartYear
? _trackingStartMonth
: 1;
final maxMonth = _selectedYear < now.year
? 12
: (_selectedYear == now.year ? now.month : 0);
if (maxMonth < minMonth) {
return <RentTableRow>[];
}
final rows = <RentTableRow>[];
var runningUsd = 0.0;
var runningSek = 0.0;
for (var month = minMonth; month <= maxMonth; month++) {
final key = TimeService.monthKey(_selectedYear, month);
final record = _records[key];
final status = _statusForMonth(_selectedYear, month, record);
double? usd;
double? sek;
double? rate;
if (record != null) {
usd = record.usdAmount;
sek = record.sekAmount;
rate = record.usdToSekRate;
runningUsd += usd;
runningSek += sek;
}
rows.add(
RentTableRow(
year: _selectedYear,
month: month,
monthLabel: DateFormat.MMMM().format(DateTime(_selectedYear, month)),
status: status,
usdAmount: usd,
sekAmount: sek,
effectiveUsdToSekRate: rate,
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 (_isBeforeTrackingStart(year, month)) {
return false;
}
final key = TimeService.monthKey(year, month);
if (_settings.notOccupiedMonths.contains(key)) {
return true;
}
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();
for (
var offset = 0;
offset < _notificationScheduleHorizonMonths;
offset++
) {
final scheduledMonth = DateTime(now.year, now.month + offset, 1);
final year = scheduledMonth.year;
final month = scheduledMonth.month;
final key = TimeService.monthKey(year, month);
final isPaid = _records.containsKey(key);
if (!_settings.occupied || _isNotOccupiedMonth(year, month) || isPaid) {
await _notificationService.cancelForMonth(year, month);
continue;
}
await _notificationService.scheduleForMonth(
year: year,
month: month,
quietHourStart: _settings.localQuietHourStart,
fallbackHour: _settings.localFallbackHour,
);
}
}
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
bool _isBeforeTrackingStart(int year, int month) {
return TimeService.monthBefore(
year: year,
month: month,
otherYear: _trackingStartYear,
otherMonth: _trackingStartMonth,
);
}
}