Files
flash-call-otp/receiver_app_new/lib/screens/otp_wait_screen.dart
2026-05-23 16:17:20 +03:00

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,
),
),
),
),
],
],
),
),
),
),
);
}
}