Enhance AppSettings and RentController to manage not-occupied months, add lifetime summary to SettingsScreen, and update tests for compatibility

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 10:57:57 +01:00
parent 67af59bd0d
commit 9c4e275ff7
6 changed files with 228 additions and 31 deletions
+166 -9
View File
@@ -36,8 +36,31 @@ class RentTableRow {
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,
@@ -92,6 +115,9 @@ class RentController extends ChangeNotifier {
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);
@@ -103,6 +129,9 @@ class RentController extends ChangeNotifier {
_settings = await _storageService.loadSettings();
_records = await _storageService.loadRecords();
_records.removeWhere(
(_, record) => _isBeforeTrackingStart(record.year, record.month),
);
await _notificationService.processPendingActions();
await _syncNotificationScheduleForCurrentMonth();
@@ -114,10 +143,59 @@ class RentController extends ChangeNotifier {
}
void selectYear(int year) {
_selectedYear = 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);
@@ -171,6 +249,9 @@ class RentController extends ChangeNotifier {
}
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.');
}
@@ -249,22 +330,45 @@ class RentController extends ChangeNotifier {
_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) {
throw StateError(
'Use Settings -> Unit occupied to manage not-occupied months.',
);
}
if (status == PaymentStatus.notPaid || status == PaymentStatus.pending) {
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.');
}
@@ -331,6 +435,9 @@ class RentController extends ChangeNotifier {
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) {
@@ -465,10 +572,31 @@ class RentController extends ChangeNotifier {
throw StateError('Backup file is missing settings or records.');
}
_settings = AppSettings.fromJson(settingsJson);
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;
@@ -510,15 +638,26 @@ class RentController extends ChangeNotifier {
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 = 1; month <= maxMonth; month++) {
for (var month = minMonth; month <= maxMonth; month++) {
final key = TimeService.monthKey(_selectedYear, month);
final record = _records[key];
final status = _statusForMonth(_selectedYear, month, record);
@@ -594,6 +733,15 @@ class RentController extends ChangeNotifier {
}
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;
}
@@ -648,4 +796,13 @@ class RentController extends ChangeNotifier {
_isLoading = value;
notifyListeners();
}
bool _isBeforeTrackingStart(int year, int month) {
return TimeService.monthBefore(
year: year,
month: month,
otherYear: _trackingStartYear,
otherMonth: _trackingStartMonth,
);
}
}