1eb8f90fa1
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
637 lines
18 KiB
Dart
637 lines
18 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: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,
|
|
NotificationService? notificationService,
|
|
}) : _storageService = storageService ?? StorageService(),
|
|
_exchangeService =
|
|
exchangeService ??
|
|
ForexRateApiService(
|
|
httpClient: http.Client(),
|
|
apiKey: _exchangeRateApiKeyFromEnv(),
|
|
) {
|
|
_notificationService =
|
|
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> setMonthStatusManually({
|
|
required int year,
|
|
required int month,
|
|
required PaymentStatus status,
|
|
double? paidUsd,
|
|
}) async {
|
|
_errorMessage = null;
|
|
_setLoading(true);
|
|
|
|
try {
|
|
final key = TimeService.monthKey(year, month);
|
|
|
|
if (status == PaymentStatus.notOccupied) {
|
|
throw StateError(
|
|
'Use Settings -> Unit occupied to manage not-occupied months.',
|
|
);
|
|
}
|
|
|
|
if (status == PaymentStatus.notPaid || status == PaymentStatus.pending) {
|
|
_records.remove(key);
|
|
await _storageService.saveRecords(_records);
|
|
await _notificationService.cancelForMonth(year, month);
|
|
await _syncNotificationScheduleForCurrentMonth();
|
|
return;
|
|
}
|
|
|
|
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++) {
|
|
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 ForexConversionResult conversion;
|
|
if (batchRates != null) {
|
|
final dateStr = DateFormat('yyyy-MM-dd').format(estDate);
|
|
final quote = batchRates[dateStr];
|
|
if (quote == null) {
|
|
throw StateError('Batch rates missing entry for $dateStr.');
|
|
}
|
|
conversion = ForexConversionResult(
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
_settings = AppSettings.fromJson(settingsJson);
|
|
final restoredRecords = <String, MonthlyRentRecord>{};
|
|
for (final item in recordsJson) {
|
|
final record = MonthlyRentRecord.fromJson(item as Map<String, dynamic>);
|
|
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();
|
|
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();
|
|
}
|
|
}
|