From e0453f30f85a41b29690f6bd14053f2e9b7d0398 Mon Sep 17 00:00:00 2001 From: Michael Bisgaard Olesen Date: Thu, 26 Oct 2023 21:50:54 +0200 Subject: [PATCH] Separate business logic from UI widgets Ensure that all business logic is performed in "pure" non-widget code. This improves code structure, testability etc. The widget `RefreshTermsAndConditionsScreen` is entirely removed as refresh logic doesn't belong in the view layer. All global (shared preferences) and network-specific (wallet-proxy) services are exposed as a "service repository" provided to the root of the widget tree along with separate watchable state components ("change notifiers") for selected network and T&C acceptance. The file state.dart is split into one file in `state/` for each of these state components. The currently valid T&C version is fetched when initializing the "home" screen. The time-based logic of reloading this version is removed as it isn't clear how it actually should be implemented. It probably should be only when we're opening/waking up the app. --- lib/main.dart | 64 ++++++++-- lib/screens/home/screen.dart | 120 ++++++++++++------ .../terms_and_conditions/refresh_screen.dart | 58 --------- lib/screens/terms_and_conditions/screen.dart | 36 ++---- lib/screens/terms_and_conditions/widget.dart | 4 +- lib/services/http.dart | 3 +- lib/services/shared_preferences/service.dart | 19 +++ lib/services/wallet_proxy/model.dart | 3 +- lib/services/wallet_proxy/service.dart | 7 +- lib/state.dart | 53 -------- lib/state/config.dart | 12 ++ lib/state/network.dart | 29 +++++ lib/state/services.dart | 26 ++++ lib/state/terms_and_conditions.dart | 73 +++++++++++ 14 files changed, 322 insertions(+), 185 deletions(-) delete mode 100644 lib/screens/terms_and_conditions/refresh_screen.dart create mode 100644 lib/services/shared_preferences/service.dart delete mode 100644 lib/state.dart create mode 100644 lib/state/config.dart create mode 100644 lib/state/network.dart create mode 100644 lib/state/services.dart create mode 100644 lib/state/terms_and_conditions.dart diff --git a/lib/main.dart b/lib/main.dart index 0bafb78..0dc1747 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,13 @@ -import 'package:concordium_wallet/state.dart'; +import 'package:concordium_wallet/screens/routes.dart'; +import 'package:concordium_wallet/services/http.dart'; +import 'package:concordium_wallet/services/shared_preferences/service.dart'; +import 'package:concordium_wallet/services/wallet_proxy/service.dart'; +import 'package:concordium_wallet/state/config.dart'; +import 'package:concordium_wallet/state/network.dart'; +import 'package:concordium_wallet/state/services.dart'; +import 'package:concordium_wallet/state/terms_and_conditions.dart'; import 'package:concordium_wallet/theme.dart'; import 'package:flutter/material.dart'; - -import 'package:concordium_wallet/screens/routes.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -10,6 +15,16 @@ void main() { runApp(const App()); } +final config = Config.ofNetworks([ + const Network( + name: NetworkName.testnet, + walletProxyConfig: WalletProxyConfig( + baseUrl: 'https://wallet-proxy.testnet.concordium.com', + ), + ), +]); +const httpService = HttpService(); + class App extends StatelessWidget { const App({super.key}); @@ -21,13 +36,44 @@ class App extends StatelessWidget { final prefs = snapshot.data; if (prefs == null) { // Loading preferences. - return const CircularProgressIndicator(); + return const Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + // Setting text direction is required because we're outside 'MaterialApp' widget. + Text('Loading shared preferences...', textDirection: TextDirection.ltr), + ], + ); } - return ChangeNotifierProvider( - create: (context) => AppState(AppSharedPreferences(prefs)), - child: MaterialApp( - routes: appRoutes, - theme: concordiumTheme(), + // Initialize services and provide them to the nested components + // (including the blocs created in the child provider). + return Provider( + create: (context) { + final testnet = config.availableNetworks[NetworkName.testnet]!; + final prefsSvc = SharedPreferencesService(prefs); + return ServiceRepository( + networkServices: {testnet: NetworkServices.forNetwork(testnet, httpService: httpService)}, + sharedPreferences: prefsSvc, + ); + }, + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => SelectedNetwork(config.availableNetworks[NetworkName.testnet]!), + ), + ChangeNotifierProvider( + create: (context) { + final prefs = context.read().sharedPreferences; + return TermsAndConditionAcceptance(prefs); + }, + ), + ], + child: MaterialApp( + routes: appRoutes, + theme: concordiumTheme(), + ), ), ); }, diff --git a/lib/screens/home/screen.dart b/lib/screens/home/screen.dart index 59d372e..5b13e02 100644 --- a/lib/screens/home/screen.dart +++ b/lib/screens/home/screen.dart @@ -1,54 +1,100 @@ -import 'package:concordium_wallet/screens/terms_and_conditions/refresh_screen.dart'; -import 'package:concordium_wallet/state.dart'; +import 'package:concordium_wallet/screens/terms_and_conditions/screen.dart'; +import 'package:concordium_wallet/services/wallet_proxy/service.dart'; +import 'package:concordium_wallet/state/network.dart'; +import 'package:concordium_wallet/state/services.dart'; +import 'package:concordium_wallet/state/terms_and_conditions.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { - final tacLastCheckedAt = context.select((AppState state) => state.termsAndConditionsLastVerifiedAt); - //print('tacLastCheckedAt: $tacLastCheckedAt'); + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); - // Temporary... - final state = context.watch(); - var lastAcceptedVersion = state.sharedPreferences.termsAndConditionsAcceptedVersion; - // print('lastAcceptedVersion: $lastAcceptedVersion'); + // Fetch currently valid T&C version unconditionally when initializing the widget. + // (now and on other appropriate triggers like activating the app). + final tacAcceptance = context.read(); + final network = context.read(); + final services = context.read().networkServices[network.selected]!; + _updateValidTac(services.walletProxy, tacAcceptance); + } - // Force recheck after 1 min. - // print('diff: ${DateTime.now().difference(tacLastCheckedAt).inMinutes}'); - if (DateTime.now().difference(tacLastCheckedAt).inMinutes > 1) { - return const RefreshTermsAndConditionsScreen(); - } + static Future _updateValidTac(WalletProxyService walletProxy, TermsAndConditionAcceptance tacAcceptance) async { + final tac = await walletProxy.getTermsAndConditions(); + tacAcceptance.validVersionUpdated(ValidTermsAndConditions.refreshedNow(termsAndConditions: tac)); + } + @override + Widget build(BuildContext context) { return Scaffold( body: Container( padding: const EdgeInsets.fromLTRB(16, 64, 16, 16), - child: Column( - children: [ - Expanded( - child: Column( + child: Builder( + builder: (context) { + final tacState = context.watch(); + final validTac = tacState.valid; + if (validTac == null) { + // Show spinner if no valid T&C have been resolved yet (not as a result of actually ongoing fetch). + // Should store the future from '_updateValidTac' and use that in a wrapping 'FutureBuilder'..? + return const Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('T&C last checked at $tacLastCheckedAt.'), - Text('Last T&C version accepted: $lastAcceptedVersion'), + CircularProgressIndicator(), + SizedBox(height: 16), + Center(child: Text('Waiting for enforced Terms & Conditions...')), ], - ), - ), - ElevatedButton( - onPressed: () { - state.setTermsAndConditionsLastVerifiedAt(DateTime.fromMillisecondsSinceEpoch(0)); - }, - child: const Text('Reset check time'), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () { - state.sharedPreferences.setTermsAndConditionsAcceptedVersion('0'); - }, - child: const Text('Reset accepted version'), - ), - ], + ); + } + final acceptedTac = tacState.accepted; + if (acceptedTac == null || !acceptedTac.isValid(validTac.termsAndConditions)) { + return TermsAndConditionsScreen( + validTermsAndConditions: validTac.termsAndConditions, + acceptedTermsAndConditionsVersion: acceptedTac?.version, + ); + } + return Column( + children: [ + Expanded( + child: Column( + children: [ + Text('Accepted T&C version: ${tacState.accepted?.version}'), + Text('Valid T&C last refreshed at ${tacState.valid?.refreshedAt}.'), + ], + ), + ), + ElevatedButton( + onPressed: () => context.read().testResetValidTime(), + child: const Text('Reset update time of valid T&C'), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => context.read().resetValid(), + // NOTE: This breaks the app because no re-fetch is triggered - hot restart is necessary to recover. + child: const Text('Reset valid T&C (and break the app)'), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + const tac = AcceptedTermsAndConditions(version: '1.2.3'); + context.read().userAccepted(tac); + }, + child: const Text('Set accepted T&C version to 1.2.3'), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => context.read().resetAccepted(), + child: const Text('Reset accepted T&C'), + ), + ], + ); + }, ), ), ); diff --git a/lib/screens/terms_and_conditions/refresh_screen.dart b/lib/screens/terms_and_conditions/refresh_screen.dart deleted file mode 100644 index fbab055..0000000 --- a/lib/screens/terms_and_conditions/refresh_screen.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:concordium_wallet/screens/terms_and_conditions/screen.dart'; -import 'package:concordium_wallet/services/wallet_proxy/model.dart'; -import 'package:concordium_wallet/state.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class RefreshTermsAndConditionsScreen extends StatelessWidget { - const RefreshTermsAndConditionsScreen({super.key}); - - void _markCheckPerformed(BuildContext context) { - // Updating timestamp of checking T&C. - context.read().setTermsAndConditionsLastVerifiedAt(DateTime.now()); - } - - @override - Widget build(BuildContext context) { - final state = context.watch(); - final acceptedTacVersion = state.sharedPreferences.termsAndConditionsAcceptedVersion; - - return Scaffold( - body: Container( - padding: const EdgeInsets.fromLTRB(16, 64, 16, 16), - child: FutureBuilder( - future: state.walletProxyService.getTac(), - builder: (context, snapshot) { - final err = snapshot.error; - if (err != null) { - // TODO What to do here? - return Text('Cannot fetch terms and conditions: $err.'); - } - final currentTac = snapshot.data; - // print('currentTac.version=${currentTac?.version}, acceptedTacVersion=$acceptedTacVersion'); - if (currentTac != null) { - // TODO It feels wrong to have this business logic in the widget builder. - if (currentTac.version == acceptedTacVersion) { - // print('already accepted; dismissing'); - // Current T&C is already accepted; update the T&C check time to make the home screen replace this widget. - Future.microtask(() => _markCheckPerformed(context)); - } else { - return TermsAndConditionsScreen( - TermsAndConditionsViewModel( - currentTac, - acceptedTacVersion, - _markCheckPerformed, - ), - ); - } - } - // Loading current T&C version. - return const Center( - child: CircularProgressIndicator(), - ); - }, - ), - ), - ); - } -} diff --git a/lib/screens/terms_and_conditions/screen.dart b/lib/screens/terms_and_conditions/screen.dart index 199e636..5db1a74 100644 --- a/lib/screens/terms_and_conditions/screen.dart +++ b/lib/screens/terms_and_conditions/screen.dart @@ -1,29 +1,16 @@ import 'package:concordium_wallet/screens/terms_and_conditions/widget.dart'; import 'package:concordium_wallet/services/wallet_proxy/model.dart'; -import 'package:concordium_wallet/state.dart'; +import 'package:concordium_wallet/state/terms_and_conditions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; -class TermsAndConditionsViewModel { - final TermsAndConditions currentTac; - final String? acceptedTacVersion; - final void Function(BuildContext context) onAccept; - - const TermsAndConditionsViewModel(this.currentTac, this.acceptedTacVersion, this.onAccept); - - void userAccepted(BuildContext context) { - final state = context.read(); - state.sharedPreferences.setTermsAndConditionsAcceptedVersion(currentTac.version); - onAccept(context); - } -} - class TermsAndConditionsScreen extends StatefulWidget { - final TermsAndConditionsViewModel viewModel; + final TermsAndConditions validTermsAndConditions; + final String? acceptedTermsAndConditionsVersion; - const TermsAndConditionsScreen(this.viewModel, {super.key}); + const TermsAndConditionsScreen({super.key, required this.validTermsAndConditions, this.acceptedTermsAndConditionsVersion}); @override State createState() => _TermsAndConditionsScreenState(); @@ -89,7 +76,7 @@ class _TermsAndConditionsScreenState extends State { Flexible( child: GestureDetector( onTap: () { - _launchUrl(widget.viewModel.currentTac.url); + _launchUrl(widget.validTermsAndConditions.url); }, child: RichText( text: TextSpan( @@ -97,16 +84,16 @@ class _TermsAndConditionsScreenState extends State { children: [ const TextSpan(text: 'I have read and agree to the '), TextSpan( - text: 'Terms and Conditions v${widget.viewModel.currentTac.version}', + text: 'Terms and Conditions v${widget.validTermsAndConditions.version}', style: const TextStyle( color: Colors.indigo, fontWeight: FontWeight.bold, ), ), - switch (widget.viewModel.acceptedTacVersion) { + switch (widget.acceptedTermsAndConditionsVersion) { null => const TextSpan(text: '.'), String v => TextSpan( - text: ' (you previously accepted version $v).', + text: ' (you previously accepted v$v).', style: Theme.of(context).textTheme.bodySmall, ), }, @@ -134,7 +121,12 @@ class _TermsAndConditionsScreenState extends State { Function()? _onAcceptButtonPressed(BuildContext context) { if (isAccepted) { - return () => widget.viewModel.userAccepted(context); + return () { + final tac = AcceptedTermsAndConditions( + version: widget.validTermsAndConditions.version, + ); + context.read().userAccepted(tac); + }; } return null; } diff --git a/lib/screens/terms_and_conditions/widget.dart b/lib/screens/terms_and_conditions/widget.dart index 9735d1c..50600fb 100644 --- a/lib/screens/terms_and_conditions/widget.dart +++ b/lib/screens/terms_and_conditions/widget.dart @@ -6,7 +6,9 @@ class ToggleAcceptedWidget extends StatelessWidget { ToggleAcceptedWidget({super.key, required this.isAccepted, required this.setAccepted}); - final MaterialStateProperty icon = MaterialStateProperty.resolveWith((states) => Icon(_iconData(states))); + final MaterialStateProperty icon = MaterialStateProperty.resolveWith( + (states) => Icon(_iconData(states)), + ); static IconData _iconData(Set states) { if (states.contains(MaterialState.selected)) { diff --git a/lib/services/http.dart b/lib/services/http.dart index 0f6cb58..1ae5df2 100644 --- a/lib/services/http.dart +++ b/lib/services/http.dart @@ -1,9 +1,10 @@ import 'package:http/http.dart' as http; class HttpService { - HttpService(); + const HttpService(); Future get(Uri url) async { + // TODO: Implement retry logic. return http.get(url); } } diff --git a/lib/services/shared_preferences/service.dart b/lib/services/shared_preferences/service.dart new file mode 100644 index 0000000..1d49889 --- /dev/null +++ b/lib/services/shared_preferences/service.dart @@ -0,0 +1,19 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SharedPreferencesService { + static const _tacAcceptedVersionKey = 'tac:accepted_version'; + + final SharedPreferences _prefs; + + const SharedPreferencesService(this._prefs); + + String? get termsAndConditionsAcceptedVersion => _prefs.getString(_tacAcceptedVersionKey); + + void setTermsAndConditionsAcceptedVersion(String? v) { + if (v == null) { + _prefs.remove(_tacAcceptedVersionKey); + } else { + _prefs.setString(_tacAcceptedVersionKey, v); + } + } +} diff --git a/lib/services/wallet_proxy/model.dart b/lib/services/wallet_proxy/model.dart index 9473ee8..6f89398 100644 --- a/lib/services/wallet_proxy/model.dart +++ b/lib/services/wallet_proxy/model.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; + part 'model.g.dart'; @JsonSerializable() @@ -6,7 +7,7 @@ class TermsAndConditions { final Uri url; final String version; - TermsAndConditions(this.url, this.version); + const TermsAndConditions(this.url, this.version); factory TermsAndConditions.fromJson(Map json) => _$TermsAndConditionsFromJson(json); diff --git a/lib/services/wallet_proxy/service.dart b/lib/services/wallet_proxy/service.dart index 078a6fa..7313ae6 100644 --- a/lib/services/wallet_proxy/service.dart +++ b/lib/services/wallet_proxy/service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import 'package:concordium_wallet/services/http.dart'; import 'package:concordium_wallet/services/wallet_proxy/model.dart'; @@ -14,7 +15,7 @@ enum WalletProxyEndpoint { class WalletProxyConfig { final String baseUrl; - WalletProxyConfig({required this.baseUrl}); + const WalletProxyConfig({required this.baseUrl}); Uri urlOf(WalletProxyEndpoint e) { // We're not worrying about URL encoding of the path @@ -27,10 +28,10 @@ class WalletProxyService { final WalletProxyConfig config; final HttpService httpService; - WalletProxyService({required this.config, required this.httpService}); + const WalletProxyService({required this.config, required this.httpService}); /// Retrieves the terms and conditions from the wallet-proxy. - Future getTac() async { + Future getTermsAndConditions() async { final url = config.urlOf(WalletProxyEndpoint.tacVersion); final response = await httpService.get(url); final jsonResponse = jsonDecode(response.body); diff --git a/lib/state.dart b/lib/state.dart deleted file mode 100644 index e8a6589..0000000 --- a/lib/state.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:concordium_wallet/services/http.dart'; -import 'package:concordium_wallet/services/wallet_proxy/service.dart'; -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class Network { - final WalletProxyConfig walletProxyConfig; - - Network({required this.walletProxyConfig}); -} - -final testnet = Network( - walletProxyConfig: WalletProxyConfig( - baseUrl: 'https://wallet-proxy.testnet.concordium.com', - ), -); - -// TODO: Extend ChangeNotifier? -class AppSharedPreferences { - static const _tacAcceptedVersionKey = 'tac:accepted_version'; - - final SharedPreferences _prefs; - - AppSharedPreferences(this._prefs); - - get termsAndConditionsAcceptedVersion => _prefs.getString(_tacAcceptedVersionKey); - - void setTermsAndConditionsAcceptedVersion(String v) { - _prefs.setString(_tacAcceptedVersionKey, v); - } -} - -class AppState extends ChangeNotifier { - final network = testnet; - final walletProxyService = WalletProxyService( - config: testnet.walletProxyConfig, - httpService: HttpService(), - ); - - var _termsAndConditionsLastVerifiedAt = DateTime.fromMicrosecondsSinceEpoch(0); // force recheck when starting app - - /// The most recent time it was ensured that the currently valid T&C has been accepted. - DateTime get termsAndConditionsLastVerifiedAt => _termsAndConditionsLastVerifiedAt; - - void setTermsAndConditionsLastVerifiedAt(DateTime v) { - _termsAndConditionsLastVerifiedAt = v; - notifyListeners(); - } - - final AppSharedPreferences sharedPreferences; - - AppState(this.sharedPreferences); -} diff --git a/lib/state/config.dart b/lib/state/config.dart new file mode 100644 index 0000000..de75cdc --- /dev/null +++ b/lib/state/config.dart @@ -0,0 +1,12 @@ +import 'package:concordium_wallet/state/network.dart'; + +class Config { + /// All available networks in the app. + final Map availableNetworks; + + const Config({required this.availableNetworks}); + + factory Config.ofNetworks(List networks) { + return Config(availableNetworks: {for (final n in networks) n.name: n}); + } +} diff --git a/lib/state/network.dart b/lib/state/network.dart new file mode 100644 index 0000000..3d5cf16 --- /dev/null +++ b/lib/state/network.dart @@ -0,0 +1,29 @@ +import 'package:concordium_wallet/services/wallet_proxy/service.dart'; +import 'package:flutter/foundation.dart'; + +class NetworkName { + final String name; + + const NetworkName(this.name); + + static const NetworkName testnet = NetworkName('testnet'); + static const NetworkName mainnet = NetworkName('mainnet'); +} + +class Network { + final NetworkName name; + final WalletProxyConfig walletProxyConfig; + + const Network({required this.name, required this.walletProxyConfig}); +} + +class SelectedNetwork extends ChangeNotifier { + Network selected; + + SelectedNetwork(this.selected); + + void setSelected(Network n) { + selected = n; + notifyListeners(); + } +} diff --git a/lib/state/services.dart b/lib/state/services.dart new file mode 100644 index 0000000..4235dd6 --- /dev/null +++ b/lib/state/services.dart @@ -0,0 +1,26 @@ +import 'package:concordium_wallet/services/http.dart'; +import 'package:concordium_wallet/services/shared_preferences/service.dart'; +import 'package:concordium_wallet/services/wallet_proxy/service.dart'; +import 'package:concordium_wallet/state/network.dart'; + +class NetworkServices { + final WalletProxyService walletProxy; + + const NetworkServices({required this.walletProxy}); + + factory NetworkServices.forNetwork(Network n, {required HttpService httpService}) { + return NetworkServices( + walletProxy: WalletProxyService( + config: n.walletProxyConfig, + httpService: httpService, + ), + ); + } +} + +class ServiceRepository { + final Map networkServices; + final SharedPreferencesService sharedPreferences; + + const ServiceRepository({required this.networkServices, required this.sharedPreferences}); +} diff --git a/lib/state/terms_and_conditions.dart b/lib/state/terms_and_conditions.dart new file mode 100644 index 0000000..59482f2 --- /dev/null +++ b/lib/state/terms_and_conditions.dart @@ -0,0 +1,73 @@ +import 'package:concordium_wallet/services/shared_preferences/service.dart'; +import 'package:concordium_wallet/services/wallet_proxy/model.dart'; +import 'package:flutter/foundation.dart'; + +class AcceptedTermsAndConditions { + final String version; + + const AcceptedTermsAndConditions({required this.version}); + + bool isValid(TermsAndConditions tac) { + return version == tac.version; + } +} + +class ValidTermsAndConditions { + final TermsAndConditions termsAndConditions; + final DateTime? refreshedAt; + + const ValidTermsAndConditions({required this.termsAndConditions, required this.refreshedAt}); + + factory ValidTermsAndConditions.refreshedNow({required TermsAndConditions termsAndConditions}) { + return ValidTermsAndConditions(termsAndConditions: termsAndConditions, refreshedAt: DateTime.now()); + } +} + +class TermsAndConditionAcceptance extends ChangeNotifier { + final SharedPreferencesService _prefs; + + AcceptedTermsAndConditions? accepted; + ValidTermsAndConditions? valid; + + TermsAndConditionAcceptance(this._prefs) { + final acceptedVersion = _prefs.termsAndConditionsAcceptedVersion; + if (acceptedVersion != null) { + userAccepted(AcceptedTermsAndConditions(version: acceptedVersion)); + } + } + + void userAccepted(AcceptedTermsAndConditions tac) { + accepted = tac; + _prefs.setTermsAndConditionsAcceptedVersion(tac.version); + notifyListeners(); + } + + void validVersionUpdated(ValidTermsAndConditions tac) { + valid = tac; + notifyListeners(); + } + + void resetAccepted() { + accepted = null; + _prefs.setTermsAndConditionsAcceptedVersion(null); + notifyListeners(); + } + + void resetValid() { + valid = null; + notifyListeners(); + } + + // Temporary - for testing. + void testResetValidTime() { + final valid = this.valid; + if (valid != null) { + validVersionUpdated( + ValidTermsAndConditions( + termsAndConditions: valid.termsAndConditions, + refreshedAt: DateTime.fromMicrosecondsSinceEpoch(0), + ), + ); + } + } +}