690 lines
22 KiB
Dart
690 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../services/api_service.dart';
|
|
import '../services/otp_controller.dart';
|
|
import 'success_screen.dart';
|
|
|
|
class OtpWaitScreen extends StatefulWidget {
|
|
final String phone;
|
|
final String method;
|
|
final int expiresIn;
|
|
|
|
const OtpWaitScreen({
|
|
super.key,
|
|
required this.phone,
|
|
required this.method,
|
|
this.expiresIn = 120,
|
|
});
|
|
|
|
@override
|
|
State<OtpWaitScreen> createState() => _OtpWaitScreenState();
|
|
}
|
|
|
|
class _OtpWaitScreenState extends State<OtpWaitScreen>
|
|
with TickerProviderStateMixin {
|
|
final OtpController _otpController = OtpController();
|
|
final TextEditingController _manualOtpController = TextEditingController();
|
|
|
|
String _status = 'waiting'; // waiting, call_detected, verified, timeout, permission_denied
|
|
int _countdown = 120;
|
|
Timer? _countdownTimer;
|
|
bool _showManualInput = false;
|
|
bool _isVerifying = false;
|
|
String? _errorMessage;
|
|
|
|
// Animation controllers
|
|
late AnimationController _ringAnimationController;
|
|
late AnimationController _pulseAnimationController;
|
|
late Animation<double> _pulseAnimation;
|
|
|
|
bool get _isFlashCall => widget.method == 'flash_call';
|
|
bool get _isIOS => Platform.isIOS;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_countdown = widget.expiresIn;
|
|
|
|
// Ringing animation (rotation)
|
|
_ringAnimationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 400),
|
|
)..repeat(reverse: true);
|
|
|
|
// Pulse animation (scale)
|
|
_pulseAnimationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 800),
|
|
)..repeat(reverse: true);
|
|
|
|
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(
|
|
CurvedAnimation(
|
|
parent: _pulseAnimationController,
|
|
curve: Curves.easeInOut,
|
|
),
|
|
);
|
|
|
|
// Start countdown
|
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_countdown--;
|
|
});
|
|
if (_countdown <= 0) {
|
|
timer.cancel();
|
|
if (_status == 'waiting') {
|
|
setState(() {
|
|
_status = 'timeout';
|
|
});
|
|
_stopAnimations();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Setup OTP controller callbacks
|
|
_otpController.onStatusChanged = (status) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_status = status;
|
|
});
|
|
if (status == 'call_detected' || status == 'verified') {
|
|
_stopAnimations();
|
|
}
|
|
if (status == 'permission_denied') {
|
|
_stopAnimations();
|
|
setState(() {
|
|
_showManualInput = true;
|
|
});
|
|
}
|
|
};
|
|
|
|
_otpController.onOtpDetected = (otp) {
|
|
if (!mounted) return;
|
|
_autoVerify(otp);
|
|
};
|
|
|
|
_otpController.onTimeout = () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_status = 'timeout';
|
|
});
|
|
_stopAnimations();
|
|
};
|
|
|
|
// Start waiting (Android only for call log polling)
|
|
if (_isIOS) {
|
|
// On iOS, show manual input directly
|
|
setState(() {
|
|
_showManualInput = true;
|
|
_status = 'waiting';
|
|
});
|
|
} else if (_isFlashCall) {
|
|
_otpController.startWaiting(widget.phone);
|
|
} else {
|
|
// SMS method - show manual input
|
|
setState(() {
|
|
_showManualInput = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _stopAnimations() {
|
|
_ringAnimationController.stop();
|
|
_pulseAnimationController.stop();
|
|
}
|
|
|
|
Future<void> _autoVerify(String otp) async {
|
|
setState(() {
|
|
_isVerifying = true;
|
|
_status = 'call_detected';
|
|
});
|
|
|
|
try {
|
|
final result = await ApiService.verifyOtp(widget.phone, otp);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result['success'] == true) {
|
|
setState(() {
|
|
_status = 'verified';
|
|
_isVerifying = false;
|
|
});
|
|
_stopAnimations();
|
|
|
|
// Navigate to success screen after a brief delay
|
|
await Future.delayed(const Duration(milliseconds: 800));
|
|
if (!mounted) return;
|
|
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(
|
|
builder: (context) => SuccessScreen(phone: widget.phone),
|
|
),
|
|
);
|
|
} else {
|
|
setState(() {
|
|
_isVerifying = false;
|
|
_errorMessage = result['message'] ?? 'فشل التحقق، أعد المحاولة';
|
|
_showManualInput = true;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isVerifying = false;
|
|
_errorMessage = 'حدث خطأ في الاتصال بالخادم';
|
|
_showManualInput = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _manualVerify() async {
|
|
final otp = _manualOtpController.text.trim();
|
|
if (otp.length != 4) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Directionality(
|
|
textDirection: TextDirection.rtl,
|
|
child: Text('يرجى إدخال 4 أرقام'),
|
|
),
|
|
backgroundColor: Colors.red,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isVerifying = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final result = await ApiService.verifyOtp(widget.phone, otp);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result['success'] == true) {
|
|
setState(() {
|
|
_status = 'verified';
|
|
_isVerifying = false;
|
|
});
|
|
_stopAnimations();
|
|
|
|
await Future.delayed(const Duration(milliseconds: 800));
|
|
if (!mounted) return;
|
|
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(
|
|
builder: (context) => SuccessScreen(phone: widget.phone),
|
|
),
|
|
);
|
|
} else {
|
|
setState(() {
|
|
_isVerifying = false;
|
|
_errorMessage = result['message'] ?? 'رمز التحقق غير صحيح';
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isVerifying = false;
|
|
_errorMessage = 'حدث خطأ في الاتصال بالخادم';
|
|
});
|
|
}
|
|
}
|
|
|
|
String _getStatusText() {
|
|
switch (_status) {
|
|
case 'waiting':
|
|
return _isFlashCall
|
|
? '⏳ بانتظار المكالمة...'
|
|
: '⏳ بانتظار الرسالة...';
|
|
case 'call_detected':
|
|
return '📞 جاءت المكالمة! جارٍ التحقق...';
|
|
case 'verified':
|
|
return '✅ تم التحقق بنجاح!';
|
|
case 'timeout':
|
|
return '❌ انتهت المدة، أعد المحاولة';
|
|
case 'permission_denied':
|
|
return '⚠️ يرجى إدخال الرمز يدوياً';
|
|
default:
|
|
return '⏳ بانتظار المكالمة...';
|
|
}
|
|
}
|
|
|
|
Color _getStatusColor() {
|
|
switch (_status) {
|
|
case 'waiting':
|
|
return Colors.orange;
|
|
case 'call_detected':
|
|
return Colors.blue;
|
|
case 'verified':
|
|
return Colors.green;
|
|
case 'timeout':
|
|
return Colors.red;
|
|
case 'permission_denied':
|
|
return Colors.orange;
|
|
default:
|
|
return Colors.orange;
|
|
}
|
|
}
|
|
|
|
String _getTitleText() {
|
|
if (_status == 'verified') {
|
|
return '✅ تم التحقق بنجاح!';
|
|
}
|
|
if (_status == 'timeout') {
|
|
return 'انتهت المدة';
|
|
}
|
|
return _isFlashCall ? 'جارٍ الاتصال بك...' : 'جارٍ إرسال الرمز...';
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_countdownTimer?.cancel();
|
|
_otpController.dispose();
|
|
_manualOtpController.dispose();
|
|
_ringAnimationController.dispose();
|
|
_pulseAnimationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Directionality(
|
|
textDirection: TextDirection.rtl,
|
|
child: PopScope(
|
|
canPop: true,
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
if (didPop) {
|
|
_otpController.stopWaiting();
|
|
_countdownTimer?.cancel();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: AppBar(
|
|
title: const Text('التحقق من الرقم'),
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () {
|
|
_otpController.stopWaiting();
|
|
_countdownTimer?.cancel();
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 20),
|
|
|
|
// Title
|
|
Text(
|
|
_getTitleText(),
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: _getStatusColor(),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
|
|
const SizedBox(height: 40),
|
|
|
|
// Animated phone icon
|
|
if (_status == 'waiting' || _status == 'call_detected')
|
|
AnimatedBuilder(
|
|
animation: Listenable.merge(
|
|
[_ringAnimationController, _pulseAnimationController]),
|
|
builder: (context, child) {
|
|
final ringOffset = _ringAnimationController.value * 10 - 5;
|
|
return Transform.translate(
|
|
offset: Offset(ringOffset, 0),
|
|
child: Transform.scale(
|
|
scale: _pulseAnimation.value,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.shade50,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.green.withValues(alpha: 0.3),
|
|
blurRadius: 20,
|
|
spreadRadius: 5,
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
_isFlashCall
|
|
? Icons.phone_in_talk_rounded
|
|
: Icons.sms_rounded,
|
|
size: 50,
|
|
color: Colors.green,
|
|
),
|
|
),
|
|
)
|
|
else if (_status == 'verified')
|
|
Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.shade50,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.check_circle_rounded,
|
|
size: 50,
|
|
color: Colors.green,
|
|
),
|
|
)
|
|
else if (_status == 'timeout')
|
|
Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.timer_off_rounded,
|
|
size: 50,
|
|
color: Colors.red.shade400,
|
|
),
|
|
)
|
|
else
|
|
Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade50,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.warning_amber_rounded,
|
|
size: 50,
|
|
color: Colors.orange.shade400,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Countdown timer
|
|
if (_status == 'waiting' || _status == 'call_detected')
|
|
Column(
|
|
children: [
|
|
Text(
|
|
'الوقت المتبقي',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: _countdown <= 30
|
|
? Colors.red
|
|
: Colors.green,
|
|
width: 3,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'$_countdown',
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
color: _countdown <= 30
|
|
? Colors.red
|
|
: Colors.green,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Status text
|
|
Container(
|
|
width: double.infinity,
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: _getStatusColor().withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: _getStatusColor().withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
child: Text(
|
|
_getStatusText(),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: _getStatusColor(),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Phone number display
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
widget.phone,
|
|
textDirection: TextDirection.ltr,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey.shade600,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Loading indicator when verifying
|
|
if (_isVerifying)
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 24),
|
|
child: CircularProgressIndicator(color: Colors.green),
|
|
),
|
|
|
|
// Error message
|
|
if (_errorMessage != null)
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.red.shade200),
|
|
),
|
|
child: Text(
|
|
_errorMessage!,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.red.shade700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Manual fallback link (Android flash call only)
|
|
if (!_showManualInput &&
|
|
_isFlashCall &&
|
|
!_isIOS &&
|
|
_status == 'waiting')
|
|
TextButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_showManualInput = true;
|
|
});
|
|
},
|
|
child: const Text(
|
|
'أدخل الكود يدوياً',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.green,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Manual OTP input
|
|
if (_showManualInput) ...[
|
|
const SizedBox(height: 8),
|
|
|
|
Text(
|
|
'أدخل رمز التحقق المكون من 4 أرقام',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// 4-digit OTP input
|
|
TextField(
|
|
controller: _manualOtpController,
|
|
keyboardType: TextInputType.number,
|
|
textDirection: TextDirection.ltr,
|
|
textAlign: TextAlign.center,
|
|
maxLength: 4,
|
|
autofocus: _isIOS,
|
|
autofillHints: _isIOS
|
|
? const [AutofillHints.oneTimeCode]
|
|
: null,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
],
|
|
decoration: InputDecoration(
|
|
hintText: '• • • •',
|
|
hintStyle: TextStyle(
|
|
color: Colors.grey.shade400,
|
|
fontSize: 24,
|
|
letterSpacing: 12,
|
|
),
|
|
counterText: '',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide:
|
|
const BorderSide(color: Colors.green, width: 2),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16, vertical: 16),
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
),
|
|
style: const TextStyle(
|
|
fontSize: 28,
|
|
letterSpacing: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
onSubmitted: (_) {
|
|
if (!_isVerifying) {
|
|
_manualVerify();
|
|
}
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// Verify button
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 52,
|
|
child: ElevatedButton(
|
|
onPressed: _isVerifying ? null : _manualVerify,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
disabledBackgroundColor: Colors.green.shade300,
|
|
disabledForegroundColor: Colors.white70,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
child: _isVerifying
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(
|
|
color: Colors.white,
|
|
strokeWidth: 2.5,
|
|
),
|
|
)
|
|
: const Text(
|
|
'تحقق',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
// Retry button on timeout
|
|
if (_status == 'timeout') ...[
|
|
const SizedBox(height: 24),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 52,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
child: const Text(
|
|
'إعادة المحاولة',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|