diff --git a/lib/application/app_clock.dart b/lib/application/app_clock.dart new file mode 100644 index 0000000..aa65425 --- /dev/null +++ b/lib/application/app_clock.dart @@ -0,0 +1,36 @@ +const bool kEnableTestingDateOverride = bool.fromEnvironment( + 'ENABLE_TESTING_DATE', + defaultValue: false, +); + +class AppClock { + DateTime? _overrideDate; + + DateTime now() { + final overrideDate = _overrideDate; + if (overrideDate == null) { + return DateTime.now(); + } + final systemNow = DateTime.now(); + return DateTime( + overrideDate.year, + overrideDate.month, + overrideDate.day, + systemNow.hour, + systemNow.minute, + systemNow.second, + systemNow.millisecond, + systemNow.microsecond, + ); + } + + DateTime? get overrideDate => _overrideDate; + + void setOverrideDate(DateTime? value) { + if (value == null) { + _overrideDate = null; + return; + } + _overrideDate = DateTime(value.year, value.month, value.day); + } +} diff --git a/lib/application/app_dependencies.dart b/lib/application/app_dependencies.dart index 47719b9..9c1f8c3 100644 --- a/lib/application/app_dependencies.dart +++ b/lib/application/app_dependencies.dart @@ -1,3 +1,4 @@ +import 'package:subctrl/application/app_clock.dart'; import 'package:subctrl/application/currencies/add_custom_currency_use_case.dart'; import 'package:subctrl/application/currencies/delete_custom_currency_use_case.dart'; import 'package:subctrl/application/currencies/get_currencies_use_case.dart'; @@ -19,11 +20,13 @@ import 'package:subctrl/application/settings/get_locale_code_use_case.dart'; import 'package:subctrl/application/settings/get_notification_reminder_offset_use_case.dart'; import 'package:subctrl/application/settings/get_notifications_enabled_use_case.dart'; import 'package:subctrl/application/settings/get_theme_preference_use_case.dart'; +import 'package:subctrl/application/settings/get_testing_date_override_use_case.dart'; import 'package:subctrl/application/settings/set_base_currency_code_use_case.dart'; import 'package:subctrl/application/settings/set_currency_rates_auto_download_use_case.dart'; import 'package:subctrl/application/settings/set_locale_code_use_case.dart'; import 'package:subctrl/application/settings/set_notification_reminder_offset_use_case.dart'; import 'package:subctrl/application/settings/set_notifications_enabled_use_case.dart'; +import 'package:subctrl/application/settings/set_testing_date_override_use_case.dart'; 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'; @@ -83,11 +86,14 @@ class AppDependencies { required this.setNotificationsEnabledUseCase, required this.getNotificationReminderOffsetUseCase, required this.setNotificationReminderOffsetUseCase, + required this.getTestingDateOverrideUseCase, + required this.setTestingDateOverrideUseCase, required this.getPendingNotificationsUseCase, required this.cancelNotificationsUseCase, required this.scheduleNotificationsUseCase, required this.requestNotificationPermissionUseCase, required this.openNotificationSettingsUseCase, + required this.appClock, required YahooFinanceCurrencyClient yahooFinanceCurrencyClient, }) : _yahooFinanceCurrencyClient = yahooFinanceCurrencyClient; @@ -112,6 +118,7 @@ class AppDependencies { yahooFinanceCurrencyClient: yahooFinanceClient, currencyRepository: currencyRepository, ); + final appClock = AppClock(); final localNotificationsService = LocalNotificationsService(); final notificationPermissionService = NotificationPermissionService(); @@ -128,6 +135,7 @@ class AppDependencies { ), refreshOverdueNextPaymentsUseCase: RefreshOverdueNextPaymentsUseCase( subscriptionRepository, + nowProvider: appClock.now, ), watchCurrenciesUseCase: WatchCurrenciesUseCase(currencyRepository), getCurrenciesUseCase: GetCurrenciesUseCase(currencyRepository), @@ -179,6 +187,12 @@ class AppDependencies { GetNotificationReminderOffsetUseCase(settingsRepository), setNotificationReminderOffsetUseCase: SetNotificationReminderOffsetUseCase(settingsRepository), + getTestingDateOverrideUseCase: GetTestingDateOverrideUseCase( + settingsRepository, + ), + setTestingDateOverrideUseCase: SetTestingDateOverrideUseCase( + settingsRepository, + ), getPendingNotificationsUseCase: GetPendingNotificationsUseCase( localNotificationsService, ), @@ -194,6 +208,7 @@ class AppDependencies { openNotificationSettingsUseCase: OpenNotificationSettingsUseCase( notificationPermissionService, ), + appClock: appClock, yahooFinanceCurrencyClient: yahooFinanceClient, ); } @@ -235,12 +250,15 @@ class AppDependencies { getNotificationReminderOffsetUseCase; final SetNotificationReminderOffsetUseCase setNotificationReminderOffsetUseCase; + final GetTestingDateOverrideUseCase getTestingDateOverrideUseCase; + final SetTestingDateOverrideUseCase setTestingDateOverrideUseCase; final GetPendingNotificationsUseCase getPendingNotificationsUseCase; final CancelNotificationsUseCase cancelNotificationsUseCase; final ScheduleNotificationsUseCase scheduleNotificationsUseCase; final RequestNotificationPermissionUseCase requestNotificationPermissionUseCase; final OpenNotificationSettingsUseCase openNotificationSettingsUseCase; + final AppClock appClock; final YahooFinanceCurrencyClient _yahooFinanceCurrencyClient; diff --git a/lib/application/settings/get_testing_date_override_use_case.dart b/lib/application/settings/get_testing_date_override_use_case.dart new file mode 100644 index 0000000..74104f4 --- /dev/null +++ b/lib/application/settings/get_testing_date_override_use_case.dart @@ -0,0 +1,11 @@ +import 'package:subctrl/domain/repositories/settings_repository.dart'; + +class GetTestingDateOverrideUseCase { + GetTestingDateOverrideUseCase(this._repository); + + final SettingsRepository _repository; + + Future call() { + return _repository.getTestingDateOverride(); + } +} diff --git a/lib/application/settings/set_testing_date_override_use_case.dart b/lib/application/settings/set_testing_date_override_use_case.dart new file mode 100644 index 0000000..2792020 --- /dev/null +++ b/lib/application/settings/set_testing_date_override_use_case.dart @@ -0,0 +1,11 @@ +import 'package:subctrl/domain/repositories/settings_repository.dart'; + +class SetTestingDateOverrideUseCase { + SetTestingDateOverrideUseCase(this._repository); + + final SettingsRepository _repository; + + Future call(DateTime? value) { + return _repository.setTestingDateOverride(value); + } +} diff --git a/lib/domain/repositories/settings_repository.dart b/lib/domain/repositories/settings_repository.dart index a99f032..40612c1 100644 --- a/lib/domain/repositories/settings_repository.dart +++ b/lib/domain/repositories/settings_repository.dart @@ -22,4 +22,8 @@ abstract class SettingsRepository { Future getNotificationReminderOffset(); Future setNotificationReminderOffset(String value); + + Future getTestingDateOverride(); + + Future setTestingDateOverride(DateTime? value); } diff --git a/lib/infrastructure/repositories/drift_settings_repository.dart b/lib/infrastructure/repositories/drift_settings_repository.dart index 5ff8cdf..53f121c 100644 --- a/lib/infrastructure/repositories/drift_settings_repository.dart +++ b/lib/infrastructure/repositories/drift_settings_repository.dart @@ -14,6 +14,7 @@ class DriftSettingsRepository implements SettingsRepository { static const _notificationsEnabledKey = 'notifications_enabled'; static const _notificationReminderOffsetKey = 'notification_reminder_offset'; + static const _testingDateOverrideKey = 'testing_date_override'; @override Future getBaseCurrencyCode() { @@ -85,4 +86,24 @@ class DriftSettingsRepository implements SettingsRepository { Future setNotificationReminderOffset(String value) { return _dao.saveSetting(_notificationReminderOffsetKey, value); } + + @override + Future getTestingDateOverride() async { + final stored = await _dao.getSetting(_testingDateOverrideKey); + if (stored == null || stored.isEmpty) { + return null; + } + return DateTime.tryParse(stored); + } + + @override + Future setTestingDateOverride(DateTime? value) { + final stored = value == null ? null : _formatDate(value); + return _dao.saveSetting(_testingDateOverrideKey, stored); + } + + String _formatDate(DateTime value) { + final normalized = DateTime(value.year, value.month, value.day); + return normalized.toIso8601String().split('T').first; + } } diff --git a/lib/main.dart b/lib/main.dart index c20e5d1..e5c66ab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; +import 'package:subctrl/application/app_clock.dart'; import 'package:subctrl/application/app_dependencies.dart'; import 'package:subctrl/domain/entities/notification_reminder_option.dart'; import 'package:subctrl/presentation/l10n/app_localizations.dart'; @@ -33,6 +34,7 @@ class _SubctrlAppState extends State { bool _areNotificationsEnabled = false; NotificationReminderOption _notificationReminderOption = NotificationReminderOption.twoDaysBefore; + DateTime? _testingDateOverride; late final AppDependencies _dependencies; @override @@ -66,6 +68,9 @@ class _SubctrlAppState extends State { .getNotificationsEnabledUseCase(); final storedReminder = await _dependencies .getNotificationReminderOffsetUseCase(); + final storedTestingDate = kEnableTestingDateOverride + ? await _dependencies.getTestingDateOverrideUseCase() + : null; final reminderOption = NotificationReminderOption.fromStorage( storedReminder, ); @@ -92,7 +97,13 @@ class _SubctrlAppState extends State { _isCurrencyRatesAutoDownloadEnabled = shouldDownloadRates; _areNotificationsEnabled = shouldEnableNotifications; _notificationReminderOption = reminderOption; + _testingDateOverride = storedTestingDate; }); + if (kEnableTestingDateOverride) { + _dependencies.appClock.setOverrideDate(storedTestingDate); + } else { + _dependencies.appClock.setOverrideDate(null); + } if (shouldPersistBaseCurrency) { await _dependencies.setBaseCurrencyCodeUseCase(storedBaseCurrency); @@ -145,6 +156,14 @@ class _SubctrlAppState extends State { ); } + Future _handleTestingDateOverrideChanged(DateTime? value) async { + setState(() { + _testingDateOverride = value; + }); + _dependencies.appClock.setOverrideDate(value); + await _dependencies.setTestingDateOverrideUseCase(value); + } + @override void dispose() { _dependencies.dispose(); @@ -186,6 +205,9 @@ class _SubctrlAppState extends State { onNotificationsPreferenceChanged: _handleNotificationsPreferenceChanged, notificationReminderOption: _notificationReminderOption, onNotificationReminderChanged: _handleNotificationReminderChanged, + testingDateOverride: _testingDateOverride, + onTestingDateOverrideChanged: _handleTestingDateOverrideChanged, + nowProvider: _dependencies.appClock.now, ), ); } @@ -207,6 +229,9 @@ class HomeTabs extends StatelessWidget { required this.onNotificationsPreferenceChanged, required this.notificationReminderOption, required this.onNotificationReminderChanged, + required this.testingDateOverride, + required this.onTestingDateOverrideChanged, + required this.nowProvider, }); final AppDependencies dependencies; @@ -222,6 +247,9 @@ class HomeTabs extends StatelessWidget { final ValueChanged onNotificationsPreferenceChanged; final NotificationReminderOption notificationReminderOption; final NotificationReminderChangedCallback onNotificationReminderChanged; + final DateTime? testingDateOverride; + final TestingDateOverrideChangedCallback onTestingDateOverrideChanged; + final DateTime Function() nowProvider; @override Widget build(BuildContext context) { @@ -231,6 +259,7 @@ class HomeTabs extends StatelessWidget { final baseCurrencyKey = baseCurrencyCode ?? 'none'; final notificationsKey = notificationsEnabled ? 'on' : 'off'; final reminderKey = notificationReminderOption.storageValue; + final testingDateKey = testingDateOverride?.toIso8601String() ?? 'system'; return CupertinoTabScaffold( tabBar: CupertinoTabBar( @@ -250,11 +279,11 @@ class HomeTabs extends StatelessWidget { key: index == 0 ? ValueKey( 'subscriptions-$themeKey-$localeKey-$baseCurrencyKey-' - '$notificationsKey-$reminderKey', + '$notificationsKey-$reminderKey-$testingDateKey', ) : ValueKey( 'analytics-$themeKey-$localeKey-$baseCurrencyKey-' - '$notificationsKey-$reminderKey', + '$notificationsKey-$reminderKey-$testingDateKey', ), builder: (context) { switch (index) { @@ -276,6 +305,10 @@ class HomeTabs extends StatelessWidget { onNotificationsPreferenceChanged, notificationReminderOption: notificationReminderOption, onNotificationReminderChanged: onNotificationReminderChanged, + testingDateOverride: testingDateOverride, + onTestingDateOverrideChanged: + onTestingDateOverrideChanged, + nowProvider: nowProvider, ); case 1: return AnalyticsScreen( @@ -295,6 +328,10 @@ class HomeTabs extends StatelessWidget { onNotificationsPreferenceChanged, notificationReminderOption: notificationReminderOption, onNotificationReminderChanged: onNotificationReminderChanged, + testingDateOverride: testingDateOverride, + onTestingDateOverrideChanged: + onTestingDateOverrideChanged, + nowProvider: nowProvider, ); default: return const SizedBox.shrink(); diff --git a/lib/presentation/l10n/app_localizations.dart b/lib/presentation/l10n/app_localizations.dart index 7674206..226a3d5 100644 --- a/lib/presentation/l10n/app_localizations.dart +++ b/lib/presentation/l10n/app_localizations.dart @@ -61,6 +61,10 @@ class AppLocalizations { 'notificationReminderBody': '{name} will renew on {date}', 'settingsCurrenciesSection': 'Currencies', 'settingsCurrenciesManage': 'Manage currencies', + 'settingsTestingSection': 'Testing', + 'settingsTestingDateLabel': 'Testing date', + 'settingsTestingDateSystem': 'System date', + 'settingsTestingDateReset': 'Use system date', 'settingsTagsSection': 'Tags', 'settingsTagsManage': 'Manage tags', 'settingsTagsTitle': 'Tags', @@ -208,6 +212,10 @@ class AppLocalizations { 'notificationReminderBody': '{name} — будет продлена {date}', 'settingsCurrenciesSection': 'Валюты', 'settingsCurrenciesManage': 'Управление списком', + 'settingsTestingSection': 'Тестирование', + 'settingsTestingDateLabel': 'Тестовая дата', + 'settingsTestingDateSystem': 'Дата системы', + 'settingsTestingDateReset': 'Использовать дату системы', 'settingsTagsSection': 'Теги', 'settingsTagsManage': 'Управление тегами', 'settingsTagsTitle': 'Теги', @@ -398,6 +406,12 @@ class AppLocalizations { String get settingsCurrenciesSection => _strings['settingsCurrenciesSection']!; String get settingsCurrenciesManage => _strings['settingsCurrenciesManage']!; + String get settingsTestingSection => _strings['settingsTestingSection']!; + String get settingsTestingDateLabel => _strings['settingsTestingDateLabel']!; + String get settingsTestingDateSystem => + _strings['settingsTestingDateSystem']!; + String get settingsTestingDateReset => + _strings['settingsTestingDateReset']!; String get settingsTagsSection => _strings['settingsTagsSection']!; String get settingsTagsManage => _strings['settingsTagsManage']!; String get settingsTagsTitle => _strings['settingsTagsTitle']!; diff --git a/lib/presentation/screens/analytics_screen.dart b/lib/presentation/screens/analytics_screen.dart index 688a550..dae2cdb 100644 --- a/lib/presentation/screens/analytics_screen.dart +++ b/lib/presentation/screens/analytics_screen.dart @@ -36,6 +36,9 @@ class AnalyticsScreen extends StatefulWidget { required this.onNotificationsPreferenceChanged, required this.notificationReminderOption, required this.onNotificationReminderChanged, + required this.testingDateOverride, + required this.onTestingDateOverrideChanged, + required this.nowProvider, }); final AppDependencies dependencies; @@ -51,6 +54,9 @@ class AnalyticsScreen extends StatefulWidget { final ValueChanged onNotificationsPreferenceChanged; final NotificationReminderOption notificationReminderOption; final NotificationReminderChangedCallback onNotificationReminderChanged; + final DateTime? testingDateOverride; + final TestingDateOverrideChangedCallback onTestingDateOverrideChanged; + final DateTime Function() nowProvider; @override State createState() => _AnalyticsScreenState(); @@ -120,7 +126,7 @@ class _AnalyticsScreenState extends State { AnalyticsPeriod.allTime => localizations.analyticsPeriodAllTime, }; final range = _currentRange(); - final today = stripTime(DateTime.now()); + final today = stripTime(widget.nowProvider()); final filteredSubscriptions = _filteredSubscriptions(range, today); final filteredTotals = _sumAmounts(filteredSubscriptions, range, today); final breakdowns = _buildBreakdowns( @@ -266,6 +272,9 @@ class _AnalyticsScreenState extends State { widget.onNotificationsPreferenceChanged, notificationReminderOption: widget.notificationReminderOption, onNotificationReminderChanged: widget.onNotificationReminderChanged, + testingDateOverride: widget.testingDateOverride, + onTestingDateOverrideChanged: widget.onTestingDateOverrideChanged, + nowProvider: widget.nowProvider, ); }, ); @@ -284,7 +293,7 @@ class _AnalyticsScreenState extends State { } _DateRange _currentRange() { - final now = DateTime.now(); + final now = widget.nowProvider(); final today = stripTime(now); switch (_selectedPeriod) { case AnalyticsPeriod.month: diff --git a/lib/presentation/screens/currency_rates_screen.dart b/lib/presentation/screens/currency_rates_screen.dart index 558de75..227ded4 100644 --- a/lib/presentation/screens/currency_rates_screen.dart +++ b/lib/presentation/screens/currency_rates_screen.dart @@ -17,10 +17,12 @@ class CurrencyRatesScreen extends StatefulWidget { super.key, required this.dependencies, required this.baseCurrencyCode, + required this.nowProvider, }); final AppDependencies dependencies; final String baseCurrencyCode; + final DateTime Function() nowProvider; @override State createState() => _CurrencyRatesScreenState(); @@ -195,6 +197,7 @@ class _CurrencyRatesScreenState extends State { quotes: quotes, baseCurrencyCode: widget.baseCurrencyCode, localizations: localizations, + nowProvider: widget.nowProvider, ), ); } @@ -276,11 +279,13 @@ class _ManualRateSheet extends StatefulWidget { required this.quotes, required this.baseCurrencyCode, required this.localizations, + required this.nowProvider, }); final List quotes; final String baseCurrencyCode; final AppLocalizations localizations; + final DateTime Function() nowProvider; @override State<_ManualRateSheet> createState() => _ManualRateSheetState(); @@ -288,7 +293,7 @@ class _ManualRateSheet extends StatefulWidget { class _ManualRateSheetState extends State<_ManualRateSheet> { late Currency _selectedCurrency = widget.quotes.first; - late DateTime _selectedDate = DateTime.now(); + late DateTime _selectedDate; final TextEditingController _rateController = TextEditingController(); late final FixedExtentScrollController _pickerController = FixedExtentScrollController(initialItem: 0); @@ -300,6 +305,12 @@ class _ManualRateSheetState extends State<_ManualRateSheet> { bool get _canSubmit => _parsedRate != null; + @override + void initState() { + super.initState(); + _selectedDate = widget.nowProvider(); + } + @override void dispose() { _rateController.dispose(); @@ -411,7 +422,7 @@ class _ManualRateSheetState extends State<_ManualRateSheet> { child: CupertinoDatePicker( mode: CupertinoDatePickerMode.date, initialDateTime: _selectedDate, - maximumDate: DateTime.now().add( + maximumDate: widget.nowProvider().add( const Duration(days: 3650), ), onDateTimeChanged: (value) { diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 8d18984..a116ed6 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -2,12 +2,15 @@ import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/cupertino.dart'; +import 'package:subctrl/application/app_clock.dart'; import 'package:subctrl/application/app_dependencies.dart'; import 'package:subctrl/application/notifications/open_notification_settings_use_case.dart'; import 'package:subctrl/application/notifications/request_notification_permission_use_case.dart'; import 'package:subctrl/domain/entities/currency.dart'; import 'package:subctrl/domain/entities/notification_reminder_option.dart'; import 'package:subctrl/domain/entities/notification_permission_status.dart'; +import 'package:subctrl/domain/utils/date_utils.dart'; +import 'package:subctrl/presentation/formatters/date_formatter.dart'; import 'package:subctrl/presentation/l10n/app_localizations.dart'; import 'package:subctrl/presentation/screens/about_screen.dart'; import 'package:subctrl/presentation/screens/currency_rates_screen.dart'; @@ -36,6 +39,9 @@ class SettingsScreen extends StatefulWidget { required this.onNotificationsPreferenceChanged, required this.notificationReminderOption, required this.onNotificationReminderChanged, + required this.testingDateOverride, + required this.onTestingDateOverrideChanged, + required this.nowProvider, required this.onRequestClose, }); @@ -52,6 +58,9 @@ class SettingsScreen extends StatefulWidget { final ValueChanged onNotificationsPreferenceChanged; final NotificationReminderOption notificationReminderOption; final NotificationReminderChangedCallback onNotificationReminderChanged; + final DateTime? testingDateOverride; + final TestingDateOverrideChangedCallback onTestingDateOverrideChanged; + final DateTime Function() nowProvider; final VoidCallback onRequestClose; @override @@ -69,6 +78,7 @@ class _SettingsScreenState extends State { late bool _isCurrencyRatesAutoDownloadEnabled; late bool _notificationsEnabled; late NotificationReminderOption _notificationReminderOption; + DateTime? _currentTestingDateOverride; @override void initState() { @@ -85,6 +95,7 @@ class _SettingsScreenState extends State { widget.currencyRatesAutoDownloadEnabled; _notificationsEnabled = widget.notificationsEnabled; _notificationReminderOption = widget.notificationReminderOption; + _currentTestingDateOverride = widget.testingDateOverride; } @override @@ -143,6 +154,11 @@ class _SettingsScreenState extends State { _notificationReminderOption = widget.notificationReminderOption; }); } + if (oldWidget.testingDateOverride != widget.testingDateOverride) { + setState(() { + _currentTestingDateOverride = widget.testingDateOverride; + }); + } } Future _pickBaseCurrency() async { @@ -433,11 +449,87 @@ class _SettingsScreenState extends State { builder: (context) => CurrencyRatesScreen( dependencies: widget.dependencies, baseCurrencyCode: baseCode, + nowProvider: widget.nowProvider, ), ), ); } + Future _applyTestingDateOverride(DateTime? value) async { + try { + await widget.onTestingDateOverrideChanged(value); + if (!mounted) return; + setState(() { + _currentTestingDateOverride = value; + }); + } catch (error, stackTrace) { + _log( + 'Failed to persist testing date override', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _selectTestingDateOverride() async { + var tempDate = _currentTestingDateOverride ?? widget.nowProvider(); + var resetRequested = false; + await showCupertinoModalPopup( + context: context, + builder: (context) { + final localizations = AppLocalizations.of(context); + final background = CupertinoColors.systemBackground.resolveFrom( + context, + ); + return Container( + color: background, + height: 320, + child: Column( + children: [ + SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16), + onPressed: _currentTestingDateOverride == null + ? null + : () { + resetRequested = true; + Navigator.of(context).pop(); + }, + child: Text(localizations.settingsTestingDateReset), + ), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16), + onPressed: () => Navigator.of(context).pop(), + child: Text(localizations.done), + ), + ], + ), + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: tempDate, + onDateTimeChanged: (value) => tempDate = value, + ), + ), + ], + ), + ); + }, + ); + if (!mounted) return; + if (resetRequested) { + await _applyTestingDateOverride(null); + return; + } + final normalized = stripTime(tempDate); + await _applyTestingDateOverride(normalized); + } + @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context); @@ -477,6 +569,12 @@ class _SettingsScreenState extends State { final reminderValue = _notificationsEnabled ? reminderLabel : localizations.settingsNotificationsStatusOff; + final testingDateValue = _currentTestingDateOverride == null + ? localizations.settingsTestingDateSystem + : formatDate( + _currentTestingDateOverride!, + Localizations.localeOf(context), + ); return CupertinoPageScaffold( backgroundColor: AppTheme.scaffoldBackgroundColor(context), navigationBar: CupertinoNavigationBar( @@ -518,6 +616,18 @@ class _SettingsScreenState extends State { ), ], ), + if (kEnableTestingDateOverride) + CupertinoFormSection.insetGrouped( + header: Text(localizations.settingsTestingSection), + children: [ + _SettingsTile( + label: localizations.settingsTestingDateLabel, + value: testingDateValue, + onTap: _selectTestingDateOverride, + showChevron: true, + ), + ], + ), CupertinoFormSection.insetGrouped( header: Text(localizations.settingsNotificationsTitle), children: [ diff --git a/lib/presentation/screens/subscriptions_screen.dart b/lib/presentation/screens/subscriptions_screen.dart index f7fdc97..fe67df3 100644 --- a/lib/presentation/screens/subscriptions_screen.dart +++ b/lib/presentation/screens/subscriptions_screen.dart @@ -1,11 +1,13 @@ +import 'dart:async'; + import 'package:flutter/cupertino.dart'; import 'package:subctrl/application/app_dependencies.dart'; +import 'package:subctrl/domain/entities/notification_reminder_option.dart'; import 'package:subctrl/domain/entities/subscription.dart'; import 'package:subctrl/presentation/l10n/app_localizations.dart'; import 'package:subctrl/presentation/theme/app_theme.dart'; import 'package:subctrl/presentation/theme/theme_preference.dart'; -import 'package:subctrl/domain/entities/notification_reminder_option.dart'; import 'package:subctrl/presentation/types/settings_callbacks.dart'; import 'package:subctrl/presentation/viewmodels/subscriptions_view_model.dart'; import 'package:subctrl/presentation/widgets/add_subscription_sheet.dart'; @@ -29,6 +31,9 @@ class SubscriptionsScreen extends StatefulWidget { required this.onNotificationsPreferenceChanged, required this.notificationReminderOption, required this.onNotificationReminderChanged, + required this.testingDateOverride, + required this.onTestingDateOverrideChanged, + required this.nowProvider, }); final AppDependencies dependencies; @@ -44,6 +49,9 @@ class SubscriptionsScreen extends StatefulWidget { final ValueChanged onNotificationsPreferenceChanged; final NotificationReminderOption notificationReminderOption; final NotificationReminderChangedCallback onNotificationReminderChanged; + final DateTime? testingDateOverride; + final TestingDateOverrideChangedCallback onTestingDateOverrideChanged; + final DateTime Function() nowProvider; @override State createState() => _SubscriptionsScreenState(); @@ -84,6 +92,7 @@ class _SubscriptionsScreenState extends State { notificationsEnabled: widget.notificationsEnabled, notificationReminderOption: widget.notificationReminderOption, initialLocale: widget.selectedLocale, + nowProvider: widget.nowProvider, ); _searchController.addListener(() { _viewModel.setSearchQuery(_searchController.text); @@ -120,6 +129,9 @@ class _SubscriptionsScreenState extends State { locale: widget.selectedLocale, ); } + if (oldWidget.testingDateOverride != widget.testingDateOverride) { + unawaited(_viewModel.refreshForTestingDateChange()); + } } Future _openAddSubscriptionSheet() async { @@ -138,6 +150,7 @@ class _SubscriptionsScreenState extends State { currencies: currencies, defaultCurrencyCode: _viewModel.baseCurrencyCode, tags: _viewModel.tags, + nowProvider: widget.nowProvider, ), ); }, @@ -164,6 +177,7 @@ class _SubscriptionsScreenState extends State { defaultCurrencyCode: _viewModel.baseCurrencyCode, tags: _viewModel.tags, initialSubscription: subscription, + nowProvider: widget.nowProvider, ), ); }, @@ -263,6 +277,9 @@ class _SubscriptionsScreenState extends State { widget.onNotificationsPreferenceChanged, notificationReminderOption: widget.notificationReminderOption, onNotificationReminderChanged: widget.onNotificationReminderChanged, + testingDateOverride: widget.testingDateOverride, + onTestingDateOverrideChanged: widget.onTestingDateOverrideChanged, + nowProvider: widget.nowProvider, ); }, ); diff --git a/lib/presentation/types/settings_callbacks.dart b/lib/presentation/types/settings_callbacks.dart index 0b38056..5bebb82 100644 --- a/lib/presentation/types/settings_callbacks.dart +++ b/lib/presentation/types/settings_callbacks.dart @@ -5,3 +5,7 @@ typedef BaseCurrencyChangedCallback = Future Function(String? code); typedef NotificationReminderChangedCallback = void Function( NotificationReminderOption option, ); + +typedef TestingDateOverrideChangedCallback = Future Function( + DateTime? value, +); diff --git a/lib/presentation/viewmodels/subscriptions_view_model.dart b/lib/presentation/viewmodels/subscriptions_view_model.dart index 847937e..7f652a3 100644 --- a/lib/presentation/viewmodels/subscriptions_view_model.dart +++ b/lib/presentation/viewmodels/subscriptions_view_model.dart @@ -52,6 +52,7 @@ class SubscriptionsViewModel extends ChangeNotifier { required bool notificationsEnabled, required NotificationReminderOption notificationReminderOption, required Locale? initialLocale, + DateTime Function()? nowProvider, }) : _watchSubscriptionsUseCase = watchSubscriptionsUseCase, _addSubscriptionUseCase = addSubscriptionUseCase, _updateSubscriptionUseCase = updateSubscriptionUseCase, @@ -69,7 +70,8 @@ class SubscriptionsViewModel extends ChangeNotifier { _scheduleNotificationsUseCase = scheduleNotificationsUseCase, _notificationsEnabled = notificationsEnabled, _notificationReminderOption = notificationReminderOption, - _locale = initialLocale { + _locale = initialLocale, + _nowProvider = nowProvider ?? DateTime.now { _baseCurrencyCode = initialBaseCurrencyCode?.toUpperCase(); _autoDownloadEnabled = initialAutoDownloadEnabled; _listenToSubscriptions(); @@ -93,6 +95,7 @@ class SubscriptionsViewModel extends ChangeNotifier { final GetPendingNotificationsUseCase _getPendingNotificationsUseCase; final CancelNotificationsUseCase _cancelNotificationsUseCase; final ScheduleNotificationsUseCase _scheduleNotificationsUseCase; + final DateTime Function() _nowProvider; bool _notificationsEnabled; NotificationReminderOption _notificationReminderOption; @@ -305,7 +308,7 @@ class SubscriptionsViewModel extends ChangeNotifier { latestUpdate = entry.fetchedAt; } } - final nowUtc = DateTime.now().toUtc(); + final nowUtc = _nowProvider().toUtc(); final needsRefresh = latestUpdate == null || nowUtc.difference(latestUpdate.toUtc()) >= const Duration(days: 1); @@ -408,7 +411,7 @@ class SubscriptionsViewModel extends ChangeNotifier { return; } final localizations = AppLocalizations(resolvedLocale); - final planner = SubscriptionNotificationPlanner(); + final planner = SubscriptionNotificationPlanner(now: _nowProvider()); final planned = planner.plan( subscriptions: _subscriptions, reminderOption: _notificationReminderOption, @@ -456,6 +459,14 @@ class SubscriptionsViewModel extends ChangeNotifier { } } + Future refreshForTestingDateChange() async { + await _refreshOverdueNextPayments(_subscriptions); + if (_autoDownloadEnabled) { + await _refreshCurrencyRatesForSubscriptions(); + } + await _syncNotifications(); + } + void _log(String message, {Object? error, StackTrace? stackTrace}) { if (!kDebugMode) return; debugPrint('[SubscriptionsViewModel] $message'); diff --git a/lib/presentation/widgets/add_subscription_sheet.dart b/lib/presentation/widgets/add_subscription_sheet.dart index 26bb131..3df95cb 100644 --- a/lib/presentation/widgets/add_subscription_sheet.dart +++ b/lib/presentation/widgets/add_subscription_sheet.dart @@ -15,12 +15,14 @@ class AddSubscriptionSheet extends StatefulWidget { this.defaultCurrencyCode, required this.tags, this.initialSubscription, + required this.nowProvider, }); final List currencies; final String? defaultCurrencyCode; final List tags; final Subscription? initialSubscription; + final DateTime Function() nowProvider; @override State createState() => _AddSubscriptionSheetState(); @@ -64,7 +66,7 @@ class _AddSubscriptionSheetState extends State { void initState() { super.initState(); final initial = widget.initialSubscription; - _purchaseDate = initial?.purchaseDate ?? DateTime.now(); + _purchaseDate = initial?.purchaseDate ?? widget.nowProvider(); _cycle = initial?.cycle ?? BillingCycle.monthly; _currencyCode = widget.defaultCurrencyCode?.toUpperCase() ?? @@ -112,7 +114,8 @@ class _AddSubscriptionSheetState extends State { Currency? get _selectedCurrency => _currencyMap[_currencyCode]; - DateTime? get _nextPaymentDate => _cycle.nextPaymentDate(_purchaseDate); + DateTime? get _nextPaymentDate => + _cycle.nextPaymentDate(_purchaseDate, widget.nowProvider()); double _parseAmount(String text) { final normalized = text.replaceAll(',', '.'); @@ -189,8 +192,8 @@ class _AddSubscriptionSheetState extends State { child: CupertinoDatePicker( mode: CupertinoDatePickerMode.date, initialDateTime: tempDate, - minimumDate: DateTime(DateTime.now().year - 10), - maximumDate: DateTime(DateTime.now().year + 5), + minimumDate: DateTime(widget.nowProvider().year - 10), + maximumDate: DateTime(widget.nowProvider().year + 5), onDateTimeChanged: (value) => tempDate = value, ), ), @@ -233,7 +236,7 @@ class _AddSubscriptionSheetState extends State { return _purchaseDate; } if (initial.isActive != _isActive) { - return DateTime.now(); + return widget.nowProvider(); } return initial.statusChangedAt; }(); diff --git a/lib/presentation/widgets/settings_sheet.dart b/lib/presentation/widgets/settings_sheet.dart index 0d93ebd..66e07a0 100644 --- a/lib/presentation/widgets/settings_sheet.dart +++ b/lib/presentation/widgets/settings_sheet.dart @@ -1,10 +1,10 @@ import 'package:flutter/cupertino.dart'; import 'package:subctrl/application/app_dependencies.dart'; +import 'package:subctrl/domain/entities/notification_reminder_option.dart'; import 'package:subctrl/presentation/screens/settings_screen.dart'; import 'package:subctrl/presentation/theme/app_theme.dart'; import 'package:subctrl/presentation/theme/theme_preference.dart'; -import 'package:subctrl/domain/entities/notification_reminder_option.dart'; import 'package:subctrl/presentation/types/settings_callbacks.dart'; class SettingsSheet extends StatelessWidget { @@ -23,6 +23,9 @@ class SettingsSheet extends StatelessWidget { required this.onNotificationsPreferenceChanged, required this.notificationReminderOption, required this.onNotificationReminderChanged, + required this.testingDateOverride, + required this.onTestingDateOverrideChanged, + required this.nowProvider, }); final AppDependencies dependencies; @@ -38,6 +41,9 @@ class SettingsSheet extends StatelessWidget { final ValueChanged onNotificationsPreferenceChanged; final NotificationReminderOption notificationReminderOption; final NotificationReminderChangedCallback onNotificationReminderChanged; + final DateTime? testingDateOverride; + final TestingDateOverrideChangedCallback onTestingDateOverrideChanged; + final DateTime Function() nowProvider; @override Widget build(BuildContext context) { @@ -99,6 +105,10 @@ class SettingsSheet extends StatelessWidget { notificationReminderOption, onNotificationReminderChanged: onNotificationReminderChanged, + testingDateOverride: testingDateOverride, + onTestingDateOverrideChanged: + onTestingDateOverrideChanged, + nowProvider: nowProvider, onRequestClose: () => Navigator.of(context).maybePop(), ), diff --git a/test/application/app_clock_test.dart b/test/application/app_clock_test.dart new file mode 100644 index 0000000..f43c72d --- /dev/null +++ b/test/application/app_clock_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:subctrl/application/app_clock.dart'; + +void main() { + test('returns override date when set', () { + final clock = AppClock(); + final override = DateTime(2024, 3, 14, 10, 30); + + clock.setOverrideDate(override); + + final now = clock.now(); + expect(now.year, override.year); + expect(now.month, override.month); + expect(now.day, override.day); + }); + + test('strips time when setting override date', () { + final clock = AppClock(); + final override = DateTime(2024, 3, 14, 10, 30); + + clock.setOverrideDate(override); + + final stored = clock.overrideDate; + expect(stored, isNotNull); + expect(stored!.hour, 0); + expect(stored.minute, 0); + expect(stored.second, 0); + }); + + test('clears override date', () { + final clock = AppClock(); + clock.setOverrideDate(DateTime(2024, 3, 14)); + + clock.setOverrideDate(null); + + expect(clock.overrideDate, isNull); + }); +} diff --git a/test/application/settings/testing_date_override_use_cases_test.dart b/test/application/settings/testing_date_override_use_cases_test.dart new file mode 100644 index 0000000..0d03480 --- /dev/null +++ b/test/application/settings/testing_date_override_use_cases_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:subctrl/application/settings/get_testing_date_override_use_case.dart'; +import 'package:subctrl/application/settings/set_testing_date_override_use_case.dart'; +import 'package:subctrl/domain/repositories/settings_repository.dart'; + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +void main() { + late SettingsRepository repository; + + setUp(() { + repository = _MockSettingsRepository(); + }); + + test('get testing date override returns repository value', () async { + final value = DateTime(2024, 1, 2); + when(() => repository.getTestingDateOverride()) + .thenAnswer((_) async => value); + + final useCase = GetTestingDateOverrideUseCase(repository); + + expect(await useCase(), value); + verify(() => repository.getTestingDateOverride()).called(1); + }); + + test('set testing date override forwards value', () async { + final value = DateTime(2024, 1, 2); + when(() => repository.setTestingDateOverride(value)) + .thenAnswer((_) async {}); + + final useCase = SetTestingDateOverrideUseCase(repository); + + await useCase(value); + + verify(() => repository.setTestingDateOverride(value)).called(1); + }); +} diff --git a/test/presentation/l10n/app_localizations_test.dart b/test/presentation/l10n/app_localizations_test.dart index 6be2d59..f02bef4 100644 --- a/test/presentation/l10n/app_localizations_test.dart +++ b/test/presentation/l10n/app_localizations_test.dart @@ -89,6 +89,10 @@ void main() { localizations.notificationReminderTitle, localizations.settingsCurrenciesSection, localizations.settingsCurrenciesManage, + localizations.settingsTestingSection, + localizations.settingsTestingDateLabel, + localizations.settingsTestingDateSystem, + localizations.settingsTestingDateReset, localizations.settingsTagsSection, localizations.settingsTagsManage, localizations.settingsTagsTitle,