298 lines
9.5 KiB
Dart
298 lines
9.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
|
|
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
|
|
class VerifyScreen extends StatefulWidget {
|
|
const VerifyScreen({super.key});
|
|
|
|
@override
|
|
State<VerifyScreen> createState() => _VerifyScreenState();
|
|
}
|
|
|
|
class _VerifyScreenState extends State<VerifyScreen> {
|
|
late VerifyController verifyController;
|
|
String? errorMessage;
|
|
// Resend cooldown
|
|
final Duration _resendCooldown = const Duration(minutes: 1);
|
|
DateTime? _resendAvailableAt;
|
|
Timer? _resendTimer;
|
|
int _remainingSeconds = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
verifyController = VerifyController(context);
|
|
// Start the resend cooldown immediately since the OTP was just sent
|
|
// during registration.
|
|
_startResendCooldown();
|
|
}
|
|
|
|
final List<TextEditingController> _codeControllers = List.generate(
|
|
6,
|
|
(index) => TextEditingController(),
|
|
);
|
|
final List<FocusNode> _focusNodes = List.generate(6, (index) => FocusNode());
|
|
|
|
@override
|
|
void dispose() {
|
|
for (var controller in _codeControllers) {
|
|
controller.dispose();
|
|
}
|
|
for (var node in _focusNodes) {
|
|
node.dispose();
|
|
}
|
|
_resendTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startResendCooldown() {
|
|
_resendTimer?.cancel();
|
|
_resendAvailableAt = DateTime.now().add(_resendCooldown);
|
|
_remainingSeconds = _resendCooldown.inSeconds;
|
|
if (mounted) setState(() {});
|
|
_resendTimer = Timer.periodic(
|
|
const Duration(seconds: 1),
|
|
(_) => _updateRemaining(),
|
|
);
|
|
}
|
|
|
|
void _updateRemaining() {
|
|
if (_resendAvailableAt == null) return;
|
|
final seconds = _resendAvailableAt!.difference(DateTime.now()).inSeconds;
|
|
if (seconds <= 0) {
|
|
_resendTimer?.cancel();
|
|
setState(() {
|
|
_resendAvailableAt = null;
|
|
_remainingSeconds = 0;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_remainingSeconds = seconds;
|
|
});
|
|
}
|
|
}
|
|
|
|
String _formatDuration(int totalSeconds) {
|
|
final mins = (totalSeconds ~/ 60).toString().padLeft(2, '0');
|
|
final secs = (totalSeconds % 60).toString().padLeft(2, '0');
|
|
return '$mins:$secs';
|
|
}
|
|
|
|
void showErrorMessage(String? message) {
|
|
setState(() {
|
|
errorMessage = message;
|
|
});
|
|
}
|
|
|
|
void _onCodeChanged(String value, int index) {
|
|
if (value.length == 1 && index < 5) {
|
|
_focusNodes[index + 1].requestFocus();
|
|
} else if (value.isEmpty && index > 0) {
|
|
_focusNodes[index - 1].requestFocus();
|
|
}
|
|
}
|
|
|
|
Future<void> _submitCode() async {
|
|
showErrorMessage(null);
|
|
String code = '';
|
|
for (var controller in _codeControllers) {
|
|
code += controller.text;
|
|
}
|
|
|
|
if (code.length != 6) {
|
|
showErrorMessage('Invalid code');
|
|
return;
|
|
}
|
|
verifyController.updateCode(code);
|
|
final response = await verifyController.verifyOtp();
|
|
if (!mounted) return;
|
|
if (response.isSuccess) {
|
|
if (verifyController.model.status == 'done') {
|
|
context.go('/onboarding/complete');
|
|
} else {
|
|
showErrorMessage(
|
|
verifyController.model.errorMessage ?? 'Verification failed',
|
|
);
|
|
}
|
|
} else {
|
|
showErrorMessage(
|
|
response.error ?? 'Verification failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: verifyController,
|
|
builder: (context, child) {
|
|
return OnboardingLayout(
|
|
showBackButton: true,
|
|
trailingLabel: 'Sign in',
|
|
trailingRoute: '/onboarding/login',
|
|
trailingIcon: Icons.login_rounded,
|
|
bottomLinkLabel: 'Continue as guest?',
|
|
bottomLinkRoute: '/',
|
|
child: OnboardingCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const OnboardingHero(
|
|
icon: Icons.sms_rounded,
|
|
title: 'Verify your account',
|
|
subtitle:
|
|
'Enter the 6-digit code we sent to your phone or email.',
|
|
),
|
|
const SizedBox(height: 32),
|
|
_buildCodeInputs(),
|
|
const SizedBox(height: 16),
|
|
if (errorMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Center(
|
|
child: Text(
|
|
errorMessage!,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.error,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildResendRow(),
|
|
const SizedBox(height: 28),
|
|
Skeletonizer(
|
|
enabled: verifyController.model.isLoading,
|
|
child: OnboardingPrimaryButton(
|
|
label: 'Verify code',
|
|
icon: Icons.check_rounded,
|
|
onPressed: _submitCode,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildCodeInputs() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final borderRadius = BorderRadius.circular(14);
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: List.generate(6, (index) {
|
|
return SizedBox(
|
|
width: 48,
|
|
child: TextFormField(
|
|
controller: _codeControllers[index],
|
|
focusNode: _focusNodes[index],
|
|
textAlign: TextAlign.center,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 1,
|
|
style: TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w700,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
decoration: InputDecoration(
|
|
counterText: '',
|
|
filled: true,
|
|
fillColor: colorScheme.surface,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(color: colorScheme.primary, width: 1.6),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(color: colorScheme.error),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(color: colorScheme.error, width: 1.6),
|
|
),
|
|
),
|
|
onChanged: (value) => _onCodeChanged(value, index),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Widget _buildResendRow() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Center(
|
|
child: Wrap(
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Text(
|
|
"Didn't receive the code?",
|
|
style: TextStyle(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
// Show countdown while cooling down, otherwise show resend button
|
|
_remainingSeconds == 0
|
|
? TextButton(
|
|
onPressed: verifyController.model.isLoading
|
|
? null
|
|
: () async {
|
|
// Start cooldown immediately to give immediate feedback
|
|
_startResendCooldown();
|
|
final resp = await verifyController.resendOtp();
|
|
if (!mounted) return;
|
|
if (!resp.isSuccess) {
|
|
// cancel cooldown on failure and show error
|
|
_resendTimer?.cancel();
|
|
setState(() {
|
|
_resendAvailableAt = null;
|
|
_remainingSeconds = 0;
|
|
});
|
|
showErrorMessage(
|
|
resp.error ?? 'Failed to resend code',
|
|
);
|
|
}
|
|
},
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colorScheme.primary,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
textStyle: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
child: const Text('Resend code'),
|
|
)
|
|
: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
child: Text(
|
|
'Resend in ${_formatDuration(_remainingSeconds)}',
|
|
style: TextStyle(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|