first commit
This commit is contained in:
24
receiver_app_new/lib/main.dart
Normal file
24
receiver_app_new/lib/main.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
689
receiver_app_new/lib/screens/otp_wait_screen.dart
Normal file
689
receiver_app_new/lib/screens/otp_wait_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
452
receiver_app_new/lib/screens/phone_input_screen.dart
Normal file
452
receiver_app_new/lib/screens/phone_input_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
171
receiver_app_new/lib/screens/success_screen.dart
Normal file
171
receiver_app_new/lib/screens/success_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
receiver_app_new/lib/services/api_service.dart
Normal file
104
receiver_app_new/lib/services/api_service.dart
Normal 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'};
|
||||
}
|
||||
}
|
||||
}
|
||||
100
receiver_app_new/lib/services/otp_controller.dart
Normal file
100
receiver_app_new/lib/services/otp_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user