diff --git a/lib/application/app_dependencies.dart b/lib/application/app_dependencies.dart index 7498313..47719b9 100644 --- a/lib/application/app_dependencies.dart +++ b/lib/application/app_dependencies.dart @@ -27,6 +27,7 @@ import 'package:subctrl/application/settings/set_notifications_enabled_use_case. import 'package:subctrl/application/settings/set_theme_preference_use_case.dart'; import 'package:subctrl/application/subscriptions/add_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/delete_subscription_use_case.dart'; +import 'package:subctrl/application/subscriptions/refresh_overdue_next_payments_use_case.dart'; import 'package:subctrl/application/subscriptions/update_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/watch_subscriptions_use_case.dart'; import 'package:subctrl/application/tags/create_tag_use_case.dart'; @@ -55,6 +56,7 @@ class AppDependencies { required this.addSubscriptionUseCase, required this.updateSubscriptionUseCase, required this.deleteSubscriptionUseCase, + required this.refreshOverdueNextPaymentsUseCase, required this.watchCurrenciesUseCase, required this.getCurrenciesUseCase, required this.setCurrencyEnabledUseCase, @@ -124,6 +126,9 @@ class AppDependencies { deleteSubscriptionUseCase: DeleteSubscriptionUseCase( subscriptionRepository, ), + refreshOverdueNextPaymentsUseCase: RefreshOverdueNextPaymentsUseCase( + subscriptionRepository, + ), watchCurrenciesUseCase: WatchCurrenciesUseCase(currencyRepository), getCurrenciesUseCase: GetCurrenciesUseCase(currencyRepository), setCurrencyEnabledUseCase: SetCurrencyEnabledUseCase(currencyRepository), @@ -197,6 +202,7 @@ class AppDependencies { final AddSubscriptionUseCase addSubscriptionUseCase; final UpdateSubscriptionUseCase updateSubscriptionUseCase; final DeleteSubscriptionUseCase deleteSubscriptionUseCase; + final RefreshOverdueNextPaymentsUseCase refreshOverdueNextPaymentsUseCase; final WatchCurrenciesUseCase watchCurrenciesUseCase; final GetCurrenciesUseCase getCurrenciesUseCase; diff --git a/lib/application/subscriptions/refresh_overdue_next_payments_use_case.dart b/lib/application/subscriptions/refresh_overdue_next_payments_use_case.dart new file mode 100644 index 0000000..57954a7 --- /dev/null +++ b/lib/application/subscriptions/refresh_overdue_next_payments_use_case.dart @@ -0,0 +1,34 @@ +import 'package:subctrl/domain/entities/subscription.dart'; +import 'package:subctrl/domain/repositories/subscription_repository.dart'; + +class RefreshOverdueNextPaymentsUseCase { + RefreshOverdueNextPaymentsUseCase( + this._repository, { + DateTime Function()? nowProvider, + }) : _nowProvider = nowProvider ?? DateTime.now; + + final SubscriptionRepository _repository; + final DateTime Function() _nowProvider; + + Future call(List subscriptions) async { + if (subscriptions.isEmpty) { + return; + } + final now = _nowProvider(); + for (final subscription in subscriptions) { + if (!isBeforeDay(subscription.nextPaymentDate, now)) { + continue; + } + final nextPaymentDate = subscription.cycle.nextPaymentDate( + subscription.purchaseDate, + now, + ); + if (nextPaymentDate == subscription.nextPaymentDate) { + continue; + } + await _repository.updateSubscription( + subscription.copyWith(nextPaymentDate: nextPaymentDate), + ); + } + } +} diff --git a/lib/presentation/screens/subscriptions_screen.dart b/lib/presentation/screens/subscriptions_screen.dart index da920b7..f7fdc97 100644 --- a/lib/presentation/screens/subscriptions_screen.dart +++ b/lib/presentation/screens/subscriptions_screen.dart @@ -63,6 +63,8 @@ class _SubscriptionsScreenState extends State { addSubscriptionUseCase: widget.dependencies.addSubscriptionUseCase, updateSubscriptionUseCase: widget.dependencies.updateSubscriptionUseCase, deleteSubscriptionUseCase: widget.dependencies.deleteSubscriptionUseCase, + refreshOverdueNextPaymentsUseCase: + widget.dependencies.refreshOverdueNextPaymentsUseCase, watchCurrenciesUseCase: widget.dependencies.watchCurrenciesUseCase, getCurrenciesUseCase: widget.dependencies.getCurrenciesUseCase, watchCurrencyRatesUseCase: widget.dependencies.watchCurrencyRatesUseCase, diff --git a/lib/presentation/viewmodels/subscriptions_view_model.dart b/lib/presentation/viewmodels/subscriptions_view_model.dart index 62404b8..847937e 100644 --- a/lib/presentation/viewmodels/subscriptions_view_model.dart +++ b/lib/presentation/viewmodels/subscriptions_view_model.dart @@ -14,6 +14,7 @@ import 'package:subctrl/application/notifications/get_pending_notifications_use_ import 'package:subctrl/application/notifications/schedule_notifications_use_case.dart'; import 'package:subctrl/application/subscriptions/add_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/delete_subscription_use_case.dart'; +import 'package:subctrl/application/subscriptions/refresh_overdue_next_payments_use_case.dart'; import 'package:subctrl/application/subscriptions/update_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/watch_subscriptions_use_case.dart'; import 'package:subctrl/application/tags/watch_tags_use_case.dart'; @@ -35,6 +36,7 @@ class SubscriptionsViewModel extends ChangeNotifier { required AddSubscriptionUseCase addSubscriptionUseCase, required UpdateSubscriptionUseCase updateSubscriptionUseCase, required DeleteSubscriptionUseCase deleteSubscriptionUseCase, + required RefreshOverdueNextPaymentsUseCase refreshOverdueNextPaymentsUseCase, required WatchCurrenciesUseCase watchCurrenciesUseCase, required GetCurrenciesUseCase getCurrenciesUseCase, required WatchCurrencyRatesUseCase watchCurrencyRatesUseCase, @@ -54,6 +56,7 @@ class SubscriptionsViewModel extends ChangeNotifier { _addSubscriptionUseCase = addSubscriptionUseCase, _updateSubscriptionUseCase = updateSubscriptionUseCase, _deleteSubscriptionUseCase = deleteSubscriptionUseCase, + _refreshOverdueNextPaymentsUseCase = refreshOverdueNextPaymentsUseCase, _watchCurrenciesUseCase = watchCurrenciesUseCase, _getCurrenciesUseCase = getCurrenciesUseCase, _watchCurrencyRatesUseCase = watchCurrencyRatesUseCase, @@ -79,6 +82,7 @@ class SubscriptionsViewModel extends ChangeNotifier { final AddSubscriptionUseCase _addSubscriptionUseCase; final UpdateSubscriptionUseCase _updateSubscriptionUseCase; final DeleteSubscriptionUseCase _deleteSubscriptionUseCase; + final RefreshOverdueNextPaymentsUseCase _refreshOverdueNextPaymentsUseCase; final WatchCurrenciesUseCase _watchCurrenciesUseCase; final GetCurrenciesUseCase _getCurrenciesUseCase; final WatchCurrencyRatesUseCase _watchCurrencyRatesUseCase; @@ -104,6 +108,7 @@ class SubscriptionsViewModel extends ChangeNotifier { bool _isFetchingRates = false; bool _autoDownloadEnabled = true; bool _isSyncingNotifications = false; + bool _isUpdatingNextPayments = false; List _subscriptions = const []; List _tags = const []; @@ -233,6 +238,7 @@ class SubscriptionsViewModel extends ChangeNotifier { _subscriptions = subscriptions; _isLoadingSubscriptions = false; notifyListeners(); + unawaited(_refreshOverdueNextPayments(subscriptions)); if (_autoDownloadEnabled) { unawaited(_refreshCurrencyRatesForSubscriptions()); } @@ -331,6 +337,26 @@ class SubscriptionsViewModel extends ChangeNotifier { } } + Future _refreshOverdueNextPayments( + List subscriptions, + ) async { + if (_isUpdatingNextPayments) { + return; + } + _isUpdatingNextPayments = true; + try { + await _refreshOverdueNextPaymentsUseCase(subscriptions); + } catch (error, stackTrace) { + _log( + 'Failed to refresh overdue next payments', + error: error, + stackTrace: stackTrace, + ); + } finally { + _isUpdatingNextPayments = false; + } + } + Map _latestRatesFrom(List rates) { final Map result = {}; for (final rate in rates) { diff --git a/pubspec.yaml b/pubspec.yaml index 9be27dd..bd31616 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+6 +version: 1.0.2+7 environment: sdk: ^3.10.3 diff --git a/test/application/subscriptions/subscription_use_cases_test.dart b/test/application/subscriptions/subscription_use_cases_test.dart index 059ff48..a3ade11 100644 --- a/test/application/subscriptions/subscription_use_cases_test.dart +++ b/test/application/subscriptions/subscription_use_cases_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:subctrl/application/subscriptions/add_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/delete_subscription_use_case.dart'; +import 'package:subctrl/application/subscriptions/refresh_overdue_next_payments_use_case.dart'; import 'package:subctrl/application/subscriptions/update_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/watch_subscriptions_use_case.dart'; import 'package:subctrl/domain/entities/subscription.dart'; @@ -30,6 +31,7 @@ void main() { late AddSubscriptionUseCase addUseCase; late UpdateSubscriptionUseCase updateUseCase; late DeleteSubscriptionUseCase deleteUseCase; + late RefreshOverdueNextPaymentsUseCase refreshOverdueNextPaymentsUseCase; setUp(() { repository = _MockSubscriptionRepository(); @@ -37,6 +39,10 @@ void main() { addUseCase = AddSubscriptionUseCase(repository); updateUseCase = UpdateSubscriptionUseCase(repository); deleteUseCase = DeleteSubscriptionUseCase(repository); + refreshOverdueNextPaymentsUseCase = RefreshOverdueNextPaymentsUseCase( + repository, + nowProvider: () => DateTime(2024, 2, 15), + ); }); test('watch use case delegates to repository stream', () async { @@ -92,4 +98,41 @@ void main() { await deleteUseCase(42); verify(() => repository.deleteSubscription(42)).called(1); }); + + test('refresh overdue next payments updates stored subscriptions', () async { + when(() => repository.updateSubscription(any())).thenAnswer((_) async {}); + final stale = Subscription( + id: 1, + name: 'Stale', + amount: 5, + currency: 'USD', + cycle: BillingCycle.monthly, + purchaseDate: DateTime(2024, 1, 1), + nextPaymentDate: DateTime(2024, 2, 1), + ); + final fresh = Subscription( + id: 2, + name: 'Fresh', + amount: 5, + currency: 'USD', + cycle: BillingCycle.monthly, + purchaseDate: DateTime(2024, 2, 1), + nextPaymentDate: DateTime(2024, 3, 1), + ); + + await refreshOverdueNextPaymentsUseCase([stale, fresh]); + + verify( + () => repository.updateSubscription( + any( + that: predicate( + (updated) => + updated.id == 1 && + updated.nextPaymentDate == DateTime(2024, 3, 1), + ), + ), + ), + ).called(1); + verifyNever(() => repository.updateSubscription(fresh)); + }); } diff --git a/test/presentation/viewmodels/subscriptions_view_model_test.dart b/test/presentation/viewmodels/subscriptions_view_model_test.dart index cd113b7..36d3a4f 100644 --- a/test/presentation/viewmodels/subscriptions_view_model_test.dart +++ b/test/presentation/viewmodels/subscriptions_view_model_test.dart @@ -14,6 +14,7 @@ import 'package:subctrl/application/notifications/get_pending_notifications_use_ import 'package:subctrl/application/notifications/schedule_notifications_use_case.dart'; import 'package:subctrl/application/subscriptions/add_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/delete_subscription_use_case.dart'; +import 'package:subctrl/application/subscriptions/refresh_overdue_next_payments_use_case.dart'; import 'package:subctrl/application/subscriptions/update_subscription_use_case.dart'; import 'package:subctrl/application/subscriptions/watch_subscriptions_use_case.dart'; import 'package:subctrl/application/tags/watch_tags_use_case.dart'; @@ -38,6 +39,9 @@ class _MockUpdateSubscriptionUseCase extends Mock class _MockDeleteSubscriptionUseCase extends Mock implements DeleteSubscriptionUseCase {} +class _MockRefreshOverdueNextPaymentsUseCase extends Mock + implements RefreshOverdueNextPaymentsUseCase {} + class _MockWatchCurrenciesUseCase extends Mock implements WatchCurrenciesUseCase {} @@ -85,6 +89,7 @@ void main() { late _MockAddSubscriptionUseCase addSubscriptionUseCase; late _MockUpdateSubscriptionUseCase updateSubscriptionUseCase; late _MockDeleteSubscriptionUseCase deleteSubscriptionUseCase; + late _MockRefreshOverdueNextPaymentsUseCase refreshOverdueNextPaymentsUseCase; late _MockWatchCurrenciesUseCase watchCurrenciesUseCase; late _MockGetCurrenciesUseCase getCurrenciesUseCase; late _MockWatchCurrencyRatesUseCase watchCurrencyRatesUseCase; @@ -107,6 +112,8 @@ void main() { addSubscriptionUseCase = _MockAddSubscriptionUseCase(); updateSubscriptionUseCase = _MockUpdateSubscriptionUseCase(); deleteSubscriptionUseCase = _MockDeleteSubscriptionUseCase(); + refreshOverdueNextPaymentsUseCase = + _MockRefreshOverdueNextPaymentsUseCase(); watchCurrenciesUseCase = _MockWatchCurrenciesUseCase(); getCurrenciesUseCase = _MockGetCurrenciesUseCase(); watchCurrencyRatesUseCase = _MockWatchCurrencyRatesUseCase(); @@ -161,12 +168,16 @@ void main() { when(() => addSubscriptionUseCase(any())).thenAnswer((_) async {}); when(() => updateSubscriptionUseCase(any())).thenAnswer((_) async {}); when(() => deleteSubscriptionUseCase(any())).thenAnswer((_) async {}); + when( + () => refreshOverdueNextPaymentsUseCase(any()), + ).thenAnswer((_) async {}); viewModel = SubscriptionsViewModel( watchSubscriptionsUseCase: watchSubscriptionsUseCase, addSubscriptionUseCase: addSubscriptionUseCase, updateSubscriptionUseCase: updateSubscriptionUseCase, deleteSubscriptionUseCase: deleteSubscriptionUseCase, + refreshOverdueNextPaymentsUseCase: refreshOverdueNextPaymentsUseCase, watchCurrenciesUseCase: watchCurrenciesUseCase, getCurrenciesUseCase: getCurrenciesUseCase, watchCurrencyRatesUseCase: watchCurrencyRatesUseCase, @@ -247,6 +258,28 @@ void main() { expect(viewModel.isLoadingCurrencies, isFalse); }); + test('triggers overdue next payment refresh on updates', () async { + final subscription = Subscription( + id: 1, + name: 'Overdue', + amount: 5, + currency: 'usd', + cycle: BillingCycle.monthly, + purchaseDate: DateTime(2024, 1, 1), + ); + subscriptionsController.add([subscription]); + tagsController.add(const []); + currenciesController.add(const []); + ratesController.add(const []); + await Future.delayed(Duration.zero); + + verify( + () => refreshOverdueNextPaymentsUseCase( + any(that: predicate>((subs) => subs.length == 1)), + ), + ).called(1); + }); + test('updateBaseCurrencyCode re-listens to currency rates stream', () async { viewModel.updateBaseCurrencyCode('eur'); await Future.delayed(Duration.zero); @@ -322,6 +355,7 @@ void main() { addSubscriptionUseCase: addSubscriptionUseCase, updateSubscriptionUseCase: updateSubscriptionUseCase, deleteSubscriptionUseCase: deleteSubscriptionUseCase, + refreshOverdueNextPaymentsUseCase: refreshOverdueNextPaymentsUseCase, watchCurrenciesUseCase: watchCurrenciesUseCase, getCurrenciesUseCase: getCurrenciesUseCase, watchCurrencyRatesUseCase: watchCurrencyRatesUseCase,