diff --git a/lib/features/auth/screens/mpin_screen.dart b/lib/features/auth/screens/mpin_screen.dart index 812ac62..3fda386 100644 --- a/lib/features/auth/screens/mpin_screen.dart +++ b/lib/features/auth/screens/mpin_screen.dart @@ -1,12 +1,14 @@ import '../../../l10n/app_localizations.dart'; +import 'dart:math'; -import 'dart:developer'; +// import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:kmobile/app.dart'; import 'package:kmobile/di/injection.dart'; import 'package:kmobile/security/secure_storage.dart'; import 'package:local_auth/local_auth.dart'; +import 'package:flutter/services.dart'; enum MPinMode { enter, set, confirm } @@ -26,22 +28,73 @@ class MPinScreen extends StatefulWidget { State createState() => _MPinScreenState(); } -class _MPinScreenState extends State { +class _MPinScreenState extends State with TickerProviderStateMixin { List mPin = []; String? errorText; + // Animation controllers + late final AnimationController _bounceController; + late final Animation _bounceAnimation; + late final AnimationController _shakeController; + late final AnimationController _waveController; + late final Animation _waveAnimation; + + // State flags for animations + bool _isError = false; + bool _isSuccess = false; + @override void initState() { super.initState(); + // Bounce animation for single dot entry + _bounceController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + _bounceAnimation = Tween(begin: 1.0, end: 1.2).animate( + CurvedAnimation( + parent: _bounceController, + curve: Curves.elasticIn, + reverseCurve: Curves.elasticOut, + ), + )..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _bounceController.reverse(); + } + }); + + // Shake animation for error + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + + // Wave animation for success + _waveController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _waveAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _waveController, curve: Curves.easeInOut), + ); + if (widget.mode == MPinMode.enter) { _tryBiometricBeforePin(); } } + @override + void dispose() { + _bounceController.dispose(); + _shakeController.dispose(); + _waveController.dispose(); + super.dispose(); + } + Future _tryBiometricBeforePin() async { final storage = getIt(); final enabled = await storage.read('biometric_enabled'); - log('biometric_enabled: $enabled'); + // log('biometric_enabled: $enabled'); if (enabled != null && enabled) { final auth = LocalAuthentication(); if (await auth.canCheckBiometrics) { @@ -62,11 +115,13 @@ class _MPinScreenState extends State { } void addDigit(String digit) { + if (_shakeController.isAnimating || _waveController.isAnimating) return; if (mPin.length < 4) { setState(() { mPin.add(digit); errorText = null; }); + _bounceController.forward(from: 0); if (mPin.length == 4) { _handleComplete(); } @@ -74,6 +129,7 @@ class _MPinScreenState extends State { } void deleteDigit() { + if (_shakeController.isAnimating || _waveController.isAnimating) return; if (mPin.isNotEmpty) { setState(() { mPin.removeLast(); @@ -83,19 +139,36 @@ class _MPinScreenState extends State { } Future _handleComplete() async { + if (_shakeController.isAnimating || _waveController.isAnimating) return; + final pin = mPin.join(); final storage = SecureStorage(); switch (widget.mode) { case MPinMode.enter: final storedPin = await storage.read('mpin'); - log('storedPin: $storedPin'); + // log('storedPin: $storedPin'); if (storedPin == int.tryParse(pin)) { - widget.onCompleted?.call(pin); - } else { + // Correct PIN setState(() { + _isSuccess = true; + errorText = null; + }); + await Future.delayed(const Duration(milliseconds: 100)); + _waveController.forward(from: 0).whenComplete(() { + widget.onCompleted?.call(pin); + }); + } else { + // Incorrect PIN + setState(() { + _isError = true; errorText = AppLocalizations.of(context).incorrectMPIN; + }); + await _shakeController.forward(from: 0); + setState(() { mPin.clear(); + _isError = false; + // Keep error text until next digit is entered }); } break; @@ -126,28 +199,81 @@ class _MPinScreenState extends State { } } else { setState(() { + _isError = true; errorText = AppLocalizations.of(context).pinsDoNotMatch; + }); + await _shakeController.forward(from: 0); + setState(() { mPin.clear(); + _isError = false; }); } break; } } - Widget buildMPinDots() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(4, (index) { - return Container( - margin: const EdgeInsets.all(8), - width: 15, - height: 15, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: index < mPin.length ? Colors.black : Colors.grey[400], + Widget buildMPinDots(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge( + [_bounceController, _shakeController, _waveController]), + builder: (context, child) { + double shakeOffset = 0; + if (_shakeController.isAnimating) { + // 4 cycles of sine wave for shake + shakeOffset = sin(_shakeController.value * 4 * pi) * 12; + } + + return Transform.translate( + offset: Offset(shakeOffset, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(4, (index) { + final isFilled = index < mPin.length; + + // Bounce animation for single dot + final isAnimatingBounce = + index == mPin.length - 1 && _bounceController.isAnimating; + double bounceScale = + isAnimatingBounce ? _bounceAnimation.value : 1.0; + + // Success wave animation + double waveScale = 1.0; + if (_isSuccess) { + final waveDelay = index * 0.15; + final waveValue = + (_waveAnimation.value - waveDelay).clamp(0.0, 1.0); + // Grow and shrink + waveScale = 1.0 + sin(waveValue * pi) * 0.4; + } + + // Determine dot color + Color dotColor; + if (_isError) { + dotColor = Theme.of(context).colorScheme.error; + } else if (_isSuccess) { + dotColor = Colors.green.shade600; + } else { + dotColor = isFilled + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.surfaceContainerHighest; + } + + return Transform.scale( + scale: bounceScale * waveScale, + child: Container( + margin: const EdgeInsets.all(8), + width: 25, + height: 25, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), + ), + ); + }), ), ); - }), + }, ); } @@ -165,9 +291,10 @@ class _MPinScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: row.map((key) { return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(1.0), child: GestureDetector( onTap: () { + HapticFeedback.lightImpact(); if (key == '<') { deleteDigit(); } else if (key == 'Enter') { @@ -185,11 +312,10 @@ class _MPinScreenState extends State { } }, child: Container( - width: 70, - height: 70, + width: 80, + height: 80, decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.grey[200], ), alignment: Alignment.center, child: key == 'Enter' @@ -197,10 +323,10 @@ class _MPinScreenState extends State { : Text( key == '<' ? '⌫' : key, style: TextStyle( - fontSize: 20, + fontSize: 30, color: key == 'Enter' ? Theme.of(context).primaryColor - : Colors.black, + : Theme.of(context).colorScheme.onSurface, ), ), ), @@ -229,7 +355,7 @@ class _MPinScreenState extends State { body: SafeArea( child: Column( children: [ - const Spacer(), + const SizedBox(height: 50), // Logo Image.asset('assets/images/logo.png', height: 100), const SizedBox(height: 20), @@ -237,19 +363,18 @@ class _MPinScreenState extends State { getTitle(), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), ), - const SizedBox(height: 20), - buildMPinDots(), + const Spacer(), + buildMPinDots(context), if (errorText != null) Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( errorText!, - style: const TextStyle(color: Colors.red), + style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), const Spacer(), buildNumberPad(), - const Spacer(), ], ), ),