diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d0b2280 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "finger_print_app", + "request": "launch", + "type": "dart" + }, + { + "name": "finger_print_app (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "finger_print_app (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/lib/data/datasources/attendance_remote_data_source.dart b/lib/data/datasources/attendance_remote_data_source.dart index ac9ff47..bed6ba6 100644 --- a/lib/data/datasources/attendance_remote_data_source.dart +++ b/lib/data/datasources/attendance_remote_data_source.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import '../../core/error/exceptions.dart'; import '../../core/network/api_client.dart'; import '../dto/attendance_response_dto.dart'; @@ -24,17 +25,62 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { required String employeeId, required File faceImage, }) async { - final formData = FormData.fromMap({ - 'EmployeeId': employeeId, - 'FaceImage': await MultipartFile.fromFile(faceImage.path), - }); + try { + final formData = FormData.fromMap({ + 'EmployeeId': employeeId, + 'FaceImage': await MultipartFile.fromFile(faceImage.path), + }); - final response = await apiClient.post( - '/Attendance/login', - data: formData, - options: Options(contentType: 'multipart/form-data'), - ); - return AttendanceResponseDto.fromJson(response.data); + final response = await apiClient.post( + '/Attendance/login', + data: formData, + options: Options(contentType: 'multipart/form-data'), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = response.data; + + if (responseData is Map) { + return AttendanceResponseDto.fromJson(responseData); + } else { + throw ServerException( + message: 'استجابة غير صحيحة من الخادم', + statusCode: response.statusCode, + ); + } + } else { + throw ServerException( + message: 'فشل تسجيل الدخول', + statusCode: response.statusCode, + ); + } + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException(message: 'انتهت مهلة الاتصال'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException(message: 'لا يوجد اتصال بالانترنيت'); + } else if (e.response?.statusCode == 500) { + throw ServerException(message: 'خطأ في الخادم يرجى المحاولة لاحقا'); + } else if (e.response != null) { + final message = + e.response?.data?['message'] ?? + e.response?.data?['error'] ?? + 'فشل تسجيل الدخول'; + + throw ServerException( + message: message.toString(), + statusCode: e.response?.statusCode, + ); + } else { + throw NetworkException(message: 'خطأ في الانترنيت يرجى المحاولة لاحقا'); + } + } catch (e) { + if (e is ServerException || e is NetworkException) { + rethrow; + } + throw ServerException(message: 'خطأ غير متوقع'); + } } @override @@ -42,17 +88,61 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { required String employeeId, required File faceImage, }) async { - final formData = FormData.fromMap({ - 'EmployeeId': employeeId, - 'FaceImage': await MultipartFile.fromFile(faceImage.path), - }); + try { + final formData = FormData.fromMap({ + 'EmployeeId': employeeId, + 'FaceImage': await MultipartFile.fromFile(faceImage.path), + }); - final response = await apiClient.post( - '/Attendance/logout', - data: formData, - options: Options(contentType: 'multipart/form-data'), - ); + final response = await apiClient.post( + '/Attendance/logout', + data: formData, + options: Options(contentType: 'multipart/form-data'), + ); - return AttendanceResponseDto.fromJson(response.data); + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = response.data; + + if (responseData is Map) { + return AttendanceResponseDto.fromJson(responseData); + } else { + throw ServerException( + message: 'استجابة غير صحيحة من الخادم', + statusCode: response.statusCode, + ); + } + } else { + throw ServerException( + message: 'فشل تسجيل الخروج', + statusCode: response.statusCode, + ); + } + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException(message: 'انتهت مهلة الاتصال'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException(message: 'لا يوجد اتصال بالانترنيت'); + } else if (e.response?.statusCode == 500) { + throw ServerException(message: 'خطأ في الخادم يرجى المحاولة لاحقا'); + } else if (e.response != null) { + final message = + e.response?.data?['message'] ?? + e.response?.data?['error'] ?? + 'فشل تسجيل الخروج'; + + throw ServerException( + message: message.toString(), + statusCode: e.response?.statusCode, + ); + } else { + throw NetworkException(message: 'خطأ في الانترنيت يرجى المحاولة لاحقا'); + } + } catch (e) { + if (e is ServerException || e is NetworkException) { + rethrow; + } + throw ServerException(message: 'خطأ غير متوقع'); + } } } diff --git a/lib/presentation/screens/face_screen.dart b/lib/presentation/screens/face_screen.dart index cd670b0..d9591cd 100644 --- a/lib/presentation/screens/face_screen.dart +++ b/lib/presentation/screens/face_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'dart:async'; import 'dart:io'; +import '../../core/error/exceptions.dart'; class OvalCameraCapturePage extends StatefulWidget { final bool isLogin; @@ -23,6 +24,7 @@ class _OvalCameraCapturePageState extends State { bool _isCameraInitialized = false; String? _errorMessage; bool _isSuccess = false; + bool _isLoading = false; Timer? _timer; @override @@ -33,6 +35,11 @@ class _OvalCameraCapturePageState extends State { Future _initializeCamera() async { try { + setState(() { + _errorMessage = null; + _isCameraInitialized = false; + }); + // Dispose existing controller if any await _cameraController?.dispose(); _cameraController = null; @@ -42,8 +49,9 @@ class _OvalCameraCapturePageState extends State { // Check if cameras list is available if (cameras.isEmpty) { + if (!mounted) return; setState(() { - _errorMessage = "لا توجد كاميرات متاحة"; + _errorMessage = "لا توجد كاميرات متاحة على هذا الجهاز"; _isCameraInitialized = false; }); return; @@ -60,8 +68,9 @@ class _OvalCameraCapturePageState extends State { if (cameras.isNotEmpty) { selectedCamera = cameras.first; } else { + if (!mounted) return; setState(() { - _errorMessage = "لا توجد كاميرات متاحة"; + _errorMessage = "لا توجد كاميرات متاحة على هذا الجهاز"; _isCameraInitialized = false; }); return; @@ -85,11 +94,33 @@ class _OvalCameraCapturePageState extends State { }); _startScan(); + } on CameraException catch (e) { + if (!mounted) return; + String errorMessage; + switch (e.code) { + case 'CameraAccessDenied': + errorMessage = "تم رفض الوصول إلى الكاميرا. يرجى السماح بالوصول في الإعدادات"; + break; + case 'CameraAccessDeniedWithoutPrompt': + errorMessage = "تم رفض الوصول إلى الكاميرا. يرجى السماح بالوصول في الإعدادات"; + break; + case 'CameraAccessRestricted': + errorMessage = "الوصول إلى الكاميرا مقيد"; + break; + case 'AudioAccessDenied': + errorMessage = "تم رفض الوصول إلى الميكروفون"; + break; + default: + errorMessage = "خطأ في تهيئة الكاميرا: ${e.description ?? 'خطأ غير معروف'}"; + } + setState(() { + _errorMessage = errorMessage; + _isCameraInitialized = false; + }); } catch (e) { if (!mounted) return; - setState(() { - _errorMessage = "خطأ في تهيئة الكاميرا: $e"; + _errorMessage = "حدث خطأ غير متوقع أثناء تهيئة الكاميرا"; _isCameraInitialized = false; }); print("Error initializing camera: $e"); @@ -98,46 +129,104 @@ class _OvalCameraCapturePageState extends State { Future _startScan() async { try { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + // Simulate scanning delay await Future.delayed(const Duration(seconds: 2)); if (!mounted || _cameraController == null || !_cameraController!.value.isInitialized) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = "الكاميرا غير مهيأة. يرجى المحاولة مرة أخرى"; + }); + } return; } final xFile = await _cameraController!.takePicture(); final file = File(xFile.path); + // Check if file exists and is readable + if (!await file.exists()) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = "فشل حفظ الصورة. يرجى المحاولة مرة أخرى"; + }); + } + return; + } + + // Call the onCapture callback which may throw exceptions await widget.onCapture(file); if (mounted) { setState(() { _isSuccess = true; + _isLoading = false; }); // Auto-close after 2 seconds Future.delayed(const Duration(seconds: 2), () { if (mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pop(true); } }); } + } on ServerException catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.message; + }); + } + } on NetworkException catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.message; + }); + } + } on CameraException catch (e) { + if (mounted) { + String errorMessage; + switch (e.code) { + case 'captureAlreadyActive': + errorMessage = "جاري التقاط صورة بالفعل. يرجى الانتظار"; + break; + case 'pictureTakingInProgress': + errorMessage = "جاري التقاط صورة. يرجى الانتظار"; + break; + default: + errorMessage = "فشل التقاط الصورة: ${e.description ?? 'خطأ غير معروف'}"; + } + setState(() { + _isLoading = false; + _errorMessage = errorMessage; + }); + } + } on FileSystemException catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = "فشل حفظ الصورة. يرجى التحقق من مساحة التخزين"; + }); + } + print("File system error: $e"); } catch (e) { if (mounted) { setState(() { - _errorMessage = e.toString(); // Show error to user - }); - - // Auto-close or let user retry? For now let's just show error. - // If we want to auto-close on error: - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - Navigator.of(context).pop(); - } + _isLoading = false; + _errorMessage = "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى"; }); } + print("Unexpected error in _startScan: $e"); } } @@ -154,7 +243,8 @@ class _OvalCameraCapturePageState extends State { backgroundColor: Color(0xff000000), body: - _errorMessage != null + // Show error screen only if camera failed to initialize + _errorMessage != null && !_isCameraInitialized ? Center( child: Padding( padding: const EdgeInsets.all(20.0), @@ -177,17 +267,39 @@ class _OvalCameraCapturePageState extends State { textAlign: TextAlign.center, ), SizedBox(height: 24), - ElevatedButton( - onPressed: _initializeCamera, - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xffE8001A), - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric( - horizontal: 32, - vertical: 12, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _initializeCamera, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xffE8001A), + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text("إعادة المحاولة"), ), - ), - child: Text("إعادة المحاولة"), + SizedBox(width: 16), + OutlinedButton( + onPressed: () { + if (mounted) { + Navigator.of(context).pop(false); + } + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white70), + padding: EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text("إلغاء"), + ), + ], ), ], ), @@ -239,9 +351,13 @@ class _OvalCameraCapturePageState extends State { ? (widget.isLogin ? "تم تسجيل دخولك بنجاح" : "تم تسجيل خروجك بنجاح") - : (widget.isLogin - ? "يتم تسجيل الدخول ..." - : "يتم تسجيل الخروج ..."), + : _isLoading + ? (widget.isLogin + ? "يتم تسجيل الدخول ..." + : "يتم تسجيل الخروج ...") + : (widget.isLogin + ? "جاهز للتقاط الصورة" + : "جاهز للتقاط الصورة"), style: const TextStyle( color: Colors.white, fontSize: 18, @@ -266,6 +382,81 @@ class _OvalCameraCapturePageState extends State { ), ), + // Error overlay (shown when error occurs during scanning) + if (_errorMessage != null && _isCameraInitialized) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.7), + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red, + ), + SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + setState(() { + _errorMessage = null; + _isLoading = false; + }); + _startScan(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xffE8001A), + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text("إعادة المحاولة"), + ), + SizedBox(width: 16), + OutlinedButton( + onPressed: () { + if (mounted) { + Navigator.of(context).pop(false); + } + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white70), + padding: EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text("إلغاء"), + ), + ], + ), + ], + ), + ), + ), + ), + ), + // // Capture button // Positioned( // bottom: 60, diff --git a/pubspec.lock b/pubspec.lock index 7c55b1f..98e1ebe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,7 +29,7 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted version: "2.13.0" @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -289,7 +289,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -324,10 +324,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -641,10 +641,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" web: dependency: transitive description: