diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index d936000..0463aaf 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -10,12 +10,9 @@ class ApiClient { ApiClient({required this.dio, this.sharedPreferences}) { dio.options = BaseOptions( baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(seconds: 30), - headers: { - 'Content-Type': 'application/json', - 'accept': 'text/plain', - }, + connectTimeout: const Duration(seconds: 60), + receiveTimeout: const Duration(seconds: 60), + headers: {'Content-Type': 'application/json', 'accept': 'text/plain'}, ); // Add interceptor to add token to requests diff --git a/lib/data/datasources/attendance_remote_data_source.dart b/lib/data/datasources/attendance_remote_data_source.dart index ad1dc23..23d4cb2 100644 --- a/lib/data/datasources/attendance_remote_data_source.dart +++ b/lib/data/datasources/attendance_remote_data_source.dart @@ -26,6 +26,8 @@ abstract class AttendanceRemoteDataSource { Future> getExtraHours({required String employeeId}); Future> getRewards({required String employeeId}); Future> getPunishments({required String employeeId}); + Future getLastRecord({required String employeeId}); + Future hasActiveLogin({required String employeeId}); Future calculateSalary({ required String employeeId, required int month, @@ -171,12 +173,10 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { queryParameters: {'IsDeleted': false, 'EmployeeId': employeeId}, ); - if (response.statusCode == 200) { + if (response.statusCode == 200 || response.statusCode == 201) { final data = response.data; - if (data is Map && - data['data'] != null && - data['data']['items'] is List) { - final items = data['data']['items'] as List; + if (data is Map) { + final items = (data['data'] ?? data['Data'])?['items'] as List? ?? []; return items.map((e) => AttendanceRecordDto.fromJson(e)).toList(); } return []; @@ -187,15 +187,82 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { ); } } on DioException catch (e) { - throw ServerException( - message: e.message ?? 'Unknown error', - statusCode: e.response?.statusCode, - ); + _handleDioError(e, 'فشل في جلب البيانات'); + rethrow; // Should not reach here due to _handleDioError throwing } catch (e) { + if (e is ServerException || e is NetworkException) rethrow; throw ServerException(message: 'خطأ غير متوقع'); } } + @override + Future getLastRecord({ + required String employeeId, + }) async { + try { + final response = await apiClient.get( + '/Attendance', + queryParameters: { + 'IsDeleted': false, + 'EmployeeId': employeeId, + 'PageNumber': 1, + 'PageSize': 1, + 'SortBy': 'CreatedAt', + 'SortDescending': true, + }, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final data = response.data; + if (data is Map) { + final items = (data['data'] ?? data['Data'])?['items'] as List? ?? []; + if (items.isNotEmpty) { + return AttendanceRecordDto.fromJson(items.first); + } + } + return null; + } else { + throw ServerException( + message: 'فشل في جلب آخر سجل', + statusCode: response.statusCode, + ); + } + } on DioException catch (e) { + _handleDioError(e, 'فشل في جلب آخر سجل'); + rethrow; + } catch (e) { + if (e is ServerException || e is NetworkException) rethrow; + throw ServerException(message: 'خطأ غير متوقع'); + } + } + + @override + Future hasActiveLogin({required String employeeId}) async { + try { + final last = await getLastRecord(employeeId: employeeId); + if (last == null) return false; + + // The API enforces a DAILY check ("Employee already logged in today") + // So we check: has a login TODAY, regardless of logout status + if (last.login != null) { + final now = DateTime.now(); + final loginDate = last.login!; + if (loginDate.year == now.year && + loginDate.month == now.month && + loginDate.day == now.day) { + // Logged in today — if no logout, definitely active + // If logout exists, they already completed today's cycle + return last.logout == null; + } + } + return false; + } catch (e) { + // If the check fails, let the user try — the API will reject if needed + print('hasActiveLogin check failed: $e'); + return false; + } + } + @override Future> getExtraHours({required String employeeId}) async { try { @@ -204,7 +271,7 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { queryParameters: {'IsDeleted': false, 'EmployeeId': employeeId}, ); - if (response.statusCode == 200) { + if (response.statusCode == 200 || response.statusCode == 201) { final responseData = response.data; if (responseData is Map) { @@ -222,11 +289,10 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { ); } } on DioException catch (e) { - throw ServerException( - message: e.message ?? 'Unknown error', - statusCode: e.response?.statusCode, - ); + _handleDioError(e, 'فشل في جلب البيانات'); + rethrow; } catch (e) { + if (e is ServerException || e is NetworkException) rethrow; throw ServerException(message: 'خطأ غير متوقع'); } } @@ -239,7 +305,7 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { queryParameters: {'IsDeleted': false, 'EmployeeId': employeeId}, ); - if (response.statusCode == 200) { + if (response.statusCode == 200 || response.statusCode == 201) { final responseData = response.data; if (responseData is Map) { return RewardListResponseDto.fromJson(responseData).items; @@ -252,11 +318,10 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { ); } } on DioException catch (e) { - throw ServerException( - message: e.message ?? 'Unknown error', - statusCode: e.response?.statusCode, - ); + _handleDioError(e, 'فشل في جلب المكافآت'); + rethrow; } catch (e) { + if (e is ServerException || e is NetworkException) rethrow; throw ServerException(message: 'خطأ غير متوقع'); } } @@ -271,7 +336,7 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { queryParameters: {'IsDeleted': false, 'EmployeeId': employeeId}, ); - if (response.statusCode == 200) { + if (response.statusCode == 200 || response.statusCode == 201) { final responseData = response.data; if (responseData is Map) { return PunishmentListResponseDto.fromJson(responseData).items; @@ -284,11 +349,10 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { ); } } on DioException catch (e) { - throw ServerException( - message: e.message ?? 'Unknown error', - statusCode: e.response?.statusCode, - ); + _handleDioError(e, 'فشل في جلب البيانات'); + rethrow; } catch (e) { + if (e is ServerException || e is NetworkException) rethrow; throw ServerException(message: 'خطأ غير متوقع'); } } @@ -309,17 +373,12 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { }, ); - print('Salary Response Status: ${response.statusCode}'); if (response.statusCode == 200 || response.statusCode == 201) { final responseData = response.data; - print( - 'Salary Response Data: $responseData (${responseData.runtimeType})', - ); if (responseData is Map) { return SalaryResponseDto.fromJson(responseData); } else if (responseData is num) { - // Handle case where API returns raw number return SalaryResponseDto( isSuccess: true, message: 'Success', @@ -327,7 +386,6 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { ); } else if (responseData is String && double.tryParse(responseData) != null) { - // Handle case where API returns raw numeric string return SalaryResponseDto( isSuccess: true, message: 'Success', @@ -335,23 +393,49 @@ class AttendanceRemoteDataSourceImpl implements AttendanceRemoteDataSource { ); } else { throw ServerException( - message: 'استجابة غير صحيحة من الخادم: $responseData', + message: 'استجابة غير صحيحة من الخادم', statusCode: response.statusCode, ); } } else { throw ServerException( - message: 'فشل في حساب الراتب (Status: ${response.statusCode})', + message: 'فشل في حساب الراتب', statusCode: response.statusCode, ); } } on DioException catch (e) { - throw ServerException( - message: e.message ?? 'Unknown error', - statusCode: e.response?.statusCode, - ); + _handleDioError(e, 'فشل في حساب الراتب'); + rethrow; } catch (e) { + if (e is ServerException || e is NetworkException) rethrow; throw ServerException(message: 'خطأ غير متوقع'); } } + + void _handleDioError(DioException e, String defaultMessage) { + 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 data = e.response?.data; + String? message; + + if (data is Map) { + message = data['message']?.toString() ?? data['error']?.toString(); + } else if (data is String) { + message = data; + } + + throw ServerException( + message: message ?? defaultMessage, + statusCode: e.response?.statusCode, + ); + } else { + throw NetworkException(message: 'خطأ في الانترنيت يرجى المحاولة لاحقا'); + } + } } diff --git a/lib/data/repositories/attendance_repository_impl.dart b/lib/data/repositories/attendance_repository_impl.dart index 09abefa..07955b9 100644 --- a/lib/data/repositories/attendance_repository_impl.dart +++ b/lib/data/repositories/attendance_repository_impl.dart @@ -133,6 +133,34 @@ class AttendanceRepositoryImpl implements AttendanceRepository { .toList(); } + @override + Future getLastRecord({required String employeeId}) async { + final dto = await remoteDataSource.getLastRecord(employeeId: employeeId); + if (dto == null) return null; + + int? hours; + if (dto.login != null && dto.logout != null) { + hours = dto.logout!.difference(dto.login!).inHours; + } + + return AttendanceModel( + id: dto.id, + employeeId: dto.employeeId, + date: dto.createdAt ?? dto.login, + loginTime: dto.login, + logoutTime: dto.logout, + workHours: hours, + createdAt: dto.createdAt ?? dto.login, + reason: dto.reason, + isDeleted: dto.isDeleted, + ); + } + + @override + Future hasActiveLogin({required String employeeId}) async { + return remoteDataSource.hasActiveLogin(employeeId: employeeId); + } + @override Future calculateSalary({ required String employeeId, diff --git a/lib/domain/repositories/attendance_repository.dart b/lib/domain/repositories/attendance_repository.dart index e93b254..99f7a3c 100644 --- a/lib/domain/repositories/attendance_repository.dart +++ b/lib/domain/repositories/attendance_repository.dart @@ -20,6 +20,8 @@ abstract class AttendanceRepository { Future> getExtraHours({required String employeeId}); Future> getRewards({required String employeeId}); Future> getPunishments({required String employeeId}); + Future getLastRecord({required String employeeId}); + Future hasActiveLogin({required String employeeId}); Future calculateSalary({ required String employeeId, required int month, diff --git a/lib/presentation/face/face_feedback.dart b/lib/presentation/face/face_feedback.dart new file mode 100644 index 0000000..b9f22eb --- /dev/null +++ b/lib/presentation/face/face_feedback.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +enum FaceHintType { + noFace, + tooDark, + tooClose, + tooFar, + notCentered, + lookStraight, + holdStill, + good, +} + +class FaceFeedback { + final FaceHintType type; + final String message; + final double quality; // 0..1 (used for progress ring) + final Color borderColor; + + FaceFeedback({ + required this.type, + required this.message, + required this.quality, + required this.borderColor, + }); + + bool get isGood => type == FaceHintType.good; +} diff --git a/lib/presentation/screens/attendence_screen.dart b/lib/presentation/screens/attendence_screen.dart index b7aa96c..1f1e45a 100644 --- a/lib/presentation/screens/attendence_screen.dart +++ b/lib/presentation/screens/attendence_screen.dart @@ -1,4 +1,393 @@ -import 'package:coda_project/presentation/screens/face_screen.dart'; +// import 'package:coda_project/presentation/screens/face_screen2.dart'; +// import 'package:coda_project/presentation/screens/notifications_screen.dart'; +// import 'package:coda_project/presentation/screens/user_settings_screen.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_svg/flutter_svg.dart'; +// import '../widgets/settings_bar.dart'; +// import '../../core/di/injection_container.dart'; +// import '../../domain/models/attendance_login_request.dart'; +// import '../../domain/models/attendance_logout_request.dart'; +// import '../../domain/usecases/attendance_login_usecase.dart'; +// import '../../domain/usecases/attendance_logout_usecase.dart'; +// import '../../data/datasources/user_local_data_source.dart'; +// import '../../data/datasources/attendance_remote_data_source.dart'; + +// class AttendanceScreen extends StatelessWidget { +// const AttendanceScreen({super.key}); + +// @override +// Widget build(BuildContext context) { +// final screenWidth = MediaQuery.sizeOf(context).width; +// final screenHeight = MediaQuery.sizeOf(context).height; +// return Directionality( +// textDirection: TextDirection.ltr, +// child: Stack( +// children: [ +// SizedBox(height: MediaQuery.of(context).size.height), + +// /// ------------------------------ +// /// SETTINGS BAR (STATIC) +// /// ------------------------------ +// SafeArea( +// child: SettingsBar( +// selectedIndex: 0, +// showBackButton: false, +// iconPaths: ['assets/images/user.svg', 'assets/images/ball.svg'], +// onTap: (index) { +// if (index == 0) { +// Navigator.push( +// context, +// MaterialPageRoute( +// builder: (context) => UserSettingsScreen(), +// ), +// ); +// } else if (index == 1) { +// Navigator.push( +// context, +// MaterialPageRoute( +// builder: (context) => NotificationsScreen(), +// ), +// ); +// } +// }, +// ), +// ), + +// /// ------------------------------ +// /// GREETING TEXT +// /// ------------------------------ +// Positioned( +// top: +// screenHeight * +// 0.14, // moved down because settings bar now exists +// left: 0, +// right: 0, +// child: Center( +// child: Text( +// "صباح الخير, محمد", +// style: TextStyle( +// fontSize: 24, +// fontWeight: FontWeight.w600, +// color: Colors.white, +// shadows: [Shadow(color: Color(0x42000000), blurRadius: 6)], +// ), +// ), +// ), +// ), + +// /// ------------------------------ +// /// MAIN CARD AREA +// /// ------------------------------ +// Positioned( +// top: +// screenHeight * +// 0.2, // pushed down because of settings bar + greeting +// left: 0, +// right: 0, +// child: Center( +// child: Padding( +// padding: EdgeInsets.symmetric(vertical: screenHeight * 0.05), +// child: Stack( +// children: [ +// Container( +// height: screenHeight * 0.5, +// width: screenWidth * 0.7, +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(32), +// boxShadow: [ +// BoxShadow( +// color: Color(0x1F2B2B2B), +// blurRadius: 5, +// offset: Offset(10, -10), +// ), +// BoxShadow( +// color: Color(0xABCECECE), +// blurRadius: 5, +// offset: Offset(-2, 5), +// ), +// BoxShadow( +// color: Color.fromARGB(148, 2, 70, 35), +// blurRadius: 80, +// offset: Offset(0, 10), +// ), +// ], +// ), +// ), +// Container( +// height: screenHeight * 0.5, +// width: screenWidth * 0.7, +// decoration: BoxDecoration( +// color: Color(0x92757575), +// borderRadius: BorderRadius.circular(32), +// ), +// ), +// ], +// ), +// ), +// ), +// ), + +// /// ------------------------------ +// /// LOGIN BUTTON +// /// ------------------------------ +// Positioned( +// top: screenHeight * 0.21, +// left: screenWidth * 0.05, +// child: _ShadowedCard( +// shadow: [ +// BoxShadow( +// color: Color(0x62000000), +// blurRadius: 10, +// spreadRadius: 5, +// offset: Offset(5, 5), +// ), +// ], +// child: _FingerButton( +// icon: "assets/images/faceLogin.svg", +// label: "تسجيل الدخول", +// onTap: () async { +// final employeeId = +// await sl().getCachedEmployeeId(); +// print("ATTENDANCE_SCREEN: Retrieved EmployeeId: $employeeId"); +// if (employeeId == null) { +// if (context.mounted) { +// ScaffoldMessenger.of(context).showSnackBar( +// const SnackBar( +// content: Text('خطأ: لم يتم العثور على رقم الموظف'), +// ), +// ); +// } +// return; +// } + +// // ------------------------------ +// // ACTIVE SESSION CHECK (LOGIN) +// // ------------------------------ +// try { +// // Optional: Show a loading dialog if it takes too long +// final hasActive = await sl() +// .hasActiveLogin(employeeId: employeeId); + +// if (hasActive) { +// if (context.mounted) { +// ScaffoldMessenger.of(context).showSnackBar( +// const SnackBar(content: Text('أنت مسجل دخول بالفعل')), +// ); +// } +// return; +// } +// } catch (e) { +// if (context.mounted) { +// ScaffoldMessenger.of(context).showSnackBar( +// SnackBar(content: Text('فشل التحقق من الجلسة: $e')), +// ); +// } +// return; +// } + +// if (context.mounted) { +// Navigator.of(context).push( +// MaterialPageRoute( +// builder: +// (_) => OvalCameraCapturePage( +// isLogin: true, +// onCapture: (imageFile) async { +// final loginUseCase = +// sl(); +// await loginUseCase( +// AttendanceLoginRequest( +// employeeId: employeeId, +// faceImage: imageFile, +// ), +// ); +// }, +// checkIfLoggedIn: () {}, +// ), +// ), +// ); +// } +// }, +// ), +// ), +// ), + +// /// ------------------------------ +// /// LOGOUT BUTTON +// /// ------------------------------ +// Positioned( +// bottom: screenHeight * 0.2, +// right: screenWidth * 0.1, +// child: _ShadowedCard( +// shadow: [ +// BoxShadow( +// color: Color(0xABCECECE), +// blurRadius: 5, +// spreadRadius: 3, +// offset: Offset(-6, -6), +// ), +// BoxShadow( +// color: Color(0x92014221), +// blurRadius: 10, +// offset: Offset(-5, -5), +// ), +// BoxShadow( +// color: Color(0x7D1A1A1A), +// blurRadius: 10, +// spreadRadius: 3, +// offset: Offset(5, 5), +// ), +// ], +// child: _FingerButton( +// icon: "assets/images/faceLogout.svg", +// label: "تسجيل خروج", +// onTap: () async { +// final employeeId = +// await sl().getCachedEmployeeId(); +// if (employeeId == null) { +// if (context.mounted) { +// ScaffoldMessenger.of(context).showSnackBar( +// const SnackBar( +// content: Text('خطأ: لم يتم العثور على رقم الموظف'), +// ), +// ); +// } +// return; +// } + +// // ------------------------------ +// // ACTIVE SESSION CHECK (LOGOUT) +// // ------------------------------ +// try { +// final hasActive = await sl() +// .hasActiveLogin(employeeId: employeeId); + +// if (!hasActive) { +// if (context.mounted) { +// ScaffoldMessenger.of(context).showSnackBar( +// const SnackBar( +// content: Text( +// 'لا يوجد تسجيل دخول فعال لتسجيل الخروج', +// ), +// ), +// ); +// } +// return; +// } +// } catch (e) { +// if (context.mounted) { +// ScaffoldMessenger.of(context).showSnackBar( +// SnackBar(content: Text('فشل التحقق من الجلسة: $e')), +// ); +// } +// return; +// } + +// if (context.mounted) { +// Navigator.of(context).push( +// MaterialPageRoute( +// builder: +// (_) => OvalCameraCapturePage( +// isLogin: false, +// onCapture: (imageFile) async { +// final logoutUseCase = +// sl(); +// await logoutUseCase( +// AttendanceLogoutRequest( +// employeeId: employeeId, +// faceImage: imageFile, +// ), +// ); +// }, +// checkIfLoggedIn: () {}, +// ), +// ), +// ); +// } +// }, +// ), +// ), +// ), +// ], +// ), +// ); +// } +// } + +// /// --------------------------------------------- +// /// SHADOW WRAPPER +// /// --------------------------------------------- + +// class _ShadowedCard extends StatelessWidget { +// final Widget child; +// final List shadow; + +// const _ShadowedCard({required this.child, required this.shadow}); + +// @override +// Widget build(BuildContext context) { +// return Stack( +// children: [ +// Container( +// height: 160, +// width: 160, +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(32), +// boxShadow: shadow, +// ), +// ), +// child, +// ], +// ); +// } +// } + +// /// --------------------------------------------- +// /// BUTTON WIDGET +// /// --------------------------------------------- + +// class _FingerButton extends StatelessWidget { +// final String icon; +// final String label; +// final VoidCallback onTap; + +// const _FingerButton({ +// required this.icon, +// required this.label, +// required this.onTap, +// }); + +// @override +// Widget build(BuildContext context) { +// return GestureDetector( +// onTap: onTap, +// child: Container( +// height: 160, +// width: 160, +// decoration: BoxDecoration( +// color: Color(0xFFEAFBF3), +// borderRadius: BorderRadius.circular(32), +// ), +// child: Column( +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// SvgPicture.asset(icon, width: 75, height: 75), +// SizedBox(height: 10), +// Text( +// label, +// style: TextStyle( +// fontSize: 18, +// fontWeight: FontWeight.w600, +// color: Colors.black, +// ), +// ), +// ], +// ), +// ), +// ); +// } +// } + +import 'package:coda_project/presentation/screens/face_screen2.dart'; import 'package:coda_project/presentation/screens/notifications_screen.dart'; import 'package:coda_project/presentation/screens/user_settings_screen.dart'; import 'package:flutter/material.dart'; @@ -10,6 +399,7 @@ import '../../domain/models/attendance_logout_request.dart'; import '../../domain/usecases/attendance_login_usecase.dart'; import '../../domain/usecases/attendance_logout_usecase.dart'; import '../../data/datasources/user_local_data_source.dart'; +import '../../data/datasources/attendance_remote_data_source.dart'; class AttendanceScreen extends StatelessWidget { const AttendanceScreen({super.key}); @@ -37,14 +427,14 @@ class AttendanceScreen extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => UserSettingsScreen(), + builder: (context) => const UserSettingsScreen(), ), ); } else if (index == 1) { Navigator.push( context, MaterialPageRoute( - builder: (context) => NotificationsScreen(), + builder: (context) => const NotificationsScreen(), ), ); } @@ -56,12 +446,10 @@ class AttendanceScreen extends StatelessWidget { /// GREETING TEXT /// ------------------------------ Positioned( - top: - screenHeight * - 0.14, // moved down because settings bar now exists + top: screenHeight * 0.14, left: 0, right: 0, - child: Center( + child: const Center( child: Text( "صباح الخير, محمد", style: TextStyle( @@ -78,9 +466,7 @@ class AttendanceScreen extends StatelessWidget { /// MAIN CARD AREA /// ------------------------------ Positioned( - top: - screenHeight * - 0.2, // pushed down because of settings bar + greeting + top: screenHeight * 0.2, left: 0, right: 0, child: Center( @@ -94,20 +480,20 @@ class AttendanceScreen extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(32), boxShadow: [ - BoxShadow( + const BoxShadow( color: Color(0x1F2B2B2B), blurRadius: 5, offset: Offset(10, -10), ), - BoxShadow( + const BoxShadow( color: Color(0xABCECECE), blurRadius: 5, offset: Offset(-2, 5), ), BoxShadow( - color: Color.fromARGB(148, 2, 70, 35), + color: const Color.fromARGB(148, 2, 70, 35), blurRadius: 80, - offset: Offset(0, 10), + offset: const Offset(0, 10), ), ], ), @@ -116,7 +502,7 @@ class AttendanceScreen extends StatelessWidget { height: screenHeight * 0.5, width: screenWidth * 0.7, decoration: BoxDecoration( - color: Color(0x92757575), + color: const Color(0x92757575), borderRadius: BorderRadius.circular(32), ), ), @@ -134,7 +520,7 @@ class AttendanceScreen extends StatelessWidget { left: screenWidth * 0.05, child: _ShadowedCard( shadow: [ - BoxShadow( + const BoxShadow( color: Color(0x62000000), blurRadius: 10, spreadRadius: 5, @@ -158,6 +544,47 @@ class AttendanceScreen extends StatelessWidget { } return; } + + // ACTIVE SESSION CHECK (LOGIN) + try { + final hasActive = await sl() + .hasActiveLogin(employeeId: employeeId); + + if (hasActive) { + if (context.mounted) { + showDialog( + context: context, + builder: + (_) => AlertDialog( + title: const Text( + 'تنبيه', + textAlign: TextAlign.center, + ), + content: const Text( + 'أنت مسجل دخول بالفعل، لا يمكنك تسجيل الدخول مرة أخرى.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: + () => Navigator.of(context).pop(), + child: const Text('حسناً'), + ), + ], + ), + ); + } + return; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('فشل التحقق من الجلسة: $e')), + ); + } + return; + } + if (context.mounted) { Navigator.of(context).push( MaterialPageRoute( @@ -190,7 +617,7 @@ class AttendanceScreen extends StatelessWidget { bottom: screenHeight * 0.2, right: screenWidth * 0.1, child: _ShadowedCard( - shadow: [ + shadow: const [ BoxShadow( color: Color(0xABCECECE), blurRadius: 5, @@ -225,6 +652,47 @@ class AttendanceScreen extends StatelessWidget { } return; } + + // ACTIVE SESSION CHECK (LOGOUT) + try { + final hasActive = await sl() + .hasActiveLogin(employeeId: employeeId); + + if (!hasActive) { + if (context.mounted) { + showDialog( + context: context, + builder: + (_) => AlertDialog( + title: const Text( + 'تنبيه', + textAlign: TextAlign.center, + ), + content: const Text( + 'لا يوجد تسجيل دخول فعال لتسجيل الخروج.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: + () => Navigator.of(context).pop(), + child: const Text('حسناً'), + ), + ], + ), + ); + } + return; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('فشل التحقق من الجلسة: $e')), + ); + } + return; + } + if (context.mounted) { Navigator.of(context).push( MaterialPageRoute( @@ -306,17 +774,17 @@ class _FingerButton extends StatelessWidget { height: 160, width: 160, decoration: BoxDecoration( - color: Color(0xFFEAFBF3), + color: const Color(0xFFEAFBF3), borderRadius: BorderRadius.circular(32), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ SvgPicture.asset(icon, width: 75, height: 75), - SizedBox(height: 10), + const SizedBox(height: 10), Text( label, - style: TextStyle( + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black, diff --git a/lib/presentation/screens/face_screen2.dart b/lib/presentation/screens/face_screen2.dart new file mode 100644 index 0000000..a6cdc4d --- /dev/null +++ b/lib/presentation/screens/face_screen2.dart @@ -0,0 +1,824 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; + +import '../../core/error/exceptions.dart'; +import '../face/face_feedback.dart'; + +class OvalCameraCapturePage extends StatefulWidget { + final bool isLogin; + final Future Function(File image) onCapture; + + const OvalCameraCapturePage({ + super.key, + this.isLogin = true, + required this.onCapture, + }); + + @override + State createState() => _OvalCameraCapturePageState(); +} + +class _OvalCameraCapturePageState extends State { + CameraController? _cameraController; + + bool _isCameraInitialized = false; + String? _errorMessage; + + bool _isSuccess = false; + bool _isSubmitting = false; + bool _isStreaming = false; + + // Smart feedback + FaceFeedback _feedback = FaceFeedback( + type: FaceHintType.noFace, + message: "ضع وجهك داخل الإطار", + quality: 0, + borderColor: Colors.white70, + ); + + double _progress = 0; + bool _isDetecting = false; + int _frameCount = 0; + + // Stability tracking + Rect? _lastFaceRect; + int _stableFrames = 0; + bool _showManualCapture = false; + Timer? _manualCaptureTimer; + + String _debugInfo = "Initializing..."; + + late final FaceDetector _faceDetector = FaceDetector( + options: FaceDetectorOptions( + performanceMode: FaceDetectorMode.fast, + enableTracking: true, + enableClassification: true, + enableLandmarks: false, + enableContours: false, + ), + ); + + static const int _throttleEveryNFrames = 5; + static const int _stableFramesNeeded = 3; + + @override + void initState() { + super.initState(); + // Go straight to camera — no network calls here + _initializeCamera(); + } + + @override + void dispose() { + _manualCaptureTimer?.cancel(); + _stopImageStream(); + _cameraController?.dispose(); + _faceDetector.close(); + super.dispose(); + } + + Future _initializeCamera() async { + try { + setState(() { + _errorMessage = null; + _isCameraInitialized = false; + _isSuccess = false; + _isSubmitting = false; + _progress = 0; + _stableFrames = 0; + _lastFaceRect = null; + }); + + await _cameraController?.dispose(); + _cameraController = null; + + final cameras = await availableCameras(); + if (cameras.isEmpty) { + if (!mounted) return; + setState(() { + _errorMessage = "لا توجد كاميرات متاحة على هذا الجهاز"; + _isCameraInitialized = false; + }); + return; + } + + final front = cameras.where( + (c) => c.lensDirection == CameraLensDirection.front, + ); + final selectedCamera = front.isNotEmpty ? front.first : cameras.first; + + _cameraController = CameraController( + selectedCamera, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: + Platform.isAndroid + ? ImageFormatGroup.yuv420 + : ImageFormatGroup.bgra8888, + ); + + await _cameraController!.initialize(); + + if (!mounted) return; + + setState(() { + _isCameraInitialized = true; + _isStreaming = false; + _showManualCapture = false; + _debugInfo = "Ready. Cam: ${selectedCamera.lensDirection}"; + }); + + _manualCaptureTimer?.cancel(); + _manualCaptureTimer = Timer(const Duration(seconds: 10), () { + if (mounted && _isCameraInitialized && !_isSuccess && !_isSubmitting) { + setState(() { + _showManualCapture = true; + }); + } + }); + + _startSmartStream(); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = "خطأ في تهيئة الكاميرا: $e"; + _isCameraInitialized = false; + }); + } + } + + void _startSmartStream() { + if (_cameraController == null || !_cameraController!.value.isInitialized) { + return; + } + if (_isStreaming) return; + + _isStreaming = true; + + try { + _cameraController!.startImageStream((CameraImage image) async { + if (!mounted) return; + if (_isSubmitting || _isSuccess) return; + + _frameCount++; + if (_frameCount % _throttleEveryNFrames != 0) return; + + if (_isDetecting) return; + _isDetecting = true; + + try { + final inputImage = _toInputImage( + image, + _cameraController!.description, + ); + if (inputImage == null) { + _isDetecting = false; + return; + } + + final faces = await _faceDetector.processImage(inputImage); + + if (faces.isEmpty) { + _stableFrames = 0; + _applyFeedback( + FaceFeedback( + type: FaceHintType.noFace, + message: "ضع وجهك داخل الإطار", + quality: 0, + borderColor: Colors.white70, + ), + ); + _isDetecting = false; + return; + } + + final face = faces.first; + final brightness = _estimateBrightness(image); + final rotation = + inputImage.metadata?.rotation ?? InputImageRotation.rotation0deg; + + final feedback = _evaluate( + face: face, + brightness: brightness, + image: image, + rotation: rotation, + ); + + _applyFeedback(feedback); + + if (feedback.isGood) { + _stableFrames++; + _progress = (_stableFrames / _stableFramesNeeded).clamp(0.0, 1.0); + if (_stableFrames >= _stableFramesNeeded) { + if (mounted) { + setState(() { + _debugInfo = "جاري التحقق من الصورة..."; + }); + } + _isDetecting = false; + await _captureAndSubmit(); + return; + } + } else { + if (_stableFrames > 0) _stableFrames--; + _progress = (_stableFrames / _stableFramesNeeded).clamp(0.0, 1.0); + } + + if (mounted && !_isSubmitting && !_isSuccess) { + setState(() { + _debugInfo = + "Faces: ${faces.length} | Bright: ${brightness.toStringAsFixed(1)}\n" + "Msg: ${feedback.message} | Stable: $_stableFrames"; + }); + } + } catch (e) { + debugPrint("Face detection error: $e"); + } finally { + _isDetecting = false; + } + }); + } catch (e) { + debugPrint("Error starting image stream: $e"); + _isStreaming = false; + } + } + + Future _stopImageStream() async { + if (!_isStreaming || _cameraController == null) return; + try { + await _cameraController!.stopImageStream(); + _isStreaming = false; + } catch (e) { + debugPrint("Error stopping image stream: $e"); + } + } + + void _stopCameraCompletely() { + _manualCaptureTimer?.cancel(); + try { + if (_isStreaming && _cameraController != null) { + _cameraController!.stopImageStream(); + _isStreaming = false; + } + _cameraController?.dispose(); + _cameraController = null; + } catch (e) { + debugPrint("Error stopping camera: $e"); + } + } + + FaceFeedback _evaluate({ + required Face face, + required double brightness, + required CameraImage image, + required InputImageRotation rotation, + }) { + // 1) lighting + if (brightness < 40) { + return FaceFeedback( + type: FaceHintType.tooDark, + message: "المكان مظلم — انتقل لمكان أكثر إضاءة", + quality: 0.1, + borderColor: Colors.orangeAccent, + ); + } + + // 2) head pose + final yaw = (face.headEulerAngleY ?? 0).abs(); + final pitch = (face.headEulerAngleX ?? 0).abs(); + if (yaw > 20 || pitch > 20) { + return FaceFeedback( + type: FaceHintType.lookStraight, + message: "انظر مباشرةً للكاميرا", + quality: 0.2, + borderColor: Colors.orangeAccent, + ); + } + + // 3) distance estimate + double frameWidth = image.width.toDouble(); + double frameHeight = image.height.toDouble(); + + if (rotation == InputImageRotation.rotation90deg || + rotation == InputImageRotation.rotation270deg) { + final temp = frameWidth; + frameWidth = frameHeight; + frameHeight = temp; + } + + final box = face.boundingBox; + final frameArea = frameWidth * frameHeight; + final faceArea = box.width * box.height; + final ratio = faceArea / frameArea; + + if (ratio < 0.05) { + return FaceFeedback( + type: FaceHintType.tooFar, + message: "اقترب قليلاً", + quality: 0.3, + borderColor: Colors.orangeAccent, + ); + } + if (ratio > 0.8) { + return FaceFeedback( + type: FaceHintType.tooClose, + message: "ابتعد قليلاً", + quality: 0.3, + borderColor: Colors.orangeAccent, + ); + } + + // 4) centered + final faceCenter = Offset(box.center.dx, box.center.dy); + final frameCenter = Offset(frameWidth / 2, frameHeight / 2); + final dist = (faceCenter - frameCenter).distance; + final maxAllowed = math.min(frameWidth, frameHeight) * 0.4; + + if (dist > maxAllowed) { + return FaceFeedback( + type: FaceHintType.notCentered, + message: "وسط وجهك داخل الإطار", + quality: 0.4, + borderColor: Colors.orangeAccent, + ); + } + + // 5) stability + if (_lastFaceRect != null) { + final moved = (box.center - _lastFaceRect!.center).distance; + if (moved > 40) { + _lastFaceRect = box; + return FaceFeedback( + type: FaceHintType.holdStill, + message: "ثبت الهاتف وابقَ ثابتاً", + quality: 0.5, + borderColor: Colors.orangeAccent, + ); + } + } + _lastFaceRect = box; + + return FaceFeedback( + type: FaceHintType.good, + message: "ممتاز — ثبت قليلاً", + quality: 1.0, + borderColor: Colors.greenAccent, + ); + } + + void _applyFeedback(FaceFeedback f) { + if (!mounted) return; + if (_feedback.type != f.type || _feedback.message != f.message) { + setState(() { + _feedback = f; + }); + } + } + + Future _captureAndSubmit() async { + if (_cameraController == null) return; + if (_isSubmitting || _isSuccess) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + await _stopImageStream(); + + // Small delay to let camera settle after stopping stream + await Future.delayed(const Duration(milliseconds: 200)); + + if (_cameraController == null || + !_cameraController!.value.isInitialized) { + _handleScanError("الكاميرا غير جاهزة، حاول مرة أخرى"); + return; + } + + final xFile = await _cameraController!.takePicture(); + final file = File(xFile.path); + + await widget.onCapture(file); + + if (mounted) { + setState(() { + _isSuccess = true; + _isSubmitting = false; + }); + + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + Navigator.of(context).pop(true); + } + }); + } + } on ServerException catch (e) { + // Check if this is an "already logged in" error from the API + final msg = e.message.toLowerCase(); + if (msg.contains('already logged in') || + msg.contains('مسجل دخول بالفعل')) { + // Stop camera and go back with a dialog + _stopCameraCompletely(); + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: + (_) => AlertDialog( + title: const Text('تنبيه', textAlign: TextAlign.center), + content: const Text( + 'أنت مسجل دخول بالفعل، لا يمكنك تسجيل الدخول مرة أخرى.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close dialog + Navigator.of(context).pop(); // Go back from camera + }, + child: const Text('حسناً'), + ), + ], + ), + ); + } + return; + } + _handleScanError(e.message); + } on NetworkException catch (e) { + _handleScanError(e.message); + } on CameraException catch (e) { + _handleScanError("فشل التقاط الصورة: ${e.description ?? e.code}"); + } catch (e) { + _handleScanError("حدث خطأ غير متوقع: $e"); + } + } + + void _handleScanError(String msg) { + if (!mounted) return; + setState(() { + _isSubmitting = false; + _errorMessage = msg; + _progress = 0; + _stableFrames = 0; + }); + + if (_cameraController != null && + _cameraController!.value.isInitialized && + !_isStreaming) { + _startSmartStream(); + } + } + + double _estimateBrightness(CameraImage image) { + if (image.planes.isEmpty) return 0; + final bytes = image.planes[0].bytes; + if (bytes.isEmpty) return 0; + + const step = 100; + int sum = 0; + int count = 0; + for (int i = 0; i < bytes.length; i += step) { + sum += bytes[i]; + count++; + } + return count == 0 ? 0 : (sum / count); + } + + InputImage? _toInputImage(CameraImage image, CameraDescription camera) { + final sensorOrientation = camera.sensorOrientation; + InputImageRotation? rotation; + + if (Platform.isIOS) { + rotation = _rotationIntToImageRotation(sensorOrientation); + } else if (Platform.isAndroid) { + var rotationCompensation = + _orientations[_cameraController!.value.deviceOrientation]; + if (rotationCompensation == null) return null; + if (camera.lensDirection == CameraLensDirection.front) { + rotationCompensation = (sensorOrientation + rotationCompensation) % 360; + } else { + rotationCompensation = + (sensorOrientation - rotationCompensation + 360) % 360; + } + rotation = _rotationIntToImageRotation(rotationCompensation); + } + if (rotation == null) return null; + + if (Platform.isAndroid) { + final nv21 = _convertYUV420ToNV21(image); + + return InputImage.fromBytes( + bytes: nv21, + metadata: InputImageMetadata( + size: Size(image.width.toDouble(), image.height.toDouble()), + rotation: rotation, + format: InputImageFormat.nv21, + bytesPerRow: image.width, + ), + ); + } + + // iOS BGRA8888 + if (image.planes.length == 1) { + return InputImage.fromBytes( + bytes: image.planes.first.bytes, + metadata: InputImageMetadata( + size: Size(image.width.toDouble(), image.height.toDouble()), + rotation: rotation, + format: InputImageFormat.bgra8888, + bytesPerRow: image.planes.first.bytesPerRow, + ), + ); + } + + return null; + } + + Uint8List _convertYUV420ToNV21(CameraImage image) { + final int width = image.width; + final int height = image.height; + + final yPlane = image.planes[0]; + final uPlane = image.planes[1]; + final vPlane = image.planes[2]; + + final int ySize = width * height; + final int uvSize = ySize ~/ 2; + + final Uint8List nv21 = Uint8List(ySize + uvSize); + + // Y Channel + if (yPlane.bytesPerRow == width) { + nv21.setAll(0, yPlane.bytes); + } else { + int offset = 0; + for (int i = 0; i < height; i++) { + nv21.setRange( + offset, + offset + width, + yPlane.bytes, + i * yPlane.bytesPerRow, + ); + offset += width; + } + } + + // UV Channel (NV21 is VU interleaved) + final int uvWidth = width ~/ 2; + final int uvHeight = height ~/ 2; + final int uvPixelStride = uPlane.bytesPerPixel ?? 1; + + int uvIndex = ySize; + + for (int row = 0; row < uvHeight; row++) { + final int srcIndex = row * uPlane.bytesPerRow; + for (int col = 0; col < uvWidth; col++) { + final int pixelIndex = srcIndex + (col * uvPixelStride); + nv21[uvIndex++] = vPlane.bytes[pixelIndex]; + nv21[uvIndex++] = uPlane.bytes[pixelIndex]; + } + } + + return nv21; + } + + InputImageRotation _rotationIntToImageRotation(int rotation) { + switch (rotation) { + case 90: + return InputImageRotation.rotation90deg; + case 180: + return InputImageRotation.rotation180deg; + case 270: + return InputImageRotation.rotation270deg; + default: + return InputImageRotation.rotation0deg; + } + } + + static final _orientations = { + DeviceOrientation.portraitUp: 0, + DeviceOrientation.landscapeLeft: 90, + DeviceOrientation.portraitDown: 180, + DeviceOrientation.landscapeRight: 270, + }; + + @override + Widget build(BuildContext context) { + if (_errorMessage != null && !_isCameraInitialized) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text(_errorMessage!, style: const TextStyle(color: Colors.white)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _initializeCamera, + child: const Text("إعادة المحاولة"), + ), + ], + ), + ), + ); + } + + if (!_isCameraInitialized || _cameraController == null) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center(child: CircularProgressIndicator(color: Colors.red)), + ); + } + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + Center(child: CameraPreview(_cameraController!)), + CustomPaint( + painter: _OvalOverlayPainter( + borderColor: _feedback.borderColor, + progress: _progress, + ), + ), + Positioned( + top: 60, + left: 0, + right: 0, + child: Column( + children: [ + Text( + widget.isLogin ? "تسجيل الدخول" : "تسجيل خروج", + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: 'Cairo', + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _feedback.message, + style: TextStyle( + color: _feedback.borderColor, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + if (_isSubmitting) + const Center(child: CircularProgressIndicator(color: Colors.white)), + if (_isSuccess) + const Center( + child: Icon(Icons.check_circle, color: Colors.green, size: 80), + ), + if (_errorMessage != null && _isCameraInitialized) + Positioned( + bottom: 20, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.all(8), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ), + Positioned( + bottom: 50, + left: 10, + right: 10, + child: Text( + _debugInfo, + style: const TextStyle( + color: Colors.yellow, + fontSize: 12, + backgroundColor: Colors.black54, + ), + textAlign: TextAlign.center, + ), + ), + if (_showManualCapture && !_isSubmitting && !_isSuccess) + Positioned( + bottom: 110, + left: 0, + right: 0, + child: Center( + child: ElevatedButton.icon( + onPressed: _captureAndSubmit, + icon: const Icon(Icons.camera_alt), + label: const Text("التقاط يدوياً"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _OvalOverlayPainter extends CustomPainter { + final Color borderColor; + final double progress; + + _OvalOverlayPainter({required this.borderColor, required this.progress}); + + @override + void paint(Canvas canvas, Size size) { + final width = size.width * 0.75; + final height = size.height * 0.55; + final center = Offset(size.width / 2, size.height / 2); + final ovalRect = Rect.fromCenter( + center: center, + width: width, + height: height, + ); + + final screenPath = + Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final ovalPath = Path()..addOval(ovalRect); + final overlayPath = Path.combine( + PathOperation.difference, + screenPath, + ovalPath, + ); + + final bgPaint = + Paint() + ..color = Colors.black.withOpacity(0.6) + ..style = PaintingStyle.fill; + + canvas.drawPath(overlayPath, bgPaint); + + final borderPaint = + Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 4.0; + + canvas.drawOval(ovalRect, borderPaint); + + if (progress > 0) { + final progressPaint = + Paint() + ..color = Colors.greenAccent + ..style = PaintingStyle.stroke + ..strokeWidth = 6.0 + ..strokeCap = StrokeCap.round; + + final startAngle = -math.pi / 2; + final sweepAngle = 2 * math.pi * progress; + + canvas.drawArc( + ovalRect.inflate(10), + startAngle, + sweepAngle, + false, + progressPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _OvalOverlayPainter oldDelegate) { + return oldDelegate.borderColor != borderColor || + oldDelegate.progress != progress; + } +} diff --git a/pubspec.lock b/pubspec.lock index 9b97053..9802d97 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -280,6 +280,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.7.0" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "7e9a6d6e66b44aa8cfe944bda9bc3346c52486dd890ca49e5bc98845cda40d7f" + url: "https://pub.dev" + source: hosted + version: "0.9.0" + google_mlkit_face_detection: + dependency: "direct main" + description: + name: google_mlkit_face_detection + sha256: "65988405c884fd84a4ccc8bded7b5e3e4c33362f6f4eaaa94818bdaaba7bab7d" + url: "https://pub.dev" + source: hosted + version: "0.12.0" html: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 11123db..abf543d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: shared_preferences: ^2.2.2 flutter_bloc: ^8.1.6 intl: ^0.19.0 + google_mlkit_face_detection: ^0.12.0 dev_dependencies: flutter_test: