first commit

This commit is contained in:
Hamza-Ayed
2026-05-23 16:17:20 +03:00
commit 2bbaa1ee16
195 changed files with 11126 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'screens/phone_input_screen.dart';
void main() {
runApp(const FlashOtpReceiverApp());
}
class FlashOtpReceiverApp extends StatelessWidget {
const FlashOtpReceiverApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flash OTP',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
fontFamily: 'Cairo',
),
home: const PhoneInputScreen(),
);
}
}

View File

@@ -0,0 +1,689 @@
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,
),
),
),
),
],
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,452 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/api_service.dart';
import 'otp_wait_screen.dart';
class PhoneInputScreen extends StatefulWidget {
const PhoneInputScreen({super.key});
@override
State<PhoneInputScreen> createState() => _PhoneInputScreenState();
}
class _PhoneInputScreenState extends State<PhoneInputScreen> {
final TextEditingController _phoneController = TextEditingController();
String _deviceType = Platform.isIOS ? 'ios' : 'android';
bool _isLoading = false;
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
String _getFullPhone() {
final digits = _phoneController.text.trim();
return '+962$digits';
}
bool _validatePhone() {
final digits = _phoneController.text.trim();
// Jordanian phone numbers: 9 digits starting with 7
if (digits.length != 9) {
return false;
}
if (!digits.startsWith('7')) {
return false;
}
return true;
}
Future<void> _sendOtp() async {
if (!_validatePhone()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Directionality(
textDirection: TextDirection.rtl,
child: Text('يرجى إدخال رقم هاتف صحيح (9 أرقام تبدأ بـ 7)'),
),
backgroundColor: Colors.red,
duration: Duration(seconds: 3),
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
final phone = _getFullPhone();
final result = await ApiService.requestOtp(phone, _deviceType);
if (!mounted) return;
if (result['success'] == true) {
final method = result['method'] as String? ?? 'flash_call';
final expiresIn = result['expires_in'] as int? ?? 120;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => OtpWaitScreen(
phone: phone,
method: method,
expiresIn: expiresIn,
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Directionality(
textDirection: TextDirection.rtl,
child:
Text(result['message'] ?? 'حدث خطأ، أعد المحاولة'),
),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Directionality(
textDirection: TextDirection.rtl,
child: Text('حدث خطأ في الاتصال بالخادم'),
),
backgroundColor: Colors.red,
duration: Duration(seconds: 3),
),
);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 40),
// App Icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.green.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.phone_android_rounded,
size: 40,
color: Colors.green.shade700,
),
),
const SizedBox(height: 20),
// App Title
const Text(
'Flash OTP',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 8),
Text(
'تحقق من رقم هاتفك بسرعة',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 48),
// Phone Number Input
Align(
alignment: Alignment.centerRight,
child: Text(
'رقم الهاتف',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
),
const SizedBox(height: 8),
// Phone field with +962 prefix
Row(
textDirection: TextDirection.ltr,
children: [
// +962 prefix
Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: const BorderRadius.only(
topRight: Radius.zero,
bottomRight: Radius.zero,
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
child: Center(
child: Text(
'+962',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
),
// Phone number input
Expanded(
child: TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
textDirection: TextDirection.ltr,
textAlign: TextAlign.left,
maxLength: 9,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
hintText: '7XXXXXXXX',
hintStyle: TextStyle(color: Colors.grey.shade400),
counterText: '',
border: OutlineInputBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.zero,
bottomLeft: Radius.zero,
topRight: Radius.circular(12),
bottomRight: Radius.circular(12),
),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.zero,
bottomLeft: Radius.zero,
topRight: Radius.circular(12),
bottomRight: Radius.circular(12),
),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.zero,
bottomLeft: Radius.zero,
topRight: Radius.circular(12),
bottomRight: Radius.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: 18,
letterSpacing: 1.5,
),
),
),
],
),
const SizedBox(height: 24),
// Device Type Toggle
Align(
alignment: Alignment.centerRight,
child: Text(
'نوع الجهاز',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Android
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_deviceType = 'android';
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: _deviceType == 'android'
? Colors.green
: Colors.transparent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(11),
bottomRight: Radius.circular(11),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.android,
color: _deviceType == 'android'
? Colors.white
: Colors.grey.shade600,
size: 20,
),
const SizedBox(width: 6),
Text(
'Android',
style: TextStyle(
color: _deviceType == 'android'
? Colors.white
: Colors.grey.shade600,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
// Divider
Container(
width: 1,
height: 30,
color: Colors.grey.shade300,
),
// iOS
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_deviceType = 'ios';
});
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: _deviceType == 'ios'
? Colors.green
: Colors.transparent,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(11),
bottomLeft: Radius.circular(11),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.phone_iphone,
color: _deviceType == 'ios'
? Colors.white
: Colors.grey.shade600,
size: 20,
),
const SizedBox(width: 6),
Text(
'iOS',
style: TextStyle(
color: _deviceType == 'ios'
? Colors.white
: Colors.grey.shade600,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
],
),
),
const SizedBox(height: 48),
// Send OTP Button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _sendOtp,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.green.shade300,
disabledForegroundColor: Colors.white70,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: const Text(
'إرسال رمز التحقق',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 24),
// Info text
Text(
_deviceType == 'android'
? 'سيتم الاتصال برقمك تلقائياً لاستلام الرمز'
: 'سيتم إرسال رمز التحقق عبر الرسائل النصية',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'phone_input_screen.dart';
class SuccessScreen extends StatefulWidget {
final String phone;
const SuccessScreen({super.key, required this.phone});
@override
State<SuccessScreen> createState() => _SuccessScreenState();
}
class _SuccessScreenState extends State<SuccessScreen>
with SingleTickerProviderStateMixin {
late AnimationController _scaleController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_scaleAnimation = CurvedAnimation(
parent: _scaleController,
curve: Curves.elasticOut,
);
// Start the animation
_scaleController.forward();
}
@override
void dispose() {
_scaleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animated checkmark
ScaleTransition(
scale: _scaleAnimation,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.green.shade50,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.green.withValues(alpha: 0.2),
blurRadius: 30,
spreadRadius: 10,
),
],
),
child: Icon(
Icons.check_circle_rounded,
size: 70,
color: Colors.green.shade600,
),
),
),
const SizedBox(height: 40),
// Success text
const Text(
'✅ تم التحقق من رقمك بنجاح',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 24),
// Phone number
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
children: [
Text(
'رقم الهاتف',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 4),
Text(
widget.phone,
textDirection: TextDirection.ltr,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
letterSpacing: 1,
),
),
],
),
),
const SizedBox(height: 48),
// Continue button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) => const PhoneInputScreen(),
),
(route) => false,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: const Text(
'متابعة',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 40),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
static const String _baseUrl = 'https://otp.intaleqapp.com/api/';
static const String _appKey =
'f3a9e7c1b8d5f2a4c6e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1';
/// Request an OTP to be sent to the given phone number
/// Returns a map with {success, expires_in, method}
static Future<Map<String, dynamic>> requestOtp(
String phone,
String deviceType,
) async {
try {
final response = await http
.post(
Uri.parse('${_baseUrl}request-otp.php'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phone': phone,
'device_type': deviceType,
'app_key': _appKey,
}),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return {
'success': data['success'] ?? false,
'expires_in': data['expires_in'] ?? 120,
'method': data['method'] ?? 'flash_call',
};
} else {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return {
'success': false,
'expires_in': 0,
'method': 'flash_call',
'message': data['message'] ?? 'Server error: ${response.statusCode}',
};
}
} on http.ClientException catch (e) {
return {
'success': false,
'expires_in': 0,
'method': 'flash_call',
'message': 'Network error: ${e.message}',
};
} on FormatException {
return {
'success': false,
'expires_in': 0,
'method': 'flash_call',
'message': 'Invalid server response',
};
} catch (e) {
return {
'success': false,
'expires_in': 0,
'method': 'flash_call',
'message': 'Connection error: $e',
};
}
}
/// Verify an OTP code for the given phone number
/// Returns a map with {success, message}
static Future<Map<String, dynamic>> verifyOtp(
String phone,
String otp,
) async {
try {
final response = await http
.post(
Uri.parse('${_baseUrl}verify-otp.php'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'phone': phone, 'otp': otp, 'app_key': _appKey}),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return {
'success': data['success'] ?? false,
'message': data['message'] ?? 'Verification completed',
};
} else {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return {
'success': false,
'message': data['message'] ?? 'Server error: ${response.statusCode}',
};
}
} on http.ClientException catch (e) {
return {'success': false, 'message': 'Network error: ${e.message}'};
} on FormatException {
return {'success': false, 'message': 'Invalid server response'};
} catch (e) {
return {'success': false, 'message': 'Connection error: $e'};
}
}
}

View File

@@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:io';
import 'package:call_log/call_log.dart';
import 'package:permission_handler/permission_handler.dart';
class OtpController {
DateTime? _startTime;
Timer? _pollTimer;
String? _detectedOtp;
bool _isPolling = false;
// Callbacks
Function(String status)? onStatusChanged;
Function(String otp)? onOtpDetected;
Function()? onTimeout;
/// Start polling call log for missed calls
Future<bool> startWaiting(String phone) async {
if (Platform.isIOS) {
// iOS: cannot read call log, skip to manual entry
onStatusChanged?.call('manual_ios');
return false;
}
// Request phone permissions (usually covers call log on many Android versions)
var status = await Permission.phone.request();
if (!status.isGranted) {
onStatusChanged?.call('permission_denied');
return false;
}
_startTime = DateTime.now();
_isPolling = true;
onStatusChanged?.call('waiting');
// Poll every 1.5 seconds
_pollTimer = Timer.periodic(const Duration(milliseconds: 1500), (_) {
_checkCallLog(phone);
});
// Timeout after 120 seconds
Future.delayed(const Duration(seconds: 120), () {
if (_isPolling && _detectedOtp == null) {
stopWaiting();
onTimeout?.call();
}
});
return true;
}
/// Check call log for missed calls since _startTime
Future<void> _checkCallLog(String phone) async {
try {
final Iterable<CallLogEntry> entries = await CallLog.query(
dateFrom: _startTime!.millisecondsSinceEpoch,
);
for (var entry in entries) {
if (entry.callType == CallType.missed && entry.number != null) {
final otp = extractOTP(entry.number!);
if (otp.length == 4) {
_detectedOtp = otp;
stopWaiting();
onStatusChanged?.call('call_detected');
onOtpDetected?.call(otp);
return;
}
}
}
} catch (e) {
// Silently continue polling
}
}
/// Extract OTP from caller number (last 4 digits)
String extractOTP(String callerNumber) {
final digitsOnly = callerNumber.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length >= 4) {
return digitsOnly.substring(digitsOnly.length - 4);
}
return digitsOnly;
}
/// Stop polling
void stopWaiting() {
_isPolling = false;
_pollTimer?.cancel();
_pollTimer = null;
}
/// Dispose resources
void dispose() {
stopWaiting();
onStatusChanged = null;
onOtpDetected = null;
onTimeout = null;
}
}