diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 3997f58..288054f 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -5,6 +5,7 @@ class AppSettings { required this.rentUsd, required this.occupied, this.notOccupiedFromYearMonth, + this.notOccupiedMonths = const [], this.localFallbackHour = 9, this.localQuietHourStart = 23, }); @@ -12,6 +13,7 @@ class AppSettings { final double rentUsd; final bool occupied; final String? notOccupiedFromYearMonth; + final List notOccupiedMonths; final int localFallbackHour; final int localQuietHourStart; @@ -23,6 +25,7 @@ class AppSettings { double? rentUsd, bool? occupied, String? notOccupiedFromYearMonth, + List? 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? ?? const []) + .whereType() + .toList(), localFallbackHour: json['localFallbackHour'] ?? 9, localQuietHourStart: json['localQuietHourStart'] ?? 23, ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d34cc9e..3a57c60 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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: [ _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 diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 615490f..ad8f567 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { Widget build(BuildContext context) { return Consumer( 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: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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( diff --git a/lib/state/rent_controller.dart b/lib/state/rent_controller.dart index f483685..dfdfecd 100644 --- a/lib/state/rent_controller.dart +++ b/lib/state/rent_controller.dart @@ -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 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 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.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 = {}; for (final item in recordsJson) { final record = MonthlyRentRecord.fromJson(item as Map); + if (_isBeforeTrackingStart(record.year, record.month)) { + continue; + } restoredRecords[record.key] = record; } _records = restoredRecords; @@ -510,15 +638,26 @@ class RentController extends ChangeNotifier { List buildRowsForSelectedYear() { final now = DateTime.now(); + if (_selectedYear < _trackingStartYear) { + return []; + } + + final minMonth = _selectedYear == _trackingStartYear + ? _trackingStartMonth + : 1; final maxMonth = _selectedYear < now.year ? 12 : (_selectedYear == now.year ? now.month : 0); + if (maxMonth < minMonth) { + return []; + } + final rows = []; 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, + ); + } } diff --git a/test/backup_restore_compatibility_test.dart b/test/backup_restore_compatibility_test.dart index 71e6fcc..23feeb9 100644 --- a/test/backup_restore_compatibility_test.dart +++ b/test/backup_restore_compatibility_test.dart @@ -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; diff --git a/test/notification_scheduling_test.dart b/test/notification_scheduling_test.dart index 3e5d0a6..2bc8001 100644 --- a/test/notification_scheduling_test.dart +++ b/test/notification_scheduling_test.dart @@ -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 convertUsdToSek({