From 867844ccc31474a466ba2ce14d896cd8d48c21be Mon Sep 17 00:00:00 2001 From: reasje Date: Thu, 5 Oct 2023 18:39:34 +0330 Subject: [PATCH] fix: Passcode UI/UX audits & Added Animations --- .../passcode_base/passcode_base_page.dart | 31 ++--- .../passcode_base_page_presenter.dart | 8 +- .../passcode_base_page_state.dart | 2 + .../widget/numbers_row_widget.dart | 119 ++++++++++++++++++ .../passcode_require_page.dart | 35 ++++-- .../passcode_require_presenter.dart | 18 ++- .../widgets/circle_animation.dart | 86 +++++++++++++ 7 files changed, 264 insertions(+), 35 deletions(-) create mode 100644 lib/features/security/presentation/passcode_base/widget/numbers_row_widget.dart create mode 100644 lib/features/security/presentation/passcode_require/widgets/circle_animation.dart diff --git a/lib/features/security/presentation/passcode_base/passcode_base_page.dart b/lib/features/security/presentation/passcode_base/passcode_base_page.dart index 4c6105b8..f2925781 100644 --- a/lib/features/security/presentation/passcode_base/passcode_base_page.dart +++ b/lib/features/security/presentation/passcode_base/passcode_base_page.dart @@ -1,6 +1,8 @@ import 'dart:developer'; import 'package:datadashwallet/app/app.dart'; +import 'package:datadashwallet/features/security/presentation/passcode_base/widget/numbers_row_widget.dart'; +import 'package:datadashwallet/features/security/presentation/passcode_require/widgets/circle_animation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; @@ -32,24 +34,10 @@ abstract class PasscodeBasePage extends HookConsumerWidget { ProviderBase get state; - Widget numbersRow(BuildContext context, WidgetRef ref) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < ref.watch(state).expectedNumbersLength; i++) ...[ - SvgPicture.asset( - 'assets/svg/security/ic_ring.svg', - height: 32, - width: 32, - colorFilter: filterFor( - ref.watch(state).enteredNumbers.length > i - ? ColorsTheme.of(context).primary60 - : ColorsTheme.of(context).iconWhite, - ), - ), - if (i != ref.watch(state).expectedNumbersLength - 1) - const SizedBox(width: 16), - ], - ], + Widget numbersRow(BuildContext context, WidgetRef ref) => NumbersRowWidget( + expectedNumbersLength: ref.watch(state).expectedNumbersLength, + enteredNumbers: ref.watch(state).enteredNumbers.length, + shakeAnimationInt: ref.read(presenter).initShakeAnimationController, ); Widget numpad( @@ -191,8 +179,11 @@ abstract class PasscodeBasePage extends HookConsumerWidget { style: FontTheme.of(context).body1.white(), ), const SizedBox(height: 64), - Center( - child: numbersRow(context, ref), + SizedBox( + height: 57.5, + child: Center( + child: numbersRow(context, ref), + ), ), ], ), diff --git a/lib/features/security/presentation/passcode_base/passcode_base_page_presenter.dart b/lib/features/security/presentation/passcode_base/passcode_base_page_presenter.dart index 80f157dc..9143fb0f 100644 --- a/lib/features/security/presentation/passcode_base/passcode_base_page_presenter.dart +++ b/lib/features/security/presentation/passcode_base/passcode_base_page_presenter.dart @@ -46,10 +46,10 @@ abstract class PasscodeBasePagePresenter } void onAddNumber(int number) async { - state.errorText = null; state.enteredNumbers.add(number); notify(); if (state.enteredNumbers.length != state.expectedNumbersLength) return; + state.errorText = null; onAllNumbersEntered(state.dismissedPage); } @@ -57,4 +57,10 @@ abstract class PasscodeBasePagePresenter if (state.enteredNumbers.isEmpty) return; notify(() => state.enteredNumbers.removeLast()); } + + void initShakeAnimationController(AnimationController animationController) { + state.shakeAnimationController = animationController; + } + + void startShakeAnimation() {} } diff --git a/lib/features/security/presentation/passcode_base/passcode_base_page_state.dart b/lib/features/security/presentation/passcode_base/passcode_base_page_state.dart index f87d4dfc..d1bdc03e 100644 --- a/lib/features/security/presentation/passcode_base/passcode_base_page_state.dart +++ b/lib/features/security/presentation/passcode_base/passcode_base_page_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; class PasscodeBasePageState with EquatableMixin { final int expectedNumbersLength = 6; @@ -6,6 +7,7 @@ class PasscodeBasePageState with EquatableMixin { String? errorText; bool isBiometricEnabled = false; bool userHasActiveFingerprints = false; + AnimationController? shakeAnimationController; String? dismissedPage; diff --git a/lib/features/security/presentation/passcode_base/widget/numbers_row_widget.dart b/lib/features/security/presentation/passcode_base/widget/numbers_row_widget.dart new file mode 100644 index 00000000..7f4c1272 --- /dev/null +++ b/lib/features/security/presentation/passcode_base/widget/numbers_row_widget.dart @@ -0,0 +1,119 @@ +import 'dart:math'; + +import 'package:datadashwallet/common/color_filter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mxc_ui/mxc_ui.dart'; + +import '../../passcode_require/widgets/circle_animation.dart'; + +class NumbersRowWidget extends StatefulWidget { + const NumbersRowWidget( + {super.key, + required this.expectedNumbersLength, + required this.enteredNumbers, + required this.shakeAnimationInt}); + + final int expectedNumbersLength; + final int enteredNumbers; + final void Function(AnimationController) shakeAnimationInt; + + @override + State createState() => _NumbersRowWidgetState(); +} + +class _NumbersRowWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animationRotation; + int shakeOffset = 10; + int shakeCount = 4; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + + final tweenSequenceList = [ + TweenSequenceItem( + tween: Tween(begin: 0, end: 2 * pi / 360), + weight: 1, + ), + for (int i = 0; i < 6; i++) + TweenSequenceItem( + tween: Tween(begin: 2 * pi / 360, end: -2 * pi / 360) + .chain(CurveTween(curve: Curves.easeInOut)), + weight: 1, + ), + TweenSequenceItem( + tween: Tween(begin: 0, end: 0), + weight: 1, + ), + ]; + + _animationRotation = TweenSequence(tweenSequenceList).animate( + CurvedAnimation(parent: _animationController, curve: Curves.linear), + ); + + _animationController.addStatusListener(_updateStatus); + + widget.shakeAnimationInt(_animationController); + } + + @override + void dispose() { + _animationController.removeStatusListener(_updateStatus); + _animationController.dispose(); + super.dispose(); + } + + void _updateStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _animationController.reset(); + } + } + + @override + Widget build(BuildContext context) { + return Expanded( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.rotate( + angle: _animationRotation.value, + child: child, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (var i = 0; i < widget.expectedNumbersLength; i++) ...[ + widget.enteredNumbers > i + ? const Expanded(child: CircleAnimation()) + : Expanded( + child: SvgPicture.asset( + 'assets/svg/security/ic_ring.svg', + height: 32, + width: 32, + colorFilter: filterFor( + ColorsTheme.of(context).iconWhite, + ), + ), + ), + // if (i != ref.watch(state).expectedNumbersLength - 1) + // const SizedBox(width: 16), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/security/presentation/passcode_require/passcode_require_page.dart b/lib/features/security/presentation/passcode_require/passcode_require_page.dart index 31152952..346c3ff8 100644 --- a/lib/features/security/presentation/passcode_require/passcode_require_page.dart +++ b/lib/features/security/presentation/passcode_require/passcode_require_page.dart @@ -68,24 +68,33 @@ class PasscodeRequirePage extends PasscodeBasePage { style: FontTheme.of(context).body1.textWhite(), textAlign: TextAlign.center, ), - const SizedBox(height: 40), - Center( - child: numbersRow(context, ref), - ), + SizedBox( + height: 84, + child: Column( + children: [ + Expanded(child: Container()), + Expanded( + flex: 2, + child: Center( + child: numbersRow(context, ref), + ), + ) + ], + )), ], ), ), - const SizedBox(height: 12), buildErrorMessage(context, ref), - Padding( - padding: const EdgeInsets.only(top: 40, left: 24, right: 24), - child: MxcButton.secondaryWhite( - key: const ValueKey('forgotPasscodeButton'), - title: FlutterI18n.translate(context, 'forgot_passcode'), - size: AxsButtonSize.xl, - onTap: () => showResetPasscodeDialog(context, ref), + if (ref.watch(state).wrongInputCounter != 0) + Padding( + padding: const EdgeInsets.only(top: 40, left: 24, right: 24), + child: MxcButton.secondaryWhite( + key: const ValueKey('forgotPasscodeButton'), + title: FlutterI18n.translate(context, 'forgot_passcode'), + size: AxsButtonSize.xl, + onTap: () => showResetPasscodeDialog(context, ref), + ), ), - ), const Spacer(), numpad(context, ref), ], diff --git a/lib/features/security/presentation/passcode_require/passcode_require_presenter.dart b/lib/features/security/presentation/passcode_require/passcode_require_presenter.dart index 28f3b731..dff24243 100644 --- a/lib/features/security/presentation/passcode_require/passcode_require_presenter.dart +++ b/lib/features/security/presentation/passcode_require/passcode_require_presenter.dart @@ -1,6 +1,7 @@ import 'package:datadashwallet/core/core.dart'; import 'package:datadashwallet/features/security/security.dart'; import 'package:datadashwallet/features/splash/splash.dart'; +import 'package:vibration/vibration.dart'; import 'passcode_require_state.dart'; import 'wrapper/passcode_require_wrapper_presenter.dart'; @@ -27,13 +28,22 @@ class PasscodeRequirePresenter }); } + @override + void startShakeAnimation() { + if (state.shakeAnimationController != null) { + state.shakeAnimationController!.forward(); + } + } + @override void onAllNumbersEntered(String? dismissedPage) async { if (state.enteredNumbers.join('') != _passcodeUseCase.passcode.value) { - if (state.wrongInputCounter < 6) { + if (state.wrongInputCounter < 5) { state.wrongInputCounter++; state.errorText = translate('attempts_x')! .replaceFirst('{0}', '${6 - state.wrongInputCounter}'); + vibrate(); + startShakeAnimation(); } else { state.errorText = null; state.wrongInputCounter = 0; @@ -64,4 +74,10 @@ class PasscodeRequirePresenter return result; } + + void vibrate() async { + if (await Vibration.hasVibrator() ?? false) { + Vibration.vibrate(duration: 400); + } + } } diff --git a/lib/features/security/presentation/passcode_require/widgets/circle_animation.dart b/lib/features/security/presentation/passcode_require/widgets/circle_animation.dart new file mode 100644 index 00000000..a6fdcc63 --- /dev/null +++ b/lib/features/security/presentation/passcode_require/widgets/circle_animation.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:mxc_ui/mxc_ui.dart'; + +class CircleAnimation extends StatefulWidget { + const CircleAnimation({super.key}); + + @override + State createState() => _CircleAnimationState(); +} + +class _CircleAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animationSize; + late Animation _animationOpacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _animationSize = Tween(begin: 57.5, end: 32.0).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + ); + _animationOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + ); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 57.5, + child: Stack( + fit: StackFit.passthrough, + children: [ + Center( + child: Container( + height: 32, + width: 32, + decoration: BoxDecoration( + border: Border.all( + color: ColorsTheme.of(context).iconWhite, width: 2), + shape: BoxShape.circle, + ), + ), + ), + AnimatedBuilder( + animation: _controller, + builder: (BuildContext context, Widget? child) { + return Center( + child: Opacity( + opacity: _animationOpacity.value, + child: Container( + height: _animationSize.value, + width: _animationSize.value, + decoration: BoxDecoration( + color: ColorsTheme.of(context).iconWhite, + shape: BoxShape.circle, + ), + ), + ), + ); + }, + // child: , + ), + ], + ), + ); + } +}