261 lines
8.6 KiB
Dart
261 lines
8.6 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
|
import 'package:miler/controllers/auth.dart';
|
|
import 'package:miler/views/onboardscreens/Creat_mpin.dart';
|
|
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
|
|
import 'package:sms_autofill/sms_autofill.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// VERIFY — step 2 of 3.
|
|
///
|
|
/// ── What changed and why ──
|
|
///
|
|
/// **The number is now on screen.** The old copy said "Enter the 6-digit code
|
|
/// sent to your device", which is the one screen in the flow where the rider
|
|
/// most needs to check a fact — *which* number did it go to — and it did not
|
|
/// tell him. If he fat-fingered a digit on the previous screen, he waits out a
|
|
/// 60-second timer for a code that was never coming, with no way to tell. It now
|
|
/// echoes the number with a "Change" action beside it, which is what every app
|
|
/// that has thought about this does.
|
|
///
|
|
/// **Failure is visible.** A wrong or expired code silently cleared all six
|
|
/// boxes and dropped a floating `SnackBar` at the bottom of the screen —
|
|
/// underneath the keyboard, which is up by definition here. The rider saw six
|
|
/// boxes empty themselves for no stated reason. The boxes now turn red and the
|
|
/// reason is printed under them.
|
|
///
|
|
/// **The decorative tile is gone**, along with the 64px tinted SMS glyph inside
|
|
/// it: it pushed the code boxes a third of the way down the screen to illustrate
|
|
/// a word already in the heading.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
class OtpPage extends GetResponsiveView {
|
|
OtpPage({super.key});
|
|
|
|
@override
|
|
Widget builder() => const _OtpPageLayout();
|
|
}
|
|
|
|
class _OtpPageLayout extends StatefulWidget {
|
|
const _OtpPageLayout();
|
|
|
|
@override
|
|
State<_OtpPageLayout> createState() => _OtpPageLayoutState();
|
|
}
|
|
|
|
class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill {
|
|
final AuthController _auth = Get.put(AuthController());
|
|
final List<TextEditingController> _otpControllers = List.generate(
|
|
6,
|
|
(_) => TextEditingController(),
|
|
);
|
|
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
|
|
|
|
bool isVerifying = false;
|
|
String? _error;
|
|
int _secondsRemaining = 60;
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_startTimer();
|
|
listenForCode();
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => _focusNodes[0].requestFocus(),
|
|
);
|
|
}
|
|
|
|
void _startTimer() {
|
|
_secondsRemaining = 60;
|
|
_timer?.cancel();
|
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
if (_secondsRemaining > 0) {
|
|
setState(() => _secondsRemaining--);
|
|
} else {
|
|
timer.cancel();
|
|
}
|
|
});
|
|
}
|
|
|
|
bool get _isComplete => _otpControllers.every((c) => c.text.isNotEmpty);
|
|
|
|
String get _code => _otpControllers.map((c) => c.text).join();
|
|
|
|
Future<void> _autoVerify() async {
|
|
if (isVerifying || !_isComplete) return;
|
|
setState(() {
|
|
isVerifying = true;
|
|
_error = null;
|
|
});
|
|
|
|
final ok = await _auth.verifyOtp(_code);
|
|
|
|
if (!mounted) return;
|
|
setState(() => isVerifying = false);
|
|
|
|
if (ok) {
|
|
openScreen(context, CreateMpin());
|
|
} else {
|
|
// Clear and refocus as before, but say so — see the class note.
|
|
for (final c in _otpControllers) {
|
|
c.clear();
|
|
}
|
|
_focusNodes[0].requestFocus();
|
|
setState(() {
|
|
_error = _secondsRemaining == 0
|
|
? 'That code is incorrect or has expired. Request a new one.'
|
|
: 'That code is incorrect. Check the SMS and try again.';
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void codeUpdated() {
|
|
final received = code ?? '';
|
|
if (received.length >= 6) {
|
|
for (int i = 0; i < 6; i++) {
|
|
_otpControllers[i].text = received[i];
|
|
}
|
|
setState(() => _error = null);
|
|
_autoVerify();
|
|
}
|
|
}
|
|
|
|
void _onDigitChanged(String val, int index) {
|
|
if (val.isNotEmpty && index < 5) {
|
|
_focusNodes[index + 1].requestFocus();
|
|
}
|
|
if (val.isEmpty && index > 0) {
|
|
_focusNodes[index - 1].requestFocus();
|
|
}
|
|
// Typing again clears a previous rejection — the red boxes refer to the code
|
|
// he just replaced, not the one he is entering now.
|
|
setState(() {
|
|
if (_error != null) _error = null;
|
|
});
|
|
if (_isComplete) _autoVerify();
|
|
}
|
|
|
|
/// `+91 98765 43210`, grouped so a rider can actually proof-read it.
|
|
String get _maskedPhone {
|
|
final raw = (_auth.currentPhone ?? '').replaceAll(RegExp(r'\D'), '');
|
|
if (raw.length != 10) return 'your mobile number';
|
|
return '+91 ${raw.substring(0, 5)} ${raw.substring(5)}';
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (var c in _otpControllers) {
|
|
c.dispose();
|
|
}
|
|
for (var f in _focusNodes) {
|
|
f.dispose();
|
|
}
|
|
_timer?.cancel();
|
|
cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final canResend = _secondsRemaining == 0;
|
|
|
|
return AuthScaffold(
|
|
onBack: () => Get.back(),
|
|
// 1 of 2, not 2 of 3: the number screen no longer counts itself, and a
|
|
// counter whose first sighting is "step 2" is a counter the rider has
|
|
// already lost track of. See the note at the top of `Sign_in`.
|
|
step: 1,
|
|
totalSteps: 2,
|
|
// Compact, for the same reason as the two screens either side of it: the
|
|
// first box takes focus on the first frame. Included so the flow does not
|
|
// gain and lose its masthead between the number screen and the PIN one.
|
|
banner: const AuthBrandBanner(compact: true),
|
|
title: 'Enter the 6-digit code',
|
|
footer: AuthPrimaryButton(
|
|
label: 'Verify & continue',
|
|
loading: isVerifying,
|
|
onPressed: isVerifying || !_isComplete ? null : _autoVerify,
|
|
),
|
|
children: [
|
|
// ── Which number, and a way out ──
|
|
// The single most useful fact on this screen, and the old version left
|
|
// it off entirely.
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: 26.h),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text.rich(
|
|
TextSpan(
|
|
style: AuthType.subtitle,
|
|
children: [
|
|
const TextSpan(text: 'Sent by SMS to '),
|
|
TextSpan(
|
|
text: _maskedPhone,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
AuthTextAction(
|
|
label: 'Change',
|
|
onPressed: isVerifying ? null : () => Get.back(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
AuthCodeRow(
|
|
controllers: _otpControllers,
|
|
nodes: _focusNodes,
|
|
hasError: _error != null,
|
|
onChanged: _onDigitChanged,
|
|
),
|
|
AuthInlineError(message: _error),
|
|
|
|
SizedBox(height: 22.h),
|
|
|
|
// ── Resend ──
|
|
//
|
|
// The countdown used to be a disabled `TextButton` reading
|
|
// "Resend in 00:42", so the affordance and the timer were the same
|
|
// control and it looked broken for its first sixty seconds. The wait is
|
|
// now a plain sentence, and the button only exists once it can be used.
|
|
Center(
|
|
child: canResend
|
|
? AuthTextAction(
|
|
label: 'Send a new code',
|
|
icon: Icons.refresh_rounded,
|
|
onPressed: () async {
|
|
setState(() => _error = null);
|
|
_startTimer();
|
|
await _auth.sendOtp();
|
|
},
|
|
)
|
|
: Text(
|
|
'You can request a new code in '
|
|
'0:${_secondsRemaining.toString().padLeft(2, '0')}',
|
|
style: AuthType.meta,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|