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 _records = {}; 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 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 updateRentUsd(double value) async { _settings = _settings.copyWith(rentUsd: value); await _storageService.saveSettings(_settings); notifyListeners(); } Future 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 markCurrentMonthPaidManually() async { final now = DateTime.now(); await markMonthPaid( year: now.year, month: now.month, assumeOnTime: false, source: 'manual', ); } Future 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 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 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 = { 'exportedAtUtc': DateTime.now().toUtc().toIso8601String(), 'settings': _settings.toJson(), 'records': records.map((e) => e.toJson()).toList(), }; return const JsonEncoder.withIndent(' ').convert(payload); } Future 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 _restoreFromPayload(Map payload) async { final settingsJson = payload['settings'] as Map?; final recordsJson = payload['records'] as List?; if (settingsJson == null || recordsJson == null) { throw StateError('Backup file is missing settings or records.'); } _settings = AppSettings.fromJson(settingsJson); final restoredRecords = {}; for (final item in recordsJson) { final record = MonthlyRentRecord.fromJson(item as Map); restoredRecords[record.key] = record; } _records = restoredRecords; await _storageService.saveSettings(_settings); await _storageService.saveRecords(_records); await _notificationService.cancelAll(); await _syncNotificationScheduleForCurrentMonth(); notifyListeners(); } Future 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; 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 buildRowsForSelectedYear() { final now = DateTime.now(); final maxMonth = _selectedYear < now.year ? 12 : (_selectedYear == now.year ? now.month : 0); final rows = []; 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 _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 _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(); } }