384 lines
11 KiB
Dart
384 lines
11 KiB
Dart
import '../../../l10n/app_localizations.dart';
|
|
import 'dart:math';
|
|
|
|
// 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 }
|
|
|
|
class MPinScreen extends StatefulWidget {
|
|
final MPinMode mode;
|
|
final String? initialPin;
|
|
final void Function(String pin)? onCompleted;
|
|
|
|
const MPinScreen({
|
|
super.key,
|
|
required this.mode,
|
|
this.initialPin,
|
|
this.onCompleted,
|
|
});
|
|
|
|
@override
|
|
State<MPinScreen> createState() => _MPinScreenState();
|
|
}
|
|
|
|
class _MPinScreenState extends State<MPinScreen> with TickerProviderStateMixin {
|
|
List<String> mPin = [];
|
|
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
|
|
void 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) {
|
|
_tryBiometricBeforePin();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_bounceController.dispose();
|
|
_shakeController.dispose();
|
|
_waveController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _tryBiometricBeforePin() async {
|
|
final storage = getIt<SecureStorage>();
|
|
final enabled = await storage.read('biometric_enabled');
|
|
// log('biometric_enabled: $enabled');
|
|
if (enabled != null && enabled) {
|
|
final auth = LocalAuthentication();
|
|
if (await auth.canCheckBiometrics) {
|
|
final didAuth = await auth.authenticate(
|
|
// ignore: use_build_context_synchronously
|
|
localizedReason: AppLocalizations.of(context).authenticateToAccess,
|
|
options: const AuthenticationOptions(biometricOnly: true),
|
|
);
|
|
if (didAuth && mounted) {
|
|
// success → directly “complete” your flow
|
|
widget.onCompleted?.call('');
|
|
// or navigate yourself:
|
|
// Navigator.of(context).pushReplacement(
|
|
// MaterialPageRoute(builder: (_) => const NavigationScaffold()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
void deleteDigit() {
|
|
if (_shakeController.isAnimating || _waveController.isAnimating) return;
|
|
if (mPin.isNotEmpty) {
|
|
setState(() {
|
|
mPin.removeLast();
|
|
errorText = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _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');
|
|
if (storedPin == int.tryParse(pin)) {
|
|
// 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;
|
|
case MPinMode.set:
|
|
// propagate parent onCompleted into confirm step
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => MPinScreen(
|
|
mode: MPinMode.confirm,
|
|
initialPin: pin,
|
|
onCompleted: widget.onCompleted, // <-- use parent callback
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
case MPinMode.confirm:
|
|
if (widget.initialPin == pin) {
|
|
// 1) persist the pin
|
|
await storage.write('mpin', pin);
|
|
|
|
// 3) now clear the entire navigation stack and go to your main scaffold
|
|
if (mounted) {
|
|
Navigator.of(context, rootNavigator: true).pushAndRemoveUntil(
|
|
MaterialPageRoute(builder: (_) => const NavigationScaffold()),
|
|
(route) => false,
|
|
);
|
|
}
|
|
} else {
|
|
setState(() {
|
|
_isError = true;
|
|
errorText = AppLocalizations.of(context).pinsDoNotMatch;
|
|
});
|
|
await _shakeController.forward(from: 0);
|
|
setState(() {
|
|
mPin.clear();
|
|
_isError = false;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildNumberPad() {
|
|
List<List<String>> keys = [
|
|
['1', '2', '3'],
|
|
['4', '5', '6'],
|
|
['7', '8', '9'],
|
|
['Enter', '0', '<'],
|
|
];
|
|
|
|
return Column(
|
|
children: keys.map((row) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: row.map((key) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(1.0),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
HapticFeedback.lightImpact();
|
|
if (key == '<') {
|
|
deleteDigit();
|
|
} else if (key == 'Enter') {
|
|
if (mPin.length == 4) {
|
|
_handleComplete();
|
|
} else {
|
|
setState(() {
|
|
errorText = AppLocalizations.of(
|
|
context,
|
|
).pleaseEnter4Digits;
|
|
});
|
|
}
|
|
} else if (key.isNotEmpty) {
|
|
addDigit(key);
|
|
}
|
|
},
|
|
child: Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
),
|
|
alignment: Alignment.center,
|
|
child: key == 'Enter'
|
|
? const Icon(Icons.check)
|
|
: Text(
|
|
key == '<' ? '⌫' : key,
|
|
style: TextStyle(
|
|
fontSize: 30,
|
|
color: key == 'Enter'
|
|
? Theme.of(context).primaryColor
|
|
: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
String getTitle() {
|
|
switch (widget.mode) {
|
|
case MPinMode.enter:
|
|
return AppLocalizations.of(context).enterMPIN;
|
|
case MPinMode.set:
|
|
return AppLocalizations.of(context).setMPIN;
|
|
case MPinMode.confirm:
|
|
return AppLocalizations.of(context).confirmMPIN;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 50),
|
|
// Logo
|
|
Image.asset('assets/images/logo.png', height: 100),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
getTitle(),
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
|
),
|
|
const Spacer(),
|
|
buildMPinDots(context),
|
|
if (errorText != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
errorText!,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
buildNumberPad(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|