9c4e275ff7
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
809 lines
23 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|