Files
rental_income_tracker/lib/state/rent_controller.dart
T

485 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.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;
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 RentController extends ChangeNotifier {
RentController({
StorageService? storageService,
ForexRateApiService? exchangeService,
}) : _storageService = storageService ?? StorageService(),
_exchangeService =
exchangeService ??
ForexRateApiService(
httpClient: http.Client(),
apiKey: _exchangeRateApiKeyFromEnv(),
) {
_notificationService = NotificationService(_onNotificationAction);
}
static String _exchangeRateApiKeyFromEnv() {
final key = dotenv.env['EXCHANGE_RATE_API_KEY']?.trim() ?? '';
if (key.isEmpty) {
if (kDebugMode) {
debugPrint(
'[RentController] EXCHANGE_RATE_API_KEY missing in .env, API calls will fail until set.',
);
}
return 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY';
}
return key;
}
final StorageService _storageService;
final ForexRateApiService _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);
if (kDebugMode) {
debugPrint(
'[RentController] markMonthPaid start year=$year month=$month source=$source assumeOnTime=$assumeOnTime',
);
}
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 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> 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;
var savedMonths = 0;
var pausedByRateLimit = false;
for (var month = 1; month <= 12; month++) {
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 conversion = await _exchangeService.convertUsdToSek(
estDate: estDate,
amount: 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') ||
errorText.toLowerCase().contains('rate_limit_reached')) {
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);
}
}
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;
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 (_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();
}
}