@@ -0,0 +1,384 @@
|
||||
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: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;
|
||||
|
||||
const String openExchangeApiKey = 'REPLACE_WITH_YOUR_OPENEXCHANGE_API_KEY';
|
||||
|
||||
class RentTableRow {
|
||||
RentTableRow({
|
||||
required this.year,
|
||||
required this.month,
|
||||
required this.monthLabel,
|
||||
required this.status,
|
||||
required this.usdAmount,
|
||||
required this.sekAmount,
|
||||
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 runningUsd;
|
||||
final double runningSek;
|
||||
}
|
||||
|
||||
class RentController extends ChangeNotifier {
|
||||
RentController({
|
||||
StorageService? storageService,
|
||||
OpenExchangeService? exchangeService,
|
||||
}) : _storageService = storageService ?? StorageService(),
|
||||
_exchangeService =
|
||||
exchangeService ??
|
||||
OpenExchangeService(
|
||||
httpClient: http.Client(),
|
||||
apiKey: openExchangeApiKey,
|
||||
) {
|
||||
_notificationService = NotificationService(_onNotificationAction);
|
||||
}
|
||||
|
||||
final StorageService _storageService;
|
||||
final OpenExchangeService _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);
|
||||
|
||||
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 paidAt = DateTime.now().toUtc();
|
||||
final estPaidAt = tz.TZDateTime.from(paidAt, TimeService.est);
|
||||
final estDate = DateTime(estPaidAt.year, estPaidAt.month, estPaidAt.day);
|
||||
final rate = await _exchangeService.fetchUsdToSekRate(estDate: estDate);
|
||||
final usd = _settings.rentUsd;
|
||||
final sek = usd * rate;
|
||||
final onTime =
|
||||
assumeOnTime || TimeService.isOnTimeByDeadline(paidAt, year, month);
|
||||
|
||||
final record = MonthlyRentRecord(
|
||||
year: year,
|
||||
month: month,
|
||||
usdAmount: usd,
|
||||
sekAmount: sek,
|
||||
usdToSekRate: rate,
|
||||
paidAtUtc: paidAt,
|
||||
onTime: onTime,
|
||||
source: source,
|
||||
);
|
||||
|
||||
_records[record.key] = record;
|
||||
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);
|
||||
|
||||
try {
|
||||
if (_settings.rentUsd <= 0) {
|
||||
throw StateError('Set rent amount in Settings before backfill.');
|
||||
}
|
||||
|
||||
final year = DateTime.now().year - 1;
|
||||
for (var month = 1; month <= 12; month++) {
|
||||
final key = TimeService.monthKey(year, month);
|
||||
if (_records.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final estDate = DateTime(year, month, 1);
|
||||
final rate = await _exchangeService.fetchUsdToSekRate(estDate: estDate);
|
||||
final usd = _settings.rentUsd;
|
||||
|
||||
final paidAtUtc = tz.TZDateTime(
|
||||
TimeService.est,
|
||||
year,
|
||||
month,
|
||||
1,
|
||||
12,
|
||||
).toUtc();
|
||||
|
||||
_records[key] = MonthlyRentRecord(
|
||||
year: year,
|
||||
month: month,
|
||||
usdAmount: usd,
|
||||
sekAmount: usd * rate,
|
||||
usdToSekRate: rate,
|
||||
paidAtUtc: paidAtUtc,
|
||||
onTime: true,
|
||||
source: 'backfill',
|
||||
);
|
||||
}
|
||||
|
||||
await _storageService.saveRecords(_records);
|
||||
notifyListeners();
|
||||
} catch (err) {
|
||||
_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;
|
||||
|
||||
if (record != null) {
|
||||
usd = record.usdAmount;
|
||||
sek = record.sekAmount;
|
||||
runningUsd += usd;
|
||||
runningSek += sek;
|
||||
}
|
||||
|
||||
rows.add(
|
||||
RentTableRow(
|
||||
year: _selectedYear,
|
||||
month: month,
|
||||
monthLabel: DateFormat.MMMM().format(DateTime(_selectedYear, month)),
|
||||
status: status,
|
||||
usdAmount: usd,
|
||||
sekAmount: sek,
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user