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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user