From bca25bd3b85928e74f80972b1bc057c34691a5f8 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 14 Nov 2023 16:35:09 +0100 Subject: [PATCH] feat: Add public beta disclaimer - The wallet is initialized once the toggles are confirmed on the welcome screen. (This fixes a bug, where the user could have restarted the app to skip over the disclaimer and uses the loading screen to show that the app is doing something) - Add a custom switch component. Primarily copied from https://stackoverflow.com/a/60090216/8643355 --- mobile/lib/common/application/switch.dart | 73 ++++++ mobile/lib/common/routes.dart | 2 +- .../lib/features/welcome/loading_screen.dart | 24 +- mobile/lib/features/welcome/onboarding.dart | 41 +-- .../lib/features/welcome/welcome_screen.dart | 233 +++++++++++++----- mobile/lib/util/preferences.dart | 4 +- 6 files changed, 260 insertions(+), 117 deletions(-) create mode 100644 mobile/lib/common/application/switch.dart diff --git a/mobile/lib/common/application/switch.dart b/mobile/lib/common/application/switch.dart new file mode 100644 index 000000000..b1f12518b --- /dev/null +++ b/mobile/lib/common/application/switch.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/color.dart'; + +class TenTenOneSwitch extends StatefulWidget { + final bool value; + final ValueChanged onChanged; + + const TenTenOneSwitch({Key? key, required this.value, required this.onChanged}) : super(key: key); + + @override + State createState() => _TenTenOneSwitchState(); +} + +class _TenTenOneSwitchState extends State with SingleTickerProviderStateMixin { + Animation? _circleAnimation; + AnimationController? _animationController; + + @override + void initState() { + super.initState(); + _animationController = + AnimationController(vsync: this, duration: const Duration(milliseconds: 60)); + _circleAnimation = AlignmentTween( + begin: widget.value ? Alignment.centerLeft : Alignment.centerRight, + end: widget.value ? Alignment.centerRight : Alignment.centerLeft) + .animate(CurvedAnimation(parent: _animationController!, curve: Curves.linear)); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController!, + builder: (context, child) { + return GestureDetector( + onTap: () { + if (_animationController!.isCompleted) { + _animationController!.reverse(); + } else { + _animationController!.forward(); + } + widget.value == false ? widget.onChanged(true) : widget.onChanged(false); + }, + child: Container( + width: 50.0, + height: 30.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24.0), + color: _circleAnimation!.value == Alignment.centerLeft + ? tenTenOnePurple.shade300 + : tenTenOnePurple.shade100), + child: Padding( + padding: const EdgeInsets.only(top: 6.0, bottom: 6.0, left: 5.0, right: 5.0), + child: Container( + alignment: widget.value + ? ((Directionality.of(context) == TextDirection.rtl) + ? Alignment.centerLeft + : Alignment.centerRight) + : ((Directionality.of(context) == TextDirection.rtl) + ? Alignment.centerRight + : Alignment.centerLeft), + child: Container( + width: 20.0, + height: 20.0, + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/common/routes.dart b/mobile/lib/common/routes.dart index 043ef5001..33463dad8 100644 --- a/mobile/lib/common/routes.dart +++ b/mobile/lib/common/routes.dart @@ -30,7 +30,7 @@ GoRouter createRoutes() { path: LoadingScreen.route, pageBuilder: (context, state) => NoTransitionPage( child: LoadingScreen( - restore: state.extra as Future?, + future: state.extra as Future?, ), ), ), diff --git a/mobile/lib/features/welcome/loading_screen.dart b/mobile/lib/features/welcome/loading_screen.dart index 70acb9712..241df867b 100644 --- a/mobile/lib/features/welcome/loading_screen.dart +++ b/mobile/lib/features/welcome/loading_screen.dart @@ -16,9 +16,9 @@ import 'package:go_router/go_router.dart'; class LoadingScreen extends StatefulWidget { static const route = "/loading"; - final Future? restore; + final Future? future; - const LoadingScreen({super.key, this.restore}); + const LoadingScreen({super.key, this.future}); @override State createState() => _LoadingScreenState(); @@ -29,18 +29,14 @@ class _LoadingScreenState extends State { @override void initState() { - List> futures = [ - Preferences.instance.getOpenPosition(), - isSeedFilePresent(), - Preferences.instance.isFullBackupRequired(), - ]; - - if (widget.restore != null) { - // wait for the restore process to finish! - futures.add(widget.restore!); - } - - Future.wait(futures).then((value) { + // Wait for the future to complete sequentially before running other futures concurrently + (widget.future ?? Future.value()).then((value) { + return Future.wait([ + Preferences.instance.getOpenPosition(), + isSeedFilePresent(), + Preferences.instance.isFullBackupRequired(), + ]); + }).then((value) { final position = value[0]; final isSeedFilePresent = value[1]; final isFullBackupRequired = value[2]; diff --git a/mobile/lib/features/welcome/onboarding.dart b/mobile/lib/features/welcome/onboarding.dart index 55293cb3d..0cfb3ee86 100644 --- a/mobile/lib/features/welcome/onboarding.dart +++ b/mobile/lib/features/welcome/onboarding.dart @@ -1,14 +1,8 @@ import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; import 'package:get_10101/common/color.dart'; -import 'package:get_10101/common/global_keys.dart'; -import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/features/welcome/seed_import_screen.dart'; import 'package:get_10101/features/welcome/welcome_screen.dart'; -import 'package:get_10101/ffi.dart'; -import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/util/file.dart'; -import 'package:get_10101/util/preferences.dart'; import 'package:go_router/go_router.dart'; final themeMode = ValueNotifier(2); @@ -71,7 +65,6 @@ class Onboarding extends StatefulWidget { class _Onboarding extends State { int _current = 0; final CarouselController _controller = CarouselController(); - bool buttonsDisabled = false; @override Widget build(BuildContext context) { @@ -137,29 +130,7 @@ class _Onboarding extends State { SizedBox( width: 250, child: ElevatedButton( - onPressed: buttonsDisabled - ? null - : () async { - setState(() { - buttonsDisabled = true; - }); - final seedPath = await getSeedFilePath(); - await api - .initNewMnemonic(targetSeedFilePath: seedPath) - .then((value) async { - Preferences.instance - .hasEmailAddress() - .then((value) => GoRouter.of(context).go(WelcomeScreen.route)); - }).catchError((error) { - logger.e("Could not create seed", error: error); - showSnackBar(ScaffoldMessenger.of(rootNavigatorKey.currentContext!), - "Failed to create seed: $error"); - // In case there was an error and we did not go forward, we want to be able to click the button again. - setState(() { - buttonsDisabled = false; - }); - }); - }, + onPressed: () => GoRouter.of(context).go(WelcomeScreen.route), style: ButtonStyle( padding: MaterialStateProperty.all(const EdgeInsets.all(15)), backgroundColor: MaterialStateProperty.all(tenTenOnePurple), @@ -183,15 +154,7 @@ class _Onboarding extends State { SizedBox( width: 250, child: TextButton( - onPressed: buttonsDisabled - ? null - : () { - setState(() { - buttonsDisabled = true; - GoRouter.of(context).go(SeedPhraseImporter.route); - buttonsDisabled = false; - }); - }, + onPressed: () => GoRouter.of(context).go(SeedPhraseImporter.route), style: ButtonStyle( padding: MaterialStateProperty.all(const EdgeInsets.all(15)), backgroundColor: MaterialStateProperty.all(Colors.white), diff --git a/mobile/lib/features/welcome/welcome_screen.dart b/mobile/lib/features/welcome/welcome_screen.dart index 72b495385..7f34ec17d 100644 --- a/mobile/lib/features/welcome/welcome_screen.dart +++ b/mobile/lib/features/welcome/welcome_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/services.dart'; +import 'package:get_10101/common/application/switch.dart'; +import 'package:get_10101/common/color.dart'; import 'package:get_10101/features/welcome/loading_screen.dart'; import 'package:get_10101/logger/logger.dart'; import 'package:flutter/material.dart'; -import 'package:get_10101/common/scrollable_safe_area.dart'; -import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/util/file.dart'; import 'package:get_10101/util/preferences.dart'; import 'package:go_router/go_router.dart'; import 'package:get_10101/ffi.dart'; @@ -22,6 +23,8 @@ class _WelcomeScreenState extends State { final GlobalKey _formKey = GlobalKey(); String _email = ""; + bool _betaDisclaimer = false; + bool _loseDisclaimer = false; /// TODO Convert to a flutter package that checks the email domain validity /// (MX record, etc.) @@ -36,75 +39,183 @@ class _WelcomeScreenState extends State { value: SystemUiOverlayStyle.dark, child: Scaffold( backgroundColor: Colors.white, - body: ScrollableSafeArea( - child: Form( - key: _formKey, - child: Container( - color: Colors.white, - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const SizedBox(height: 50), - Center( - child: Image.asset('assets/10101_logo_icon.png', width: 150, height: 150), - ), - const SizedBox(height: 50), - const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Text( - "As we are in closed beta, there may be bugs. To assist with any issues, please provide your email.", - style: TextStyle(fontSize: 16, color: Colors.black87), - )) - ], - ), - const SizedBox(height: 20), - TextFormField( - keyboardType: TextInputType.emailAddress, - initialValue: _email, - decoration: const InputDecoration( - labelText: 'Email', - hintText: 'Enter your email address to continue', + body: SafeArea( + bottom: false, + child: Container( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: 20, right: 20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text("10101", + style: TextStyle( + color: tenTenOnePurple, fontWeight: FontWeight.bold, fontSize: 40)), + Text("Beta\nDisclaimer.", + style: TextStyle( + color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 40)), + SizedBox(height: 20), + ]), + ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20), + child: Text("Important: The 10101 Wallet is still in its testing phase and is provided on an \"as is\" basis and may contain defects.\n\n".toUpperCase() + + "A primary purpose of this beta testing phase is to obtain feedback on performance and the identification of defects." + .toUpperCase() + + " Users are advised to safeguard important data, to use caution and not to rely in any way on the correct functioning or performance of the beta product." + .toUpperCase()), ), - validator: (value) { - if (value == null || value.isEmpty || !isEmailValid(value)) { - return 'Please enter a valid email address'; - } - return null; - }, - onSaved: (value) { - _email = value ?? ""; - }, ), - ElevatedButton( - onPressed: () { - if (_formKey.currentState != null && _formKey.currentState!.validate()) { - _formKey.currentState?.save(); - try { - api.registerBeta(email: _email); - Preferences.instance.setEmailAddress(_email); - logger.i("Successfully stored the email address $_email ."); - context.go(LoadingScreen.route); - } catch (e) { - showSnackBar(ScaffoldMessenger.of(context), "$e"); - } - } - }, - child: const Text( - 'Continue', - style: TextStyle(fontSize: 16), + ), + Container( + decoration: BoxDecoration( + color: tenTenOnePurple.withOpacity(0.05), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10.0), topRight: Radius.circular(10.0))), + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: tenTenOnePurple.shade300.withOpacity(0.2), + borderRadius: BorderRadius.circular(10.0)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox( + width: 250, + child: Text( + "10101 is still being tested and may contain defects.", + softWrap: true), + ), + TenTenOneSwitch( + value: _betaDisclaimer, + onChanged: (value) => setState(() => _betaDisclaimer = value)), + ], + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: tenTenOnePurple.shade300.withOpacity(0.2), + borderRadius: BorderRadius.circular(10.0)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox( + width: 250, + child: Text( + "If I lose my seed phrase and my device, my funds will be lost forever", + softWrap: true), + ), + TenTenOneSwitch( + value: _loseDisclaimer, + onChanged: (value) => setState(() => _loseDisclaimer = value)), + ], + ), + ), + const SizedBox(height: 10), + Form( + key: _formKey, + child: TextFormField( + keyboardType: TextInputType.emailAddress, + initialValue: _email, + decoration: InputDecoration( + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10.0)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10.0), + borderSide: BorderSide( + color: tenTenOnePurple.shade300.withOpacity(0.2))), + filled: true, + fillColor: tenTenOnePurple.shade300.withOpacity(0.2), + labelText: 'Email (optional)', + labelStyle: const TextStyle(color: Colors.black87, fontSize: 14), + hintText: 'Let us know how to reach you'), + validator: (value) { + if (value == null || value.isEmpty) { + return null; + } + + if (!isEmailValid(value)) { + return 'Please enter a valid email address'; + } + return null; + }, + onSaved: (value) { + _email = value ?? ""; + }, + ), + ), + const SizedBox(height: 25), + SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: ElevatedButton( + onPressed: !(_betaDisclaimer && _loseDisclaimer) + ? null + : () { + if (_formKey.currentState != null && + _formKey.currentState!.validate()) { + GoRouter.of(context) + .go(LoadingScreen.route, extra: setupWallet()); + } + }, + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.all(15)), + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return tenTenOnePurple.shade100; + } else { + return tenTenOnePurple; + } + }), + shape: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30.0), + side: BorderSide(color: tenTenOnePurple.shade100), + ); + } else { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30.0), + side: const BorderSide(color: tenTenOnePurple), + ); + } + })), + child: const Text( + "Continue", + style: TextStyle(fontSize: 18, color: Colors.white), + )), + ), + ], ), ), - ], - ), + ) + ], ), ), ), )); } + Future setupWallet() async { + var seedPath = await getSeedFilePath(); + await Preferences.instance.setEmailAddress(_email); + logger.i("Successfully stored the email address $_email ."); + await api.initNewMnemonic(targetSeedFilePath: seedPath); + await api.registerBeta(email: _email); + } + @override void initState() { super.initState(); diff --git a/mobile/lib/util/preferences.dart b/mobile/lib/util/preferences.dart index 4a7940e4f..091766b67 100644 --- a/mobile/lib/util/preferences.dart +++ b/mobile/lib/util/preferences.dart @@ -52,9 +52,9 @@ class Preferences { return preferences.getBool(userSeedBackupConfirmed) ?? false; } - setEmailAddress(String value) async { + Future setEmailAddress(String value) async { SharedPreferences preferences = await SharedPreferences.getInstance(); - preferences.setString(emailAddress, value); + return preferences.setString(emailAddress, value); } Future getEmailAddress() async {