implemented correct colors and proper animations in mpin screen.
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'dart:developer';
|
// import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:kmobile/app.dart';
|
import 'package:kmobile/app.dart';
|
||||||
import 'package:kmobile/di/injection.dart';
|
import 'package:kmobile/di/injection.dart';
|
||||||
import 'package:kmobile/security/secure_storage.dart';
|
import 'package:kmobile/security/secure_storage.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
enum MPinMode { enter, set, confirm }
|
enum MPinMode { enter, set, confirm }
|
||||||
|
|
||||||
@@ -26,22 +28,73 @@ class MPinScreen extends StatefulWidget {
|
|||||||
State<MPinScreen> createState() => _MPinScreenState();
|
State<MPinScreen> createState() => _MPinScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MPinScreenState extends State<MPinScreen> {
|
class _MPinScreenState extends State<MPinScreen> with TickerProviderStateMixin {
|
||||||
List<String> mPin = [];
|
List<String> mPin = [];
|
||||||
String? errorText;
|
String? errorText;
|
||||||
|
|
||||||
|
// Animation controllers
|
||||||
|
late final AnimationController _bounceController;
|
||||||
|
late final Animation<double> _bounceAnimation;
|
||||||
|
late final AnimationController _shakeController;
|
||||||
|
late final AnimationController _waveController;
|
||||||
|
late final Animation<double> _waveAnimation;
|
||||||
|
|
||||||
|
// State flags for animations
|
||||||
|
bool _isError = false;
|
||||||
|
bool _isSuccess = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Bounce animation for single dot entry
|
||||||
|
_bounceController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
);
|
||||||
|
_bounceAnimation = Tween<double>(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<double>(begin: 0, end: 1).animate(
|
||||||
|
CurvedAnimation(parent: _waveController, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
|
||||||
if (widget.mode == MPinMode.enter) {
|
if (widget.mode == MPinMode.enter) {
|
||||||
_tryBiometricBeforePin();
|
_tryBiometricBeforePin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bounceController.dispose();
|
||||||
|
_shakeController.dispose();
|
||||||
|
_waveController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _tryBiometricBeforePin() async {
|
Future<void> _tryBiometricBeforePin() async {
|
||||||
final storage = getIt<SecureStorage>();
|
final storage = getIt<SecureStorage>();
|
||||||
final enabled = await storage.read('biometric_enabled');
|
final enabled = await storage.read('biometric_enabled');
|
||||||
log('biometric_enabled: $enabled');
|
// log('biometric_enabled: $enabled');
|
||||||
if (enabled != null && enabled) {
|
if (enabled != null && enabled) {
|
||||||
final auth = LocalAuthentication();
|
final auth = LocalAuthentication();
|
||||||
if (await auth.canCheckBiometrics) {
|
if (await auth.canCheckBiometrics) {
|
||||||
@@ -62,11 +115,13 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void addDigit(String digit) {
|
void addDigit(String digit) {
|
||||||
|
if (_shakeController.isAnimating || _waveController.isAnimating) return;
|
||||||
if (mPin.length < 4) {
|
if (mPin.length < 4) {
|
||||||
setState(() {
|
setState(() {
|
||||||
mPin.add(digit);
|
mPin.add(digit);
|
||||||
errorText = null;
|
errorText = null;
|
||||||
});
|
});
|
||||||
|
_bounceController.forward(from: 0);
|
||||||
if (mPin.length == 4) {
|
if (mPin.length == 4) {
|
||||||
_handleComplete();
|
_handleComplete();
|
||||||
}
|
}
|
||||||
@@ -74,6 +129,7 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void deleteDigit() {
|
void deleteDigit() {
|
||||||
|
if (_shakeController.isAnimating || _waveController.isAnimating) return;
|
||||||
if (mPin.isNotEmpty) {
|
if (mPin.isNotEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
mPin.removeLast();
|
mPin.removeLast();
|
||||||
@@ -83,19 +139,36 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleComplete() async {
|
Future<void> _handleComplete() async {
|
||||||
|
if (_shakeController.isAnimating || _waveController.isAnimating) return;
|
||||||
|
|
||||||
final pin = mPin.join();
|
final pin = mPin.join();
|
||||||
final storage = SecureStorage();
|
final storage = SecureStorage();
|
||||||
|
|
||||||
switch (widget.mode) {
|
switch (widget.mode) {
|
||||||
case MPinMode.enter:
|
case MPinMode.enter:
|
||||||
final storedPin = await storage.read('mpin');
|
final storedPin = await storage.read('mpin');
|
||||||
log('storedPin: $storedPin');
|
// log('storedPin: $storedPin');
|
||||||
if (storedPin == int.tryParse(pin)) {
|
if (storedPin == int.tryParse(pin)) {
|
||||||
widget.onCompleted?.call(pin);
|
// Correct PIN
|
||||||
} else {
|
|
||||||
setState(() {
|
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;
|
errorText = AppLocalizations.of(context).incorrectMPIN;
|
||||||
|
});
|
||||||
|
await _shakeController.forward(from: 0);
|
||||||
|
setState(() {
|
||||||
mPin.clear();
|
mPin.clear();
|
||||||
|
_isError = false;
|
||||||
|
// Keep error text until next digit is entered
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -126,28 +199,81 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_isError = true;
|
||||||
errorText = AppLocalizations.of(context).pinsDoNotMatch;
|
errorText = AppLocalizations.of(context).pinsDoNotMatch;
|
||||||
|
});
|
||||||
|
await _shakeController.forward(from: 0);
|
||||||
|
setState(() {
|
||||||
mPin.clear();
|
mPin.clear();
|
||||||
|
_isError = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildMPinDots() {
|
Widget buildMPinDots(BuildContext context) {
|
||||||
return Row(
|
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,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: List.generate(4, (index) {
|
children: List.generate(4, (index) {
|
||||||
return Container(
|
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),
|
margin: const EdgeInsets.all(8),
|
||||||
width: 15,
|
width: 25,
|
||||||
height: 15,
|
height: 25,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: index < mPin.length ? Colors.black : Colors.grey[400],
|
color: dotColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,9 +291,10 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: row.map((key) {
|
children: row.map((key) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(1.0),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
if (key == '<') {
|
if (key == '<') {
|
||||||
deleteDigit();
|
deleteDigit();
|
||||||
} else if (key == 'Enter') {
|
} else if (key == 'Enter') {
|
||||||
@@ -185,11 +312,10 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 70,
|
width: 80,
|
||||||
height: 70,
|
height: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: Colors.grey[200],
|
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: key == 'Enter'
|
child: key == 'Enter'
|
||||||
@@ -197,10 +323,10 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
: Text(
|
: Text(
|
||||||
key == '<' ? '⌫' : key,
|
key == '<' ? '⌫' : key,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 30,
|
||||||
color: key == 'Enter'
|
color: key == 'Enter'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: Colors.black,
|
: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -229,7 +355,7 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const SizedBox(height: 50),
|
||||||
// Logo
|
// Logo
|
||||||
Image.asset('assets/images/logo.png', height: 100),
|
Image.asset('assets/images/logo.png', height: 100),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@@ -237,19 +363,18 @@ class _MPinScreenState extends State<MPinScreen> {
|
|||||||
getTitle(),
|
getTitle(),
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const Spacer(),
|
||||||
buildMPinDots(),
|
buildMPinDots(context),
|
||||||
if (errorText != null)
|
if (errorText != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
errorText!,
|
errorText!,
|
||||||
style: const TextStyle(color: Colors.red),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
buildNumberPad(),
|
buildNumberPad(),
|
||||||
const Spacer(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Reference in New Issue
Block a user