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
+9
View File
@@ -5,6 +5,7 @@ class AppSettings {
required this.rentUsd,
required this.occupied,
this.notOccupiedFromYearMonth,
this.notOccupiedMonths = const <String>[],
this.localFallbackHour = 9,
this.localQuietHourStart = 23,
});
@@ -12,6 +13,7 @@ class AppSettings {
final double rentUsd;
final bool occupied;
final String? notOccupiedFromYearMonth;
final List<String> notOccupiedMonths;
final int localFallbackHour;
final int localQuietHourStart;
@@ -23,6 +25,7 @@ class AppSettings {
double? rentUsd,
bool? occupied,
String? notOccupiedFromYearMonth,
List<String>? notOccupiedMonths,
bool clearNotOccupiedFromYearMonth = false,
int? localFallbackHour,
int? localQuietHourStart,
@@ -33,6 +36,7 @@ class AppSettings {
notOccupiedFromYearMonth: clearNotOccupiedFromYearMonth
? null
: (notOccupiedFromYearMonth ?? this.notOccupiedFromYearMonth),
notOccupiedMonths: notOccupiedMonths ?? this.notOccupiedMonths,
localFallbackHour: localFallbackHour ?? this.localFallbackHour,
localQuietHourStart: localQuietHourStart ?? this.localQuietHourStart,
);
@@ -43,6 +47,7 @@ class AppSettings {
'rentUsd': rentUsd,
'occupied': occupied,
'notOccupiedFromYearMonth': notOccupiedFromYearMonth,
'notOccupiedMonths': notOccupiedMonths,
'localFallbackHour': localFallbackHour,
'localQuietHourStart': localQuietHourStart,
};
@@ -53,6 +58,10 @@ class AppSettings {
rentUsd: (json['rentUsd'] ?? 0).toDouble(),
occupied: json['occupied'] ?? true,
notOccupiedFromYearMonth: json['notOccupiedFromYearMonth'] as String?,
notOccupiedMonths:
(json['notOccupiedMonths'] as List<dynamic>? ?? const <dynamic>[])
.whereType<String>()
.toList(),
localFallbackHour: json['localFallbackHour'] ?? 9,
localQuietHourStart: json['localQuietHourStart'] ?? 23,
);
+16 -20
View File
@@ -34,6 +34,12 @@ class HomeScreen extends StatelessWidget {
selected: current == PaymentStatus.notPaid,
onTap: () => Navigator.of(context).pop(PaymentStatus.notPaid),
),
ListTile(
title: const Text('Not occupied'),
selected: current == PaymentStatus.notOccupied,
onTap: () =>
Navigator.of(context).pop(PaymentStatus.notOccupied),
),
ListTile(
title: const Text('Pending'),
selected: current == PaymentStatus.pending,
@@ -91,20 +97,6 @@ class HomeScreen extends StatelessWidget {
RentController controller,
RentTableRow row,
) async {
if (row.status == PaymentStatus.notOccupied) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Use Settings -> Unit occupied to manage not-occupied months.',
),
),
);
return;
}
final nextStatus = await _showStatusPicker(context, row.status);
if (!context.mounted) {
return;
@@ -114,8 +106,9 @@ class HomeScreen extends StatelessWidget {
}
double? paidUsd;
if (nextStatus == PaymentStatus.onTime ||
nextStatus == PaymentStatus.late) {
if (nextStatus == PaymentStatus.onTime) {
paidUsd = controller.settings.rentUsd;
} else if (nextStatus == PaymentStatus.late) {
paidUsd = await _showPaidUsdDialog(
context,
initialValue: row.usdAmount ?? controller.settings.rentUsd,
@@ -186,8 +179,11 @@ class HomeScreen extends StatelessWidget {
children: <Widget>[
_YearSwitcher(
year: controller.selectedYear,
onPrevious: () =>
controller.selectYear(controller.selectedYear - 1),
onPrevious: controller.canSelectPreviousYear
? () => controller.selectYear(
controller.selectedYear - 1,
)
: null,
onNext: controller.selectedYear < DateTime.now().year
? () => controller.selectYear(
controller.selectedYear + 1,
@@ -282,7 +278,7 @@ class HomeScreen extends StatelessWidget {
'Total: ${usdCurrency.format(totalUsd)} | ${sekCurrency.format(totalSek)}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () async {
await controller.markCurrentMonthPaidManually();
@@ -337,7 +333,7 @@ class _YearSwitcher extends StatelessWidget {
});
final int year;
final VoidCallback onPrevious;
final VoidCallback? onPrevious;
final VoidCallback? onNext;
@override
+35
View File
@@ -1,5 +1,6 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:rental_income_tracker/state/rent_controller.dart';
@@ -56,11 +57,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
return Consumer<RentController>(
builder: (context, controller, _) {
final summary = controller.buildLifetimeSummary();
final usdCurrency = NumberFormat.currency(
symbol: r'$',
decimalDigits: 2,
);
final sekCurrency = NumberFormat.currency(
symbol: 'SEK ',
decimalDigits: 2,
);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'All-time totals (since May 2024)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'${usdCurrency.format(summary.totalUsd)} | ${sekCurrency.format(summary.totalSek)}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'Occupancy: ${summary.occupancyPercent.toStringAsFixed(1)}% (${summary.occupiedMonths}/${summary.totalTrackedMonths} months)',
),
],
),
),
),
const SizedBox(height: 12),
TextField(
controller: _rentController,
keyboardType: const TextInputType.numberWithOptions(
+165 -8
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) {
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,
);
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ class _FakeStorageService extends StorageService {
class _FakeExchangeService extends FrankfurterApiService {
_FakeExchangeService({required this.quote})
: super(httpClient: http.Client(), apiKey: 'test-key');
: super(httpClient: http.Client());
final double quote;
+1 -1
View File
@@ -30,7 +30,7 @@ class _FakeStorageService extends StorageService {
}
class _FakeExchangeService extends FrankfurterApiService {
_FakeExchangeService() : super(httpClient: http.Client(), apiKey: 'test-key');
_FakeExchangeService() : super(httpClient: http.Client());
@override
Future<ConversionResult> convertUsdToSek({