Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/application/app_dependencies.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -124,6 +126,9 @@ class AppDependencies {
deleteSubscriptionUseCase: DeleteSubscriptionUseCase(
subscriptionRepository,
),
refreshOverdueNextPaymentsUseCase: RefreshOverdueNextPaymentsUseCase(
subscriptionRepository,
),
watchCurrenciesUseCase: WatchCurrenciesUseCase(currencyRepository),
getCurrenciesUseCase: GetCurrenciesUseCase(currencyRepository),
setCurrencyEnabledUseCase: SetCurrencyEnabledUseCase(currencyRepository),
Expand Down Expand Up @@ -197,6 +202,7 @@ class AppDependencies {
final AddSubscriptionUseCase addSubscriptionUseCase;
final UpdateSubscriptionUseCase updateSubscriptionUseCase;
final DeleteSubscriptionUseCase deleteSubscriptionUseCase;
final RefreshOverdueNextPaymentsUseCase refreshOverdueNextPaymentsUseCase;

final WatchCurrenciesUseCase watchCurrenciesUseCase;
final GetCurrenciesUseCase getCurrenciesUseCase;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> call(List<Subscription> 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),
);
}
}
}
2 changes: 2 additions & 0 deletions lib/presentation/screens/subscriptions_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ class _SubscriptionsScreenState extends State<SubscriptionsScreen> {
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,
Expand Down
26 changes: 26 additions & 0 deletions lib/presentation/viewmodels/subscriptions_view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -54,6 +56,7 @@ class SubscriptionsViewModel extends ChangeNotifier {
_addSubscriptionUseCase = addSubscriptionUseCase,
_updateSubscriptionUseCase = updateSubscriptionUseCase,
_deleteSubscriptionUseCase = deleteSubscriptionUseCase,
_refreshOverdueNextPaymentsUseCase = refreshOverdueNextPaymentsUseCase,
_watchCurrenciesUseCase = watchCurrenciesUseCase,
_getCurrenciesUseCase = getCurrenciesUseCase,
_watchCurrencyRatesUseCase = watchCurrencyRatesUseCase,
Expand All @@ -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;
Expand All @@ -104,6 +108,7 @@ class SubscriptionsViewModel extends ChangeNotifier {
bool _isFetchingRates = false;
bool _autoDownloadEnabled = true;
bool _isSyncingNotifications = false;
bool _isUpdatingNextPayments = false;

List<Subscription> _subscriptions = const [];
List<Tag> _tags = const [];
Expand Down Expand Up @@ -233,6 +238,7 @@ class SubscriptionsViewModel extends ChangeNotifier {
_subscriptions = subscriptions;
_isLoadingSubscriptions = false;
notifyListeners();
unawaited(_refreshOverdueNextPayments(subscriptions));
if (_autoDownloadEnabled) {
unawaited(_refreshCurrencyRatesForSubscriptions());
}
Expand Down Expand Up @@ -331,6 +337,26 @@ class SubscriptionsViewModel extends ChangeNotifier {
}
}

Future<void> _refreshOverdueNextPayments(
List<Subscription> 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<String, CurrencyRate> _latestRatesFrom(List<CurrencyRate> rates) {
final Map<String, CurrencyRate> result = {};
for (final rate in rates) {
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions test/application/subscriptions/subscription_use_cases_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,13 +31,18 @@ void main() {
late AddSubscriptionUseCase addUseCase;
late UpdateSubscriptionUseCase updateUseCase;
late DeleteSubscriptionUseCase deleteUseCase;
late RefreshOverdueNextPaymentsUseCase refreshOverdueNextPaymentsUseCase;

setUp(() {
repository = _MockSubscriptionRepository();
watchUseCase = WatchSubscriptionsUseCase(repository);
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 {
Expand Down Expand Up @@ -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<Subscription>(
(updated) =>
updated.id == 1 &&
updated.nextPaymentDate == DateTime(2024, 3, 1),
),
),
),
).called(1);
verifyNever(() => repository.updateSubscription(fresh));
});
}
34 changes: 34 additions & 0 deletions test/presentation/viewmodels/subscriptions_view_model_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {}

Expand Down Expand Up @@ -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;
Expand All @@ -107,6 +112,8 @@ void main() {
addSubscriptionUseCase = _MockAddSubscriptionUseCase();
updateSubscriptionUseCase = _MockUpdateSubscriptionUseCase();
deleteSubscriptionUseCase = _MockDeleteSubscriptionUseCase();
refreshOverdueNextPaymentsUseCase =
_MockRefreshOverdueNextPaymentsUseCase();
watchCurrenciesUseCase = _MockWatchCurrenciesUseCase();
getCurrenciesUseCase = _MockGetCurrenciesUseCase();
watchCurrencyRatesUseCase = _MockWatchCurrencyRatesUseCase();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>.delayed(Duration.zero);

verify(
() => refreshOverdueNextPaymentsUseCase(
any(that: predicate<List<Subscription>>((subs) => subs.length == 1)),
),
).called(1);
});

test('updateBaseCurrencyCode re-listens to currency rates stream', () async {
viewModel.updateBaseCurrencyCode('eur');
await Future<void>.delayed(Duration.zero);
Expand Down Expand Up @@ -322,6 +355,7 @@ void main() {
addSubscriptionUseCase: addSubscriptionUseCase,
updateSubscriptionUseCase: updateSubscriptionUseCase,
deleteSubscriptionUseCase: deleteSubscriptionUseCase,
refreshOverdueNextPaymentsUseCase: refreshOverdueNextPaymentsUseCase,
watchCurrenciesUseCase: watchCurrenciesUseCase,
getCurrenciesUseCase: getCurrenciesUseCase,
watchCurrencyRatesUseCase: watchCurrencyRatesUseCase,
Expand Down
Loading