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.rentUsd,
required this.occupied, required this.occupied,
this.notOccupiedFromYearMonth, this.notOccupiedFromYearMonth,
this.notOccupiedMonths = const <String>[],
this.localFallbackHour = 9, this.localFallbackHour = 9,
this.localQuietHourStart = 23, this.localQuietHourStart = 23,
}); });
@@ -12,6 +13,7 @@ class AppSettings {
final double rentUsd; final double rentUsd;
final bool occupied; final bool occupied;
final String? notOccupiedFromYearMonth; final String? notOccupiedFromYearMonth;
final List<String> notOccupiedMonths;
final int localFallbackHour; final int localFallbackHour;
final int localQuietHourStart; final int localQuietHourStart;
@@ -23,6 +25,7 @@ class AppSettings {
double? rentUsd, double? rentUsd,
bool? occupied, bool? occupied,
String? notOccupiedFromYearMonth, String? notOccupiedFromYearMonth,
List<String>? notOccupiedMonths,
bool clearNotOccupiedFromYearMonth = false, bool clearNotOccupiedFromYearMonth = false,
int? localFallbackHour, int? localFallbackHour,
int? localQuietHourStart, int? localQuietHourStart,
@@ -33,6 +36,7 @@ class AppSettings {
notOccupiedFromYearMonth: clearNotOccupiedFromYearMonth notOccupiedFromYearMonth: clearNotOccupiedFromYearMonth
? null ? null
: (notOccupiedFromYearMonth ?? this.notOccupiedFromYearMonth), : (notOccupiedFromYearMonth ?? this.notOccupiedFromYearMonth),
notOccupiedMonths: notOccupiedMonths ?? this.notOccupiedMonths,
localFallbackHour: localFallbackHour ?? this.localFallbackHour, localFallbackHour: localFallbackHour ?? this.localFallbackHour,
localQuietHourStart: localQuietHourStart ?? this.localQuietHourStart, localQuietHourStart: localQuietHourStart ?? this.localQuietHourStart,
); );
@@ -43,6 +47,7 @@ class AppSettings {
'rentUsd': rentUsd, 'rentUsd': rentUsd,
'occupied': occupied, 'occupied': occupied,
'notOccupiedFromYearMonth': notOccupiedFromYearMonth, 'notOccupiedFromYearMonth': notOccupiedFromYearMonth,
'notOccupiedMonths': notOccupiedMonths,
'localFallbackHour': localFallbackHour, 'localFallbackHour': localFallbackHour,
'localQuietHourStart': localQuietHourStart, 'localQuietHourStart': localQuietHourStart,
}; };
@@ -53,6 +58,10 @@ class AppSettings {
rentUsd: (json['rentUsd'] ?? 0).toDouble(), rentUsd: (json['rentUsd'] ?? 0).toDouble(),
occupied: json['occupied'] ?? true, occupied: json['occupied'] ?? true,
notOccupiedFromYearMonth: json['notOccupiedFromYearMonth'] as String?, notOccupiedFromYearMonth: json['notOccupiedFromYearMonth'] as String?,
notOccupiedMonths:
(json['notOccupiedMonths'] as List<dynamic>? ?? const <dynamic>[])
.whereType<String>()
.toList(),
localFallbackHour: json['localFallbackHour'] ?? 9, localFallbackHour: json['localFallbackHour'] ?? 9,
localQuietHourStart: json['localQuietHourStart'] ?? 23, localQuietHourStart: json['localQuietHourStart'] ?? 23,
); );
+16 -20
View File
@@ -34,6 +34,12 @@ class HomeScreen extends StatelessWidget {
selected: current == PaymentStatus.notPaid, selected: current == PaymentStatus.notPaid,
onTap: () => Navigator.of(context).pop(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( ListTile(
title: const Text('Pending'), title: const Text('Pending'),
selected: current == PaymentStatus.pending, selected: current == PaymentStatus.pending,
@@ -91,20 +97,6 @@ class HomeScreen extends StatelessWidget {
RentController controller, RentController controller,
RentTableRow row, RentTableRow row,
) async { ) 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); final nextStatus = await _showStatusPicker(context, row.status);
if (!context.mounted) { if (!context.mounted) {
return; return;
@@ -114,8 +106,9 @@ class HomeScreen extends StatelessWidget {
} }
double? paidUsd; double? paidUsd;
if (nextStatus == PaymentStatus.onTime || if (nextStatus == PaymentStatus.onTime) {
nextStatus == PaymentStatus.late) { paidUsd = controller.settings.rentUsd;
} else if (nextStatus == PaymentStatus.late) {
paidUsd = await _showPaidUsdDialog( paidUsd = await _showPaidUsdDialog(
context, context,
initialValue: row.usdAmount ?? controller.settings.rentUsd, initialValue: row.usdAmount ?? controller.settings.rentUsd,
@@ -186,8 +179,11 @@ class HomeScreen extends StatelessWidget {
children: <Widget>[ children: <Widget>[
_YearSwitcher( _YearSwitcher(
year: controller.selectedYear, year: controller.selectedYear,
onPrevious: () => onPrevious: controller.canSelectPreviousYear
controller.selectYear(controller.selectedYear - 1), ? () => controller.selectYear(
controller.selectedYear - 1,
)
: null,
onNext: controller.selectedYear < DateTime.now().year onNext: controller.selectedYear < DateTime.now().year
? () => controller.selectYear( ? () => controller.selectYear(
controller.selectedYear + 1, controller.selectedYear + 1,
@@ -282,7 +278,7 @@ class HomeScreen extends StatelessWidget {
'Total: ${usdCurrency.format(totalUsd)} | ${sekCurrency.format(totalSek)}', 'Total: ${usdCurrency.format(totalUsd)} | ${sekCurrency.format(totalSek)}',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
onPressed: () async { onPressed: () async {
await controller.markCurrentMonthPaidManually(); await controller.markCurrentMonthPaidManually();
@@ -337,7 +333,7 @@ class _YearSwitcher extends StatelessWidget {
}); });
final int year; final int year;
final VoidCallback onPrevious; final VoidCallback? onPrevious;
final VoidCallback? onNext; final VoidCallback? onNext;
@override @override
+35
View File
@@ -1,5 +1,6 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:rental_income_tracker/state/rent_controller.dart'; import 'package:rental_income_tracker/state/rent_controller.dart';
@@ -56,11 +57,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<RentController>( return Consumer<RentController>(
builder: (context, controller, _) { 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( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(title: const Text('Settings')),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: <Widget>[ 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( TextField(
controller: _rentController, controller: _rentController,
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
+166 -9
View File
@@ -36,8 +36,31 @@ class RentTableRow {
final double runningSek; 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 { class RentController extends ChangeNotifier {
static const int _notificationScheduleHorizonMonths = 24; static const int _notificationScheduleHorizonMonths = 24;
static const int _trackingStartYear = 2024;
static const int _trackingStartMonth = 5;
RentController({ RentController({
StorageService? storageService, StorageService? storageService,
@@ -92,6 +115,9 @@ class RentController extends ChangeNotifier {
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
int get selectedYear => _selectedYear; int get selectedYear => _selectedYear;
int get trackingStartYear => _trackingStartYear;
int get trackingStartMonth => _trackingStartMonth;
bool get canSelectPreviousYear => _selectedYear > _trackingStartYear;
Future<void> initialize() async { Future<void> initialize() async {
_setLoading(true); _setLoading(true);
@@ -103,6 +129,9 @@ class RentController extends ChangeNotifier {
_settings = await _storageService.loadSettings(); _settings = await _storageService.loadSettings();
_records = await _storageService.loadRecords(); _records = await _storageService.loadRecords();
_records.removeWhere(
(_, record) => _isBeforeTrackingStart(record.year, record.month),
);
await _notificationService.processPendingActions(); await _notificationService.processPendingActions();
await _syncNotificationScheduleForCurrentMonth(); await _syncNotificationScheduleForCurrentMonth();
@@ -114,10 +143,59 @@ class RentController extends ChangeNotifier {
} }
void selectYear(int year) { 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(); 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 { Future<void> updateRentUsd(double value) async {
_settings = _settings.copyWith(rentUsd: value); _settings = _settings.copyWith(rentUsd: value);
await _storageService.saveSettings(_settings); await _storageService.saveSettings(_settings);
@@ -171,6 +249,9 @@ class RentController extends ChangeNotifier {
} }
try { try {
if (_isBeforeTrackingStart(year, month)) {
throw StateError('Months before May 2024 are not tracked.');
}
if (_isNotOccupiedMonth(year, month)) { if (_isNotOccupiedMonth(year, month)) {
throw StateError('Cannot mark a non-occupied month as paid.'); throw StateError('Cannot mark a non-occupied month as paid.');
} }
@@ -249,22 +330,45 @@ class RentController extends ChangeNotifier {
_setLoading(true); _setLoading(true);
try { try {
if (_isBeforeTrackingStart(year, month)) {
throw StateError('Months before May 2024 are not tracked.');
}
final key = TimeService.monthKey(year, month); final key = TimeService.monthKey(year, month);
final notOccupiedMonths = Set<String>.from(_settings.notOccupiedMonths);
if (status == PaymentStatus.notOccupied) { if (status == PaymentStatus.notOccupied) {
throw StateError( notOccupiedMonths.add(key);
'Use Settings -> Unit occupied to manage not-occupied months.',
);
}
if (status == PaymentStatus.notPaid || status == PaymentStatus.pending) {
_records.remove(key); _records.remove(key);
_settings = _settings.copyWith(
notOccupiedMonths: notOccupiedMonths.toList()..sort(),
);
await _storageService.saveSettings(_settings);
await _storageService.saveRecords(_records); await _storageService.saveRecords(_records);
await _notificationService.cancelForMonth(year, month); await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth(); await _syncNotificationScheduleForCurrentMonth();
return; 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)) { if (_isNotOccupiedMonth(year, month)) {
throw StateError('Cannot mark a non-occupied month as paid.'); throw StateError('Cannot mark a non-occupied month as paid.');
} }
@@ -331,6 +435,9 @@ class RentController extends ChangeNotifier {
var isFirstIndividualFetch = true; var isFirstIndividualFetch = true;
for (var month = 1; month <= 12; month++) { for (var month = 1; month <= 12; month++) {
if (_isBeforeTrackingStart(year, month)) {
continue;
}
final key = TimeService.monthKey(year, month); final key = TimeService.monthKey(year, month);
if (_records.containsKey(key)) { if (_records.containsKey(key)) {
if (kDebugMode) { if (kDebugMode) {
@@ -465,10 +572,31 @@ class RentController extends ChangeNotifier {
throw StateError('Backup file is missing settings or records.'); 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>{}; final restoredRecords = <String, MonthlyRentRecord>{};
for (final item in recordsJson) { for (final item in recordsJson) {
final record = MonthlyRentRecord.fromJson(item as Map<String, dynamic>); final record = MonthlyRentRecord.fromJson(item as Map<String, dynamic>);
if (_isBeforeTrackingStart(record.year, record.month)) {
continue;
}
restoredRecords[record.key] = record; restoredRecords[record.key] = record;
} }
_records = restoredRecords; _records = restoredRecords;
@@ -510,15 +638,26 @@ class RentController extends ChangeNotifier {
List<RentTableRow> buildRowsForSelectedYear() { List<RentTableRow> buildRowsForSelectedYear() {
final now = DateTime.now(); final now = DateTime.now();
if (_selectedYear < _trackingStartYear) {
return <RentTableRow>[];
}
final minMonth = _selectedYear == _trackingStartYear
? _trackingStartMonth
: 1;
final maxMonth = _selectedYear < now.year final maxMonth = _selectedYear < now.year
? 12 ? 12
: (_selectedYear == now.year ? now.month : 0); : (_selectedYear == now.year ? now.month : 0);
if (maxMonth < minMonth) {
return <RentTableRow>[];
}
final rows = <RentTableRow>[]; final rows = <RentTableRow>[];
var runningUsd = 0.0; var runningUsd = 0.0;
var runningSek = 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 key = TimeService.monthKey(_selectedYear, month);
final record = _records[key]; final record = _records[key];
final status = _statusForMonth(_selectedYear, month, record); final status = _statusForMonth(_selectedYear, month, record);
@@ -594,6 +733,15 @@ class RentController extends ChangeNotifier {
} }
bool _isNotOccupiedMonth(int year, int month) { 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) { if (_settings.occupied || _settings.notOccupiedFromYearMonth == null) {
return false; return false;
} }
@@ -648,4 +796,13 @@ class RentController extends ChangeNotifier {
_isLoading = value; _isLoading = value;
notifyListeners(); 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 { class _FakeExchangeService extends FrankfurterApiService {
_FakeExchangeService({required this.quote}) _FakeExchangeService({required this.quote})
: super(httpClient: http.Client(), apiKey: 'test-key'); : super(httpClient: http.Client());
final double quote; final double quote;
+1 -1
View File
@@ -30,7 +30,7 @@ class _FakeStorageService extends StorageService {
} }
class _FakeExchangeService extends FrankfurterApiService { class _FakeExchangeService extends FrankfurterApiService {
_FakeExchangeService() : super(httpClient: http.Client(), apiKey: 'test-key'); _FakeExchangeService() : super(httpClient: http.Client());
@override @override
Future<ConversionResult> convertUsdToSek({ Future<ConversionResult> convertUsdToSek({