Skip to content
This repository has been archived by the owner on Feb 9, 2024. It is now read-only.

Commit

Permalink
Separate business logic from UI widgets
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bisgardo committed Oct 26, 2023
1 parent 1a3e361 commit 057f9b7
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 185 deletions.
64 changes: 55 additions & 9 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
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';

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});

Expand All @@ -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 state components 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<ServiceRepository>().sharedPreferences;
return TermsAndConditionAcceptance(prefs);
},
),
],
child: MaterialApp(
routes: appRoutes,
theme: concordiumTheme(),
),
),
);
},
Expand Down
119 changes: 82 additions & 37 deletions lib/screens/home/screen.dart
Original file line number Diff line number Diff line change
@@ -1,54 +1,99 @@
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<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();

// Temporary...
final state = context.watch<AppState>();
var lastAcceptedVersion = state.sharedPreferences.termsAndConditionsAcceptedVersion;
// print('lastAcceptedVersion: $lastAcceptedVersion');
// Fetch currently valid T&C version unconditionally when initializing the widget.
final tacAcceptance = context.read<TermsAndConditionAcceptance>();
final network = context.read<SelectedNetwork>();
final services = context.read<ServiceRepository>().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<void> _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<TermsAndConditionAcceptance>();
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<TermsAndConditionAcceptance>().testResetValidTime(),
child: const Text('Reset update time of valid T&C'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => context.read<TermsAndConditionAcceptance>().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<TermsAndConditionAcceptance>().userAccepted(tac);
},
child: const Text('Set accepted T&C version to 1.2.3'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => context.read<TermsAndConditionAcceptance>().resetAccepted(),
child: const Text('Reset accepted T&C'),
),
],
);
},
),
),
);
Expand Down
58 changes: 0 additions & 58 deletions lib/screens/terms_and_conditions/refresh_screen.dart

This file was deleted.

36 changes: 14 additions & 22 deletions lib/screens/terms_and_conditions/screen.dart
Original file line number Diff line number Diff line change
@@ -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<AppState>();
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<TermsAndConditionsScreen> createState() => _TermsAndConditionsScreenState();
Expand Down Expand Up @@ -89,24 +76,24 @@ class _TermsAndConditionsScreenState extends State<TermsAndConditionsScreen> {
Flexible(
child: GestureDetector(
onTap: () {
_launchUrl(widget.viewModel.currentTac.url);
_launchUrl(widget.validTermsAndConditions.url);
},
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodySmall,
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,
),
},
Expand Down Expand Up @@ -134,7 +121,12 @@ class _TermsAndConditionsScreenState extends State<TermsAndConditionsScreen> {

Function()? _onAcceptButtonPressed(BuildContext context) {
if (isAccepted) {
return () => widget.viewModel.userAccepted(context);
return () {
final tac = AcceptedTermsAndConditions(
version: widget.validTermsAndConditions.version,
);
context.read<TermsAndConditionAcceptance>().userAccepted(tac);
};
}
return null;
}
Expand Down
4 changes: 3 additions & 1 deletion lib/screens/terms_and_conditions/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ class ToggleAcceptedWidget extends StatelessWidget {

ToggleAcceptedWidget({super.key, required this.isAccepted, required this.setAccepted});

final MaterialStateProperty<Icon?> icon = MaterialStateProperty.resolveWith<Icon?>((states) => Icon(_iconData(states)));
final MaterialStateProperty<Icon?> icon = MaterialStateProperty.resolveWith<Icon?>(
(states) => Icon(_iconData(states)),
);

static IconData _iconData(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
Expand Down
3 changes: 2 additions & 1 deletion lib/services/http.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import 'package:http/http.dart' as http;

class HttpService {
HttpService();
const HttpService();

Future<http.Response> get(Uri url) async {
// TODO: Implement retry logic.
return http.get(url);
}
}
Loading

0 comments on commit 057f9b7

Please sign in to comment.