250 lines
6.7 KiB
Dart
250 lines
6.7 KiB
Dart
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';
|
|
|
|
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> {
|
|
List<String> mPin = [];
|
|
String? errorText;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.mode == MPinMode.enter) {
|
|
_tryBiometricBeforePin();
|
|
}
|
|
}
|
|
|
|
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(
|
|
localizedReason: 'Authenticate to access kMobile',
|
|
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 (mPin.length < 4) {
|
|
setState(() {
|
|
mPin.add(digit);
|
|
errorText = null;
|
|
});
|
|
if (mPin.length == 4) {
|
|
_handleComplete();
|
|
}
|
|
}
|
|
}
|
|
|
|
void deleteDigit() {
|
|
if (mPin.isNotEmpty) {
|
|
setState(() {
|
|
mPin.removeLast();
|
|
errorText = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleComplete() async {
|
|
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)) {
|
|
widget.onCompleted?.call(pin);
|
|
} else {
|
|
setState(() {
|
|
errorText = "Incorrect mPIN. Try again.";
|
|
mPin.clear();
|
|
});
|
|
}
|
|
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(() {
|
|
errorText = "Pins do not match. Try again.";
|
|
mPin.clear();
|
|
});
|
|
}
|
|
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 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(8.0),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
if (key == '<') {
|
|
deleteDigit();
|
|
} else if (key == 'Enter') {
|
|
if (mPin.length == 4) {
|
|
_handleComplete();
|
|
} else {
|
|
setState(() {
|
|
errorText = "Please enter 4 digits";
|
|
});
|
|
}
|
|
} else if (key.isNotEmpty) {
|
|
addDigit(key);
|
|
}
|
|
},
|
|
child: Container(
|
|
width: 70,
|
|
height: 70,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.grey[200],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: key == 'Enter'
|
|
? const Icon(Icons.check)
|
|
: Text(
|
|
key == '<' ? '⌫' : key,
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
color: key == 'Enter' ? Colors.blue : Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
String getTitle() {
|
|
switch (widget.mode) {
|
|
case MPinMode.enter:
|
|
return "Enter your mPIN";
|
|
case MPinMode.set:
|
|
return "Set your new mPIN";
|
|
case MPinMode.confirm:
|
|
return "Confirm your mPIN";
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const Spacer(),
|
|
// Logo
|
|
Image.asset('assets/images/logo.png', height: 100),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
getTitle(),
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(height: 20),
|
|
buildMPinDots(),
|
|
if (errorText != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child:
|
|
Text(errorText!, style: const TextStyle(color: Colors.red)),
|
|
),
|
|
const Spacer(),
|
|
buildNumberPad(),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|