Files
tripz-llc/apps/rider/lib/features/auth/view/otp_screen.dart
T

309 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import '../cubit/auth_cubit.dart';
import '../cubit/auth_state.dart';
import '../../../core/design/tokens.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_button.dart';
/// شاشة تحقق OTP — تطابق stitch/otp_verification.html:
/// شريط علوي بلون primary + 4 خانات منفصلة (ترتيب LTR ثابت) + عدّاد
/// إعادة إرسال + تأكيد تلقائي عند اكتمال الخانات.
class OtpScreen extends StatefulWidget {
const OtpScreen({super.key});
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
static const _otpLength = 4;
final List<TextEditingController> _controllers =
List.generate(_otpLength, (_) => TextEditingController());
final List<FocusNode> _focusNodes =
List.generate(_otpLength, (_) => FocusNode());
int _resendSeconds = 60;
Timer? _resendTimer;
@override
void initState() {
super.initState();
_startResendTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
});
}
@override
void dispose() {
_resendTimer?.cancel();
for (final c in _controllers) {
c.dispose();
}
for (final f in _focusNodes) {
f.dispose();
}
super.dispose();
}
void _startResendTimer() {
_resendSeconds = 60;
_resendTimer?.cancel();
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_resendSeconds <= 0) {
timer.cancel();
} else {
setState(() => _resendSeconds--);
}
});
}
String get _otpCode => _controllers.map((c) => c.text).join();
bool get _isComplete => _otpCode.length == _otpLength;
void _onDigitChanged(int index, String value) {
if (value.length > 1) {
// لصق تلقائي: يوزّع الأرقام الملصوقة على الخانات بالترتيب
final digits = value.replaceAll(RegExp(r'[^0-9]'), '');
for (int i = 0; i < _otpLength && i < digits.length; i++) {
_controllers[i].text = digits[i];
}
final nextIdx = digits.length.clamp(0, _otpLength - 1);
_focusNodes[nextIdx].requestFocus();
} else if (value.isNotEmpty) {
if (index < _otpLength - 1) {
_focusNodes[index + 1].requestFocus();
} else {
_focusNodes[index].unfocus();
}
}
setState(() {});
if (_isComplete) {
_submit();
}
}
void _onKeyPress(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty &&
index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
setState(() {});
}
}
void _submit() {
if (!_isComplete) return;
context.read<AuthCubit>().verify(_otpCode);
}
void _resend() {
final phone = context.read<AuthCubit>().state.phone;
context.read<AuthCubit>().sendOtp(phone);
_startResendTimer();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final cs = theme.colorScheme;
final phone = context.watch<AuthCubit>().state.phone;
return BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final isLoading = state.status == AuthStatus.authenticating;
return TripzScaffold(
padding: false,
safeAreaTop: false,
bottomAction: TripzButton(
onPressed: (_isComplete && !isLoading) ? _submit : null,
label: l10n.confirm,
loading: isLoading,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── شريط علوي بلون primary (يطابق TopAppBar في stitch) ──
SafeArea(
bottom: false,
child: SizedBox(
height: kToolbarHeight,
child: ColoredBox(
color: cs.primary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: T.s8),
child: Row(
children: [
IconButton(
icon: const BackButtonIcon(),
color: cs.onPrimary,
onPressed: () => context.go('/phone'),
),
const SizedBox(width: T.s8),
Text(
l10n.confirmPhoneNumber,
style: theme.textTheme.titleLarge?.copyWith(
color: cs.onPrimary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: T.s24),
// نص الشرح + تعديل الرقم
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
l10n.otpSentTo(phone),
style: theme.textTheme.bodyLarge?.copyWith(
color: cs.onSurfaceVariant,
height: 1.5,
),
),
),
TextButton(
onPressed: () => context.go('/phone'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: T.s8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
l10n.edit,
style: theme.textTheme.labelLarge?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: T.s32),
// ── خانات الرمز — ترتيب LTR ثابت (يطابق dir="ltr" في stitch) ──
Directionality(
textDirection: TextDirection.ltr,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_otpLength, (i) {
final hasFocus = _focusNodes[i].hasFocus;
final hasValue = _controllers[i].text.isNotEmpty;
return Padding(
padding: EdgeInsetsDirectional.only(
end: i < _otpLength - 1 ? T.s16 : 0,
),
child: Container(
width: 60,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(T.r12),
boxShadow: (hasFocus || hasValue) ? const [T.shadowSm] : null,
),
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _onKeyPress(i, event),
child: TextField(
controller: _controllers[i],
focusNode: _focusNodes[i],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: cs.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: cs.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r12),
borderSide: BorderSide(color: cs.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r12),
borderSide: BorderSide(
color: hasValue ? cs.primary : cs.outlineVariant,
width: hasValue ? 1.5 : 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
),
onChanged: (v) => _onDigitChanged(i, v),
),
),
),
);
}),
),
),
const SizedBox(height: T.s24),
// عدّاد إعادة الإرسال
Center(
child: _resendSeconds > 0
? Text(
l10n.resendIn(_resendSeconds),
style: theme.textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
)
: TripzButton(
onPressed: _resend,
style: TripzButtonStyle.text,
label: l10n.resendCode,
fullWidth: false,
),
),
],
),
),
),
],
),
);
},
);
}
}