Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
7ca33cdaa3
commit
64c9f233a1
|
@ -46,7 +46,7 @@ AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ
|
||||||
AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4ZvOJQQJ99BEACqBBLyXJ3w3AAAKACOGYqeW"
|
AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4ZvOJQQJ99BEACqBBLyXJ3w3AAAKACOGYqeW"
|
||||||
|
|
||||||
; Aws rekognition
|
; Aws rekognition
|
||||||
AWS_REGION="ap-southeast-1"
|
AWS_REGION=ap-southeast-1
|
||||||
AWS_ACCESS_KEY="AKIAW3MD7UU5G2XTA44C"
|
AWS_ACCESS_KEY=AKIAQCK3TTCVDWT7HK4N
|
||||||
AWS_SECRET_KEY="8jgxMWWmsEUd4q/++9W+R/IOQ/IxFTAKmtnaBQKe"
|
AWS_SECRET_KEY=hLjsFn1bcxpxpPV2oamYn/INSEgZSaAgdp+A0Mt6
|
||||||
|
|
|
@ -38,6 +38,10 @@ android {
|
||||||
val appcompat_version = "1.7.0"
|
val appcompat_version = "1.7.0"
|
||||||
implementation("androidx.appcompat:appcompat:$appcompat_version")
|
implementation("androidx.appcompat:appcompat:$appcompat_version")
|
||||||
implementation("androidx.appcompat:appcompat-resources:$appcompat_version")
|
implementation("androidx.appcompat:appcompat-resources:$appcompat_version")
|
||||||
|
|
||||||
|
implementation("com.google.mlkit:face-detection:16.1.7")
|
||||||
|
implementation("com.google.mlkit:face-mesh-detection:16.0.0-beta1")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
|
|
@ -7,10 +7,16 @@
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<!-- Biometric permission -->
|
<!-- Biometric permission -->
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<!-- Camera -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<!-- ... -->
|
<!-- ... -->
|
||||||
<application android:label="sigap"
|
<application android:label="sigap"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity android:name=".MainActivity"
|
<activity android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,10 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/account/presentation/pages/account_page.dart';
|
|
||||||
import 'package:sigap/src/features/history/presentation/pages/history_page.dart';
|
|
||||||
import 'package:sigap/src/features/home/presentation/pages/home_page.dart';
|
|
||||||
import 'package:sigap/src/features/panic/presentation/pages/panic_button_page.dart';
|
import 'package:sigap/src/features/panic/presentation/pages/panic_button_page.dart';
|
||||||
import 'package:sigap/src/features/search/presentation/pages/search_page.dart';
|
|
||||||
import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart';
|
import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart';
|
||||||
|
|
||||||
class NavigationMenu extends StatelessWidget {
|
class NavigationMenu extends StatelessWidget {
|
||||||
|
@ -20,11 +16,11 @@ class NavigationMenu extends StatelessWidget {
|
||||||
() => IndexedStack(
|
() => IndexedStack(
|
||||||
index: controller.selectedIndex.value,
|
index: controller.selectedIndex.value,
|
||||||
children: const [
|
children: const [
|
||||||
HomePage(),
|
// HomePage(),
|
||||||
SearchPage(),
|
// SearchPage(),
|
||||||
PanicButtonPage(),
|
PanicButtonPage(),
|
||||||
HistoryPage(),
|
// HistoryPage(),
|
||||||
AccountPage(),
|
// AccountPage(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,12 +2,10 @@ import 'package:get/get.dart';
|
||||||
import 'package:sigap/navigation_menu.dart';
|
import 'package:sigap/navigation_menu.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/registraion_form_screen.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/registraion_form_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart';
|
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart';
|
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/api_urls.dart';
|
||||||
|
import 'package:sigap/src/utils/dio.client/dio_client.dart';
|
||||||
|
import 'package:sigap/src/utils/helpers/error_utils.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/image_validator.dart';
|
||||||
|
|
||||||
/// Service class for interacting with Supabase Edge Functions for face recognition
|
/// Service class for interacting with Supabase Edge Functions for face recognition
|
||||||
class EdgeFunctionService {
|
class EdgeFunctionService {
|
||||||
|
@ -11,6 +16,9 @@ class EdgeFunctionService {
|
||||||
static final EdgeFunctionService instance = EdgeFunctionService._();
|
static final EdgeFunctionService instance = EdgeFunctionService._();
|
||||||
EdgeFunctionService._();
|
EdgeFunctionService._();
|
||||||
|
|
||||||
|
// Logger
|
||||||
|
final _logger = Logger();
|
||||||
|
|
||||||
// Supabase client for Edge Function invocation
|
// Supabase client for Edge Function invocation
|
||||||
final supabase = SupabaseService.instance.client;
|
final supabase = SupabaseService.instance.client;
|
||||||
|
|
||||||
|
@ -18,6 +26,13 @@ class EdgeFunctionService {
|
||||||
final String _detectFaceFunction = 'detect-face';
|
final String _detectFaceFunction = 'detect-face';
|
||||||
final String _verifyFaceFunction = 'verify-face';
|
final String _verifyFaceFunction = 'verify-face';
|
||||||
|
|
||||||
|
// Edge function URLs
|
||||||
|
static String get supabaseUrl => Endpoints.supabaseUrl;
|
||||||
|
static String get supabaseKey => Endpoints.supabaseAnonKey;
|
||||||
|
|
||||||
|
String get _detectFaceUrl => '$supabaseUrl/functions/v1/$_detectFaceFunction';
|
||||||
|
String get _verifyFaceUrl => '$supabaseUrl/functions/v1/$_verifyFaceFunction';
|
||||||
|
|
||||||
// Max retries
|
// Max retries
|
||||||
final int _maxRetries = 0;
|
final int _maxRetries = 0;
|
||||||
|
|
||||||
|
@ -28,69 +43,287 @@ class EdgeFunctionService {
|
||||||
|
|
||||||
while (retries <= _maxRetries) {
|
while (retries <= _maxRetries) {
|
||||||
try {
|
try {
|
||||||
// Read image as bytes and convert to base64 for sending
|
_logger.d('Calling edge function for face detection...');
|
||||||
final bytes = await File(imageFile.path).readAsBytes();
|
|
||||||
final base64Image = base64Encode(bytes);
|
|
||||||
|
|
||||||
// Prepare request payload
|
// Validate image first
|
||||||
final payload = {
|
if (!ImageValidator.isValidImageFile(imageFile.path)) {
|
||||||
'image': base64Image,
|
throw EdgeFunctionException(
|
||||||
'options': {'detectAttributes': true, 'returnFaceId': true},
|
code: 'invalid_image_format',
|
||||||
};
|
message: 'Please upload a valid JPG or PNG image',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Call the Supabase Edge Function
|
// Create form data
|
||||||
final res = await supabase.functions.invoke(
|
final formData = FormData();
|
||||||
_detectFaceFunction,
|
|
||||||
body: payload,
|
// Read file bytes
|
||||||
|
final imageBytes = await File(imageFile.path).readAsBytes();
|
||||||
|
|
||||||
|
// Add file with proper MIME type
|
||||||
|
final mimeType = ImageValidator.getMimeType(imageFile.path);
|
||||||
|
|
||||||
|
formData.files.add(
|
||||||
|
MapEntry(
|
||||||
|
'image',
|
||||||
|
MultipartFile.fromBytes(
|
||||||
|
imageBytes,
|
||||||
|
filename: imageFile.name,
|
||||||
|
contentType: mimeType,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process the response
|
// Set headers
|
||||||
final data = res.data;
|
final options = Options(
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $supabaseKey',
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
final response = await DioClient().post(
|
||||||
|
_detectFaceUrl,
|
||||||
|
data: formData,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'http_error',
|
||||||
|
message: 'HTTP ${response.statusCode}: ${response.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
final data = response.data;
|
||||||
|
|
||||||
|
if (data['success'] != true) {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'api_error',
|
||||||
|
message: data['error'] ?? 'Unknown API error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<FaceModel> faces = [];
|
List<FaceModel> faces = [];
|
||||||
|
final faceDetails = data['faceDetails'] as List;
|
||||||
|
|
||||||
// Handle different response formats
|
for (var i = 0; i < faceDetails.length; i++) {
|
||||||
if (data is Map && data.containsKey('faces') && data['faces'] is List) {
|
faces.add(FaceModel.fromEdgeFunction(imageFile, faceDetails[i]));
|
||||||
// Process list of faces
|
|
||||||
final facesList = data['faces'] as List;
|
|
||||||
for (var i = 0; i < facesList.length; i++) {
|
|
||||||
faces.add(FaceModel.fromEdgeFunction(imageFile, facesList[i]));
|
|
||||||
}
|
|
||||||
} else if (data is Map) {
|
|
||||||
// Single face response
|
|
||||||
faces.add(
|
|
||||||
FaceModel.fromEdgeFunction(imageFile, data as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.d('Successfully detected ${faces.length} faces');
|
||||||
return faces;
|
return faces;
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
lastException = e is Exception ? e : Exception(e.toString());
|
_logger.e(
|
||||||
retries++;
|
'Dio error in face detection (attempt ${retries + 1}): ${e.message}',
|
||||||
|
);
|
||||||
|
|
||||||
// Wait before retrying
|
String errorMessage = 'Network error occurred. Please try again later.';
|
||||||
|
String errorCode = 'network_error';
|
||||||
|
|
||||||
|
// Handle different Dio error types
|
||||||
|
if (e.type == DioExceptionType.connectionTimeout ||
|
||||||
|
e.type == DioExceptionType.sendTimeout ||
|
||||||
|
e.type == DioExceptionType.receiveTimeout) {
|
||||||
|
errorMessage =
|
||||||
|
'Connection timed out. Please check your internet and try again.';
|
||||||
|
errorCode = 'timeout';
|
||||||
|
} else if (e.type == DioExceptionType.connectionError) {
|
||||||
|
errorMessage =
|
||||||
|
'No internet connection. Please check your connection and try again.';
|
||||||
|
errorCode = 'connection_error';
|
||||||
|
} else if (e.response != null) {
|
||||||
|
// Server responded with an error status code
|
||||||
|
final statusCode = e.response!.statusCode;
|
||||||
|
final responseData = e.response!.data;
|
||||||
|
|
||||||
|
if (statusCode == 500) {
|
||||||
|
if (responseData is Map && responseData['error'] is String) {
|
||||||
|
final serverError = responseData['error'];
|
||||||
|
if (serverError.contains(
|
||||||
|
'Missing required environment variables',
|
||||||
|
)) {
|
||||||
|
errorCode = 'server_config_error';
|
||||||
|
errorMessage =
|
||||||
|
'The verification service is temporarily unavailable due to maintenance.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Server error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastException = EdgeFunctionException(
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage,
|
||||||
|
details: e.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
retries++;
|
||||||
if (retries <= _maxRetries) {
|
if (retries <= _maxRetries) {
|
||||||
await Future.delayed(Duration(seconds: retries * 2));
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
print('Retrying face detection (attempt $retries)...');
|
_logger.d('Retrying face detection (attempt $retries)...');
|
||||||
|
}
|
||||||
|
} on EdgeFunctionException catch (e) {
|
||||||
|
_logger.e(
|
||||||
|
'Edge function error (attempt ${retries + 1}): ${e.toString()}',
|
||||||
|
);
|
||||||
|
lastException = e;
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
_logger.d('Retrying face detection (attempt $retries)...');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e(
|
||||||
|
'Unexpected error in face detection (attempt ${retries + 1}): $e',
|
||||||
|
);
|
||||||
|
|
||||||
|
String userMessage =
|
||||||
|
'Verification service error. Please try again later.';
|
||||||
|
String errorCode = 'unknown_error';
|
||||||
|
|
||||||
|
if (e is SocketException) {
|
||||||
|
errorCode = 'network_error';
|
||||||
|
userMessage =
|
||||||
|
'Network connection issue. Please check your internet connection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
lastException = EdgeFunctionException(
|
||||||
|
code: errorCode,
|
||||||
|
message: userMessage,
|
||||||
|
details: e.toString(),
|
||||||
|
);
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
_logger.d('Retrying face detection (attempt $retries)...');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, all retries failed
|
// If we get here, all retries failed
|
||||||
print('Face detection failed after $_maxRetries retries: $lastException');
|
_logger.e(
|
||||||
throw lastException ?? Exception('Face detection failed');
|
'Face detection failed after $_maxRetries retries: $lastException',
|
||||||
|
);
|
||||||
|
throw lastException ??
|
||||||
|
EdgeFunctionException(
|
||||||
|
code: 'max_retries',
|
||||||
|
message: 'Verification service unavailable. Please try again later.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compares two face images and returns a comparison result with retries
|
/// Compares two face images and returns a comparison result with retries
|
||||||
Future<FaceComparisonResult> compareFaces(
|
Future<FaceComparisonResult> compareFaces(
|
||||||
XFile sourceImage,
|
XFile sourceImage,
|
||||||
XFile targetImage,
|
XFile targetImage, {
|
||||||
) async {
|
double similarityThreshold = 70.0,
|
||||||
|
FaceModel? sourceModel,
|
||||||
|
FaceModel? targetModel,
|
||||||
|
}) async {
|
||||||
int retries = 0;
|
int retries = 0;
|
||||||
Exception? lastException;
|
Exception? lastException;
|
||||||
|
|
||||||
while (retries <= _maxRetries) {
|
while (retries <= _maxRetries) {
|
||||||
try {
|
try {
|
||||||
// First detect faces in both images
|
_logger.d('Calling edge function for face comparison...');
|
||||||
|
|
||||||
|
// Validate images first
|
||||||
|
if (!ImageValidator.isValidImageFile(sourceImage.path)) {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'invalid_source_image',
|
||||||
|
message: 'Source image must be a valid JPG or PNG file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ImageValidator.isValidImageFile(targetImage.path)) {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'invalid_target_image',
|
||||||
|
message: 'Selfie image must be a valid JPG or PNG file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create form data
|
||||||
|
final formData = FormData();
|
||||||
|
|
||||||
|
// Read file bytes
|
||||||
|
final sourceBytes = await File(sourceImage.path).readAsBytes();
|
||||||
|
final targetBytes = await File(targetImage.path).readAsBytes();
|
||||||
|
|
||||||
|
// Get MIME types
|
||||||
|
final sourceMimeType = ImageValidator.getMimeType(sourceImage.path);
|
||||||
|
final targetMimeType = ImageValidator.getMimeType(targetImage.path);
|
||||||
|
|
||||||
|
// Add files with proper MIME types
|
||||||
|
formData.files.addAll([
|
||||||
|
MapEntry(
|
||||||
|
'idCard',
|
||||||
|
MultipartFile.fromBytes(
|
||||||
|
sourceBytes,
|
||||||
|
filename: sourceImage.name,
|
||||||
|
contentType: sourceMimeType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MapEntry(
|
||||||
|
'selfie',
|
||||||
|
MultipartFile.fromBytes(
|
||||||
|
targetBytes,
|
||||||
|
filename: targetImage.name,
|
||||||
|
contentType: targetMimeType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add similarity threshold
|
||||||
|
formData.fields.add(
|
||||||
|
MapEntry('similarity_threshold', similarityThreshold.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
final options = Options(
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $supabaseKey',
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
final response = await DioClient().post(
|
||||||
|
_verifyFaceUrl,
|
||||||
|
data: formData,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'http_error',
|
||||||
|
message: 'HTTP ${response.statusCode}: ${response.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
final data = response.data;
|
||||||
|
|
||||||
|
if (data['success'] != true) {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'api_error',
|
||||||
|
message: data['error'] ?? 'Unknown API error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing models if provided, otherwise detect faces
|
||||||
|
FaceModel sourceFace;
|
||||||
|
FaceModel targetFace;
|
||||||
|
|
||||||
|
if (sourceModel != null && targetModel != null) {
|
||||||
|
// Use the provided models if they're valid
|
||||||
|
sourceFace = sourceModel;
|
||||||
|
targetFace = targetModel;
|
||||||
|
} else {
|
||||||
|
// For face comparison, we'll need to detect faces first to create our models
|
||||||
List<FaceModel> sourceFaces = await detectFaces(sourceImage);
|
List<FaceModel> sourceFaces = await detectFaces(sourceImage);
|
||||||
List<FaceModel> targetFaces = await detectFaces(targetImage);
|
List<FaceModel> targetFaces = await detectFaces(targetImage);
|
||||||
|
|
||||||
|
@ -107,55 +340,83 @@ class EdgeFunctionService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the primary faces from each image
|
sourceFace = sourceFaces.first;
|
||||||
FaceModel sourceFace = sourceFaces.first;
|
targetFace = targetFaces.first;
|
||||||
FaceModel targetFace = targetFaces.first;
|
}
|
||||||
|
|
||||||
// Read images as bytes and convert to base64 for sending
|
|
||||||
final sourceBytes = await File(sourceImage.path).readAsBytes();
|
|
||||||
final targetBytes = await File(targetImage.path).readAsBytes();
|
|
||||||
|
|
||||||
// Prepare request payload
|
|
||||||
final payload = {
|
|
||||||
'sourceImage': base64Encode(sourceBytes),
|
|
||||||
'targetImage': base64Encode(targetBytes),
|
|
||||||
'options': {
|
|
||||||
'threshold': 80.0, // Default similarity threshold (80%)
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call the Supabase Edge Function
|
|
||||||
final res = await supabase.functions.invoke(
|
|
||||||
_verifyFaceFunction,
|
|
||||||
body: payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process the response
|
|
||||||
final data = res.data;
|
|
||||||
|
|
||||||
return FaceComparisonResult.fromEdgeFunction(
|
return FaceComparisonResult.fromEdgeFunction(
|
||||||
sourceFace,
|
sourceFace,
|
||||||
targetFace,
|
targetFace,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
lastException = e is Exception ? e : Exception(e.toString());
|
_logger.e(
|
||||||
retries++;
|
'Dio error in face comparison (attempt ${retries + 1}): ${e.message}',
|
||||||
|
);
|
||||||
|
|
||||||
// Wait before retrying
|
String errorMessage = 'Network error occurred. Please try again later.';
|
||||||
|
String errorCode = 'network_error';
|
||||||
|
|
||||||
|
// Handle different Dio error types
|
||||||
|
if (e.type == DioExceptionType.connectionTimeout ||
|
||||||
|
e.type == DioExceptionType.sendTimeout ||
|
||||||
|
e.type == DioExceptionType.receiveTimeout) {
|
||||||
|
errorMessage =
|
||||||
|
'Connection timed out. Please check your internet and try again.';
|
||||||
|
errorCode = 'timeout';
|
||||||
|
}
|
||||||
|
|
||||||
|
lastException = EdgeFunctionException(
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage,
|
||||||
|
details: e.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
retries++;
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
}
|
||||||
|
} on EdgeFunctionException catch (e) {
|
||||||
|
_logger.e(
|
||||||
|
'Edge function error in face comparison (attempt ${retries + 1}): ${e.toString()}',
|
||||||
|
);
|
||||||
|
lastException = e;
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e(
|
||||||
|
'Unexpected error in face comparison (attempt ${retries + 1}): $e',
|
||||||
|
);
|
||||||
|
|
||||||
|
lastException = EdgeFunctionException(
|
||||||
|
code: 'unknown_error',
|
||||||
|
message: 'Failed to compare faces. Please try again later.',
|
||||||
|
details: e.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
retries++;
|
||||||
if (retries <= _maxRetries) {
|
if (retries <= _maxRetries) {
|
||||||
await Future.delayed(Duration(seconds: retries * 2));
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
print('Retrying face comparison (attempt $retries)...');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, all retries failed
|
// If we get here, all retries failed
|
||||||
print('Face comparison failed after $_maxRetries retries: $lastException');
|
_logger.e(
|
||||||
return FaceComparisonResult.error(
|
'Face comparison failed after $_maxRetries retries: $lastException',
|
||||||
FaceModel.empty().withMessage('Source face processing error'),
|
);
|
||||||
FaceModel.empty().withMessage('Target face processing error'),
|
|
||||||
'Face comparison failed after multiple attempts. Please try again.',
|
if (lastException is EdgeFunctionException) {
|
||||||
|
throw lastException;
|
||||||
|
} else {
|
||||||
|
throw EdgeFunctionException(
|
||||||
|
code: 'max_retries',
|
||||||
|
message: 'Face comparison service unavailable. Please try again later.',
|
||||||
|
details: lastException?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verific
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
|
||||||
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
||||||
|
@ -312,6 +313,9 @@ class FormRegistrationController extends GetxController {
|
||||||
if (Get.isRegistered<SelfieVerificationController>()) {
|
if (Get.isRegistered<SelfieVerificationController>()) {
|
||||||
Get.delete<SelfieVerificationController>(force: true);
|
Get.delete<SelfieVerificationController>(force: true);
|
||||||
}
|
}
|
||||||
|
if (Get.isRegistered<FaceLivenessController>()) {
|
||||||
|
Get.delete<FaceLivenessController>(force: true);
|
||||||
|
}
|
||||||
if (Get.isRegistered<IdentityVerificationController>()) {
|
if (Get.isRegistered<IdentityVerificationController>()) {
|
||||||
Get.delete<IdentityVerificationController>(force: true);
|
Get.delete<IdentityVerificationController>(force: true);
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,7 @@ class SignInController extends GetxController {
|
||||||
|
|
||||||
// Navigate to sign up screen
|
// Navigate to sign up screen
|
||||||
void goToSignUp() {
|
void goToSignUp() {
|
||||||
Get.toNamed(AppRoutes.signUp);
|
Get.toNamed(AppRoutes.signupWithRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear error messages
|
// Clear error messages
|
||||||
|
|
|
@ -1,511 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:math' as Math;
|
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
|
||||||
import 'package:google_mlkit_face_mesh_detection/google_mlkit_face_mesh_detection.dart';
|
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
|
||||||
|
|
||||||
enum LivenessStatus {
|
|
||||||
preparing,
|
|
||||||
detectingFace,
|
|
||||||
checkLeftRotation,
|
|
||||||
checkRightRotation,
|
|
||||||
checkSmile,
|
|
||||||
checkEyesOpen,
|
|
||||||
readyForPhoto,
|
|
||||||
photoTaken,
|
|
||||||
completed,
|
|
||||||
failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
class FaceLivenessController extends GetxController
|
|
||||||
with WidgetsBindingObserver {
|
|
||||||
// Camera
|
|
||||||
CameraController? _cameraController;
|
|
||||||
late FaceDetector _faceDetector;
|
|
||||||
var frontCamera;
|
|
||||||
|
|
||||||
// Face Detection States
|
|
||||||
final _isFaceInFrame = false.obs;
|
|
||||||
final _isFaceLeft = false.obs;
|
|
||||||
final _isFaceRight = false.obs;
|
|
||||||
final _isEyeOpen = false.obs;
|
|
||||||
final _isNoFace = false.obs;
|
|
||||||
final _isMultiFace = false.obs;
|
|
||||||
final _isCaptured = false.obs;
|
|
||||||
final _isSmiled = false.obs;
|
|
||||||
final _isFaceReadyForPhoto = false.obs;
|
|
||||||
final _isDifferentPerson = false.obs;
|
|
||||||
|
|
||||||
// Status tracking
|
|
||||||
final Rx<LivenessStatus> status = Rx<LivenessStatus>(
|
|
||||||
LivenessStatus.preparing,
|
|
||||||
);
|
|
||||||
final RxString currentInstruction = RxString('Initializing camera...');
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
bool get isFaceInFrame => _isFaceInFrame.value;
|
|
||||||
bool get isFaceLeft => _isFaceLeft.value;
|
|
||||||
bool get isFaceRight => _isFaceRight.value;
|
|
||||||
bool get isEyeOpen => _isEyeOpen.value;
|
|
||||||
bool get isNoFace => _isNoFace.value;
|
|
||||||
bool get isMultiFace => _isMultiFace.value;
|
|
||||||
bool get isCaptured => _isCaptured.value;
|
|
||||||
bool get isSmiled => _isSmiled.value;
|
|
||||||
bool get isFaceReadyForPhoto => _isFaceReadyForPhoto.value;
|
|
||||||
bool get isDifferentPerson => _isDifferentPerson.value;
|
|
||||||
|
|
||||||
CameraController? get cameraController => _cameraController;
|
|
||||||
|
|
||||||
// Face Mesh Detector
|
|
||||||
final FaceMeshDetector _faceMeshDetector = FaceMeshDetector(
|
|
||||||
option: FaceMeshDetectorOptions.faceMesh,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Face Comparison
|
|
||||||
List<double>? _firstPersonEmbedding;
|
|
||||||
|
|
||||||
// Captured Image
|
|
||||||
final _capturedImage = Rxn<XFile>();
|
|
||||||
XFile? get capturedImage => _capturedImage.value;
|
|
||||||
|
|
||||||
// Successful Steps
|
|
||||||
final _successfulSteps = <String>[].obs;
|
|
||||||
List<String> get successfulSteps => _successfulSteps;
|
|
||||||
|
|
||||||
// Face Detector Options
|
|
||||||
final FaceDetectorOptions options = FaceDetectorOptions(
|
|
||||||
performanceMode:
|
|
||||||
Platform.isAndroid ? FaceDetectorMode.fast : FaceDetectorMode.accurate,
|
|
||||||
enableClassification: true,
|
|
||||||
enableLandmarks: true,
|
|
||||||
enableTracking: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Device Orientations
|
|
||||||
final orientations = {
|
|
||||||
DeviceOrientation.portraitUp: 0,
|
|
||||||
DeviceOrientation.landscapeLeft: 90,
|
|
||||||
DeviceOrientation.portraitDown: 180,
|
|
||||||
DeviceOrientation.landscapeRight: 270,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onInit() {
|
|
||||||
super.onInit();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
_initializeCamera();
|
|
||||||
_faceDetector = FaceDetector(options: options);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initializeCamera() async {
|
|
||||||
try {
|
|
||||||
status.value = LivenessStatus.preparing;
|
|
||||||
currentInstruction.value = 'Initializing camera...';
|
|
||||||
|
|
||||||
final cameras = await availableCameras();
|
|
||||||
final frontCameras = cameras.firstWhere(
|
|
||||||
(camera) => camera.lensDirection == CameraLensDirection.front,
|
|
||||||
);
|
|
||||||
|
|
||||||
frontCamera = frontCameras;
|
|
||||||
|
|
||||||
_cameraController = CameraController(
|
|
||||||
frontCamera,
|
|
||||||
ResolutionPreset.medium,
|
|
||||||
imageFormatGroup:
|
|
||||||
Platform.isAndroid
|
|
||||||
? ImageFormatGroup.nv21
|
|
||||||
: ImageFormatGroup.bgra8888,
|
|
||||||
);
|
|
||||||
|
|
||||||
await _cameraController!.initialize();
|
|
||||||
|
|
||||||
_cameraController!.startImageStream((CameraImage img) {
|
|
||||||
_processCameraImage(img);
|
|
||||||
});
|
|
||||||
|
|
||||||
status.value = LivenessStatus.detectingFace;
|
|
||||||
currentInstruction.value = 'Position your face in the frame';
|
|
||||||
|
|
||||||
update(); // Notify GetX to rebuild UI
|
|
||||||
} catch (e) {
|
|
||||||
print('Error initializing camera: $e');
|
|
||||||
status.value = LivenessStatus.failed;
|
|
||||||
currentInstruction.value = 'Failed to initialize camera: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _processCameraImage(CameraImage img) async {
|
|
||||||
try {
|
|
||||||
final inputImage = _getInputImageFromCameraImage(img);
|
|
||||||
if (inputImage == null) return;
|
|
||||||
|
|
||||||
final List<Face> faces = await _faceDetector.processImage(inputImage);
|
|
||||||
|
|
||||||
if (faces.length > 1) {
|
|
||||||
_isMultiFace.value = true;
|
|
||||||
_successfulSteps.clear();
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
status.value = LivenessStatus.detectingFace;
|
|
||||||
currentInstruction.value =
|
|
||||||
'Multiple faces detected. Please ensure only your face is visible.';
|
|
||||||
} else if (faces.isEmpty) {
|
|
||||||
_isNoFace.value = true;
|
|
||||||
_successfulSteps.clear();
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
status.value = LivenessStatus.detectingFace;
|
|
||||||
currentInstruction.value =
|
|
||||||
'No face detected. Please position your face in the frame.';
|
|
||||||
} else if (faces.isNotEmpty) {
|
|
||||||
_isMultiFace.value = false;
|
|
||||||
_isNoFace.value = false;
|
|
||||||
final Face face = faces.first;
|
|
||||||
await _compareFaces(face);
|
|
||||||
|
|
||||||
if (_isDifferentPerson.value) {
|
|
||||||
_duplicatePersonFaceDetect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_handleFaceDetection(face);
|
|
||||||
} else {
|
|
||||||
_handleNoFaceDetected();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error processing camera image: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleFaceDetection(Face face) {
|
|
||||||
if (!_isCaptured.value) {
|
|
||||||
final double? rotY = face.headEulerAngleY;
|
|
||||||
final double leftEyeOpen = face.leftEyeOpenProbability ?? -1.0;
|
|
||||||
final double rightEyeOpen = face.rightEyeOpenProbability ?? -1.0;
|
|
||||||
final double smileProb = face.smilingProbability ?? -1.0;
|
|
||||||
|
|
||||||
print("Head angle: $rotY");
|
|
||||||
print("Left eye open: $leftEyeOpen");
|
|
||||||
print("Right eye open: $rightEyeOpen");
|
|
||||||
print("Smiling probability: $smileProb");
|
|
||||||
|
|
||||||
_updateFaceInFrameStatus();
|
|
||||||
_updateHeadRotationStatus(rotY);
|
|
||||||
_updateSmilingStatus(smileProb);
|
|
||||||
_updateEyeOpenStatus(leftEyeOpen, rightEyeOpen);
|
|
||||||
_updateFaceInFrameForPhotoStatus(rotY, smileProb);
|
|
||||||
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
_isFaceRight.value &&
|
|
||||||
_isSmiled.value &&
|
|
||||||
_isFaceReadyForPhoto.value &&
|
|
||||||
_isEyeOpen.value) {
|
|
||||||
if (!_isCaptured.value) {
|
|
||||||
_captureImage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleNoFaceDetected() {
|
|
||||||
if (_isFaceInFrame.value) {
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
status.value = LivenessStatus.detectingFace;
|
|
||||||
currentInstruction.value =
|
|
||||||
'Face lost. Please position your face in the frame.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _duplicatePersonFaceDetect() {
|
|
||||||
if (_isDifferentPerson.value) {
|
|
||||||
_addSuccessfulStep('Different person Found');
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
status.value = LivenessStatus.detectingFace;
|
|
||||||
currentInstruction.value =
|
|
||||||
'Different person detected. Please ensure only you are in the frame.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateFaceInFrameStatus() {
|
|
||||||
if (!_isFaceInFrame.value) {
|
|
||||||
_isFaceInFrame.value = true;
|
|
||||||
_addSuccessfulStep('Face in frame');
|
|
||||||
|
|
||||||
if (status.value == LivenessStatus.detectingFace) {
|
|
||||||
status.value = LivenessStatus.checkLeftRotation;
|
|
||||||
currentInstruction.value = 'Great! Now rotate your face to the left';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateFaceInFrameForPhotoStatus(double? rotY, double? smileProb) {
|
|
||||||
if (_isFaceRight.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
rotY != null &&
|
|
||||||
rotY > -2 &&
|
|
||||||
rotY < 2 &&
|
|
||||||
smileProb! < 0.2) {
|
|
||||||
_isFaceReadyForPhoto.value = true;
|
|
||||||
_addSuccessfulStep('Face Ready For Photo');
|
|
||||||
|
|
||||||
if (status.value == LivenessStatus.checkEyesOpen) {
|
|
||||||
status.value = LivenessStatus.readyForPhoto;
|
|
||||||
currentInstruction.value = 'Perfect! Hold still for photo capture';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_isFaceReadyForPhoto.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateHeadRotationStatus(double? rotY) {
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
!_isFaceLeft.value &&
|
|
||||||
rotY != null &&
|
|
||||||
rotY < -7) {
|
|
||||||
_isFaceLeft.value = true;
|
|
||||||
_addSuccessfulStep('Face rotated left');
|
|
||||||
|
|
||||||
if (status.value == LivenessStatus.checkLeftRotation) {
|
|
||||||
status.value = LivenessStatus.checkRightRotation;
|
|
||||||
currentInstruction.value = 'Good! Now rotate your face to the right';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isFaceLeft.value && !_isFaceRight.value && rotY != null && rotY > 7) {
|
|
||||||
_isFaceRight.value = true;
|
|
||||||
_addSuccessfulStep('Face rotated right');
|
|
||||||
|
|
||||||
if (status.value == LivenessStatus.checkRightRotation) {
|
|
||||||
status.value = LivenessStatus.checkSmile;
|
|
||||||
currentInstruction.value = 'Great! Now smile for the camera';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateEyeOpenStatus(double leftEyeOpen, double rightEyeOpen) {
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
_isFaceRight.value &&
|
|
||||||
_isSmiled.value &&
|
|
||||||
!_isEyeOpen.value) {
|
|
||||||
if (leftEyeOpen > 0.3 && rightEyeOpen > 0.3) {
|
|
||||||
_isEyeOpen.value = true;
|
|
||||||
_addSuccessfulStep('Eyes Open');
|
|
||||||
|
|
||||||
if (status.value == LivenessStatus.checkEyesOpen) {
|
|
||||||
status.value = LivenessStatus.readyForPhoto;
|
|
||||||
currentInstruction.value = 'Perfect! Hold still for photo capture';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateSmilingStatus(double smileProb) {
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
_isFaceRight.value &&
|
|
||||||
!_isSmiled.value &&
|
|
||||||
smileProb > 0.3) {
|
|
||||||
_isSmiled.value = true;
|
|
||||||
_addSuccessfulStep('Smiling');
|
|
||||||
|
|
||||||
if (status.value == LivenessStatus.checkSmile) {
|
|
||||||
status.value = LivenessStatus.checkEyesOpen;
|
|
||||||
currentInstruction.value = 'Excellent! Now open your eyes wide';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _resetFaceDetectionStatus() {
|
|
||||||
_isFaceInFrame.value = false;
|
|
||||||
_isFaceLeft.value = false;
|
|
||||||
_isFaceRight.value = false;
|
|
||||||
_isEyeOpen.value = false;
|
|
||||||
_isNoFace.value = false;
|
|
||||||
_isMultiFace.value = false;
|
|
||||||
_isSmiled.value = false;
|
|
||||||
_successfulSteps.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void resetProcess() {
|
|
||||||
_capturedImage.value = null;
|
|
||||||
_isCaptured.value = false;
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
status.value = LivenessStatus.preparing;
|
|
||||||
currentInstruction.value = 'Resetting liveness check...';
|
|
||||||
|
|
||||||
// Reinitialize camera if needed
|
|
||||||
if (_cameraController == null || !_cameraController!.value.isInitialized) {
|
|
||||||
_initializeCamera();
|
|
||||||
} else {
|
|
||||||
status.value = LivenessStatus.detectingFace;
|
|
||||||
currentInstruction.value = 'Position your face in the frame';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addSuccessfulStep(String step) {
|
|
||||||
if (!_successfulSteps.contains(step)) {
|
|
||||||
_successfulSteps.add(step);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InputImage? _getInputImageFromCameraImage(CameraImage image) {
|
|
||||||
final sensorOrientation = frontCamera.sensorOrientation;
|
|
||||||
InputImageRotation? rotation;
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
|
|
||||||
} else if (Platform.isAndroid) {
|
|
||||||
var rotationCompensation =
|
|
||||||
orientations[_cameraController!.value.deviceOrientation];
|
|
||||||
if (rotationCompensation == null) return null;
|
|
||||||
if (frontCamera.lensDirection == CameraLensDirection.front) {
|
|
||||||
rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
|
|
||||||
} else {
|
|
||||||
rotationCompensation =
|
|
||||||
(sensorOrientation - rotationCompensation + 360) % 360;
|
|
||||||
}
|
|
||||||
rotation = InputImageRotationValue.fromRawValue(rotationCompensation!);
|
|
||||||
}
|
|
||||||
if (rotation == null) return null;
|
|
||||||
|
|
||||||
final format = InputImageFormatValue.fromRawValue(image.format.raw);
|
|
||||||
if (format == null ||
|
|
||||||
(Platform.isAndroid && format != InputImageFormat.nv21) ||
|
|
||||||
(Platform.isIOS && format != InputImageFormat.bgra8888))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (image.planes.length != 1) return null;
|
|
||||||
final plane = image.planes.first;
|
|
||||||
|
|
||||||
return InputImage.fromBytes(
|
|
||||||
bytes: plane.bytes,
|
|
||||||
metadata: InputImageMetadata(
|
|
||||||
size: Size(image.width.toDouble(), image.height.toDouble()),
|
|
||||||
rotation: rotation,
|
|
||||||
format: format,
|
|
||||||
bytesPerRow: plane.bytesPerRow,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _captureImage() async {
|
|
||||||
if (_cameraController!.value.isTakingPicture) return;
|
|
||||||
try {
|
|
||||||
status.value = LivenessStatus.photoTaken;
|
|
||||||
currentInstruction.value = 'Capturing photo...';
|
|
||||||
|
|
||||||
final XFile file = await _cameraController!.takePicture();
|
|
||||||
_isCaptured.value = true;
|
|
||||||
_capturedImage.value = file;
|
|
||||||
|
|
||||||
status.value = LivenessStatus.completed;
|
|
||||||
currentInstruction.value = 'Liveness check successful!';
|
|
||||||
|
|
||||||
_faceDetector.close();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error capturing image: $e');
|
|
||||||
status.value = LivenessStatus.failed;
|
|
||||||
currentInstruction.value = 'Failed to capture image: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Face comparison methods
|
|
||||||
Future<List<double>> _extractFaceEmbeddings(Face face) async {
|
|
||||||
return [
|
|
||||||
face.boundingBox.left,
|
|
||||||
face.boundingBox.top,
|
|
||||||
face.boundingBox.right,
|
|
||||||
face.boundingBox.bottom,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _compareFaces(Face currentFace) async {
|
|
||||||
final currentEmbedding = await _extractFaceEmbeddings(currentFace);
|
|
||||||
|
|
||||||
if (_firstPersonEmbedding == null) {
|
|
||||||
_firstPersonEmbedding = currentEmbedding;
|
|
||||||
} else {
|
|
||||||
final double similarity = _calculateSimilarity(
|
|
||||||
_firstPersonEmbedding!,
|
|
||||||
currentEmbedding,
|
|
||||||
);
|
|
||||||
_isDifferentPerson.value = similarity < 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _calculateSimilarity(
|
|
||||||
List<double> embedding1,
|
|
||||||
List<double> embedding2,
|
|
||||||
) {
|
|
||||||
double dotProduct = 0.0;
|
|
||||||
double norm1 = 0.0;
|
|
||||||
double norm2 = 0.0;
|
|
||||||
|
|
||||||
for (int i = 0; i < embedding1.length; i++) {
|
|
||||||
dotProduct += embedding1[i] * embedding2[i];
|
|
||||||
norm1 += embedding1[i] * embedding1[i];
|
|
||||||
norm2 += embedding2[i] * embedding2[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
|
|
||||||
}
|
|
||||||
|
|
||||||
String getCurrentDirection() {
|
|
||||||
// Use the currentInstruction instead
|
|
||||||
return currentInstruction.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isFaceInsideFrame(Rect boundingBox) {
|
|
||||||
const double previewWidth = 300;
|
|
||||||
const double previewHeight = 300;
|
|
||||||
|
|
||||||
return boundingBox.left >= 0 &&
|
|
||||||
boundingBox.top >= 0 &&
|
|
||||||
boundingBox.right <= previewWidth &&
|
|
||||||
boundingBox.bottom <= previewHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
final CameraController? cameraController = _cameraController;
|
|
||||||
if (cameraController == null || !cameraController.value.isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state == AppLifecycleState.inactive) {
|
|
||||||
cameraController.dispose();
|
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
|
||||||
_initializeCamera();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onClose() {
|
|
||||||
_faceDetector.close();
|
|
||||||
if (_cameraController != null) _cameraController!.dispose();
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_faceMeshDetector.close();
|
|
||||||
super.onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a FaceModel from the captured image
|
|
||||||
FaceModel generateFaceModel() {
|
|
||||||
if (_capturedImage.value == null) {
|
|
||||||
return FaceModel.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
return FaceModel(
|
|
||||||
imagePath: _capturedImage.value!.path,
|
|
||||||
faceId: 'live-face-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.95,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
).withLiveness(
|
|
||||||
isLive: true,
|
|
||||||
confidence: 0.92,
|
|
||||||
message: 'Liveness check passed successfully',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,703 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as dev;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||||
|
import 'package:google_mlkit_face_mesh_detection/google_mlkit_face_mesh_detection.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
|
|
||||||
|
// Enum for liveness status
|
||||||
|
enum LivenessStatus {
|
||||||
|
preparing,
|
||||||
|
detectingFace,
|
||||||
|
checkLeftRotation,
|
||||||
|
checkRightRotation,
|
||||||
|
checkSmile,
|
||||||
|
checkEyesOpen,
|
||||||
|
readyForPhoto,
|
||||||
|
photoTaken,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
class FaceLivenessController extends GetxController {
|
||||||
|
// Camera
|
||||||
|
CameraController? cameraController;
|
||||||
|
List<CameraDescription>? cameras;
|
||||||
|
|
||||||
|
// ML Kit detectors
|
||||||
|
late FaceDetector faceDetector;
|
||||||
|
late FaceMeshDetector faceMeshDetector;
|
||||||
|
|
||||||
|
// Observable states
|
||||||
|
final status = LivenessStatus.preparing.obs;
|
||||||
|
final isFaceInFrame = false.obs;
|
||||||
|
final isFaceLeft = false.obs;
|
||||||
|
final isFaceRight = false.obs;
|
||||||
|
final isEyeOpen = false.obs;
|
||||||
|
final isSmiled = false.obs;
|
||||||
|
final isFaceReadyForPhoto = false.obs;
|
||||||
|
final isCaptured = false.obs;
|
||||||
|
final successfulSteps = <String>[].obs;
|
||||||
|
|
||||||
|
// Image processing
|
||||||
|
XFile? capturedImage;
|
||||||
|
// Removed imageStreamSubscription as startImageStream does not return a StreamSubscription
|
||||||
|
bool isProcessingImage = false;
|
||||||
|
|
||||||
|
// Verification steps tracking
|
||||||
|
int currentStepIndex = 0;
|
||||||
|
final List<String> verificationSteps = [
|
||||||
|
'Look to your left',
|
||||||
|
'Look to your right',
|
||||||
|
'Please smile',
|
||||||
|
'Keep your eyes open',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Timing and thresholds
|
||||||
|
Timer? stepTimer;
|
||||||
|
Timer? stabilityTimer;
|
||||||
|
static const Duration stepTimeout = Duration(seconds: 10);
|
||||||
|
static const Duration stabilityDuration = Duration(milliseconds: 1500);
|
||||||
|
|
||||||
|
// Face detection thresholds
|
||||||
|
static const double leftRotationThreshold = -15.0;
|
||||||
|
static const double rightRotationThreshold = 15.0;
|
||||||
|
static const double smileThreshold = 0.3;
|
||||||
|
static const double eyeOpenThreshold = 0.4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
dev.log(
|
||||||
|
'FaceLivenessController initializing...',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
_initializeDetectors();
|
||||||
|
_initializeCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
dev.log('FaceLivenessController closing...', name: 'LIVENESS_CONTROLLER');
|
||||||
|
_cleanup();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize ML Kit detectors
|
||||||
|
void _initializeDetectors() {
|
||||||
|
try {
|
||||||
|
// Face detector with comprehensive options
|
||||||
|
faceDetector = FaceDetector(
|
||||||
|
options: FaceDetectorOptions(
|
||||||
|
enableContours: true,
|
||||||
|
enableLandmarks: true,
|
||||||
|
enableClassification: true,
|
||||||
|
enableTracking: true,
|
||||||
|
minFaceSize: 0.1,
|
||||||
|
performanceMode: FaceDetectorMode.accurate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Face mesh detector
|
||||||
|
faceMeshDetector = FaceMeshDetector(
|
||||||
|
option: FaceMeshDetectorOptions.faceMesh,
|
||||||
|
);
|
||||||
|
|
||||||
|
dev.log(
|
||||||
|
'ML Kit detectors initialized successfully',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
dev.log(
|
||||||
|
'Error initializing ML Kit detectors: $e',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize camera
|
||||||
|
Future<void> _initializeCamera() async {
|
||||||
|
try {
|
||||||
|
dev.log('Initializing camera...', name: 'LIVENESS_CONTROLLER');
|
||||||
|
|
||||||
|
cameras = await availableCameras();
|
||||||
|
if (cameras == null || cameras!.isEmpty) {
|
||||||
|
dev.log('No cameras available', name: 'LIVENESS_CONTROLLER');
|
||||||
|
status.value = LivenessStatus.failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find front camera
|
||||||
|
final frontCamera = cameras!.firstWhere(
|
||||||
|
(camera) => camera.lensDirection == CameraLensDirection.front,
|
||||||
|
orElse: () => cameras!.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
cameraController = CameraController(
|
||||||
|
frontCamera,
|
||||||
|
ResolutionPreset.medium,
|
||||||
|
enableAudio: false,
|
||||||
|
imageFormatGroup: ImageFormatGroup.nv21,
|
||||||
|
);
|
||||||
|
|
||||||
|
await cameraController!.initialize();
|
||||||
|
|
||||||
|
dev.log('Camera initialized successfully', name: 'LIVENESS_CONTROLLER');
|
||||||
|
|
||||||
|
// Start image stream for face detection
|
||||||
|
_startImageStream();
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
status.value = LivenessStatus.detectingFace;
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error initializing camera: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
status.value = LivenessStatus.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start image stream for real-time face detection
|
||||||
|
void _startImageStream() {
|
||||||
|
if (cameraController == null || !cameraController!.value.isInitialized) {
|
||||||
|
dev.log('Camera not ready for image stream', name: 'LIVENESS_CONTROLLER');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cameraController!.startImageStream((CameraImage image) {
|
||||||
|
if (!isProcessingImage && cameraController!.value.isInitialized) {
|
||||||
|
isProcessingImage = true;
|
||||||
|
_processImage(image).catchError((error) {
|
||||||
|
dev.log(
|
||||||
|
'Error in image processing: $error',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
isProcessingImage = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dev.log('Image stream started', name: 'LIVENESS_CONTROLLER');
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error starting image stream: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
// Retry after a delay
|
||||||
|
Timer(Duration(seconds: 2), () {
|
||||||
|
if (cameraController?.value.isInitialized == true) {
|
||||||
|
_startImageStream();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process camera image for face detection
|
||||||
|
Future<void> _processImage(CameraImage image) async {
|
||||||
|
try {
|
||||||
|
// Convert camera image to InputImage
|
||||||
|
final inputImage = _convertCameraImage(image);
|
||||||
|
if (inputImage == null) return;
|
||||||
|
|
||||||
|
// Detect faces
|
||||||
|
final faces = await faceDetector.processImage(inputImage);
|
||||||
|
|
||||||
|
// Process face detection results
|
||||||
|
await _processFaceDetection(faces);
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error processing image: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
} finally {
|
||||||
|
isProcessingImage = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CameraImage to InputImage
|
||||||
|
InputImage? _convertCameraImage(CameraImage image) {
|
||||||
|
try {
|
||||||
|
if (cameras == null || cameras!.isEmpty) {
|
||||||
|
dev.log(
|
||||||
|
'No cameras available for conversion',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final camera = cameras!.firstWhere(
|
||||||
|
(camera) => camera.lensDirection == CameraLensDirection.front,
|
||||||
|
orElse: () => cameras!.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
final sensorOrientation = camera.sensorOrientation;
|
||||||
|
InputImageRotation? rotation;
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
|
||||||
|
} else if (Platform.isAndroid) {
|
||||||
|
var rotationCompensation = sensorOrientation;
|
||||||
|
rotation = InputImageRotationValue.fromRawValue(rotationCompensation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotation == null) {
|
||||||
|
dev.log('Could not determine rotation', name: 'LIVENESS_CONTROLLER');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final format = InputImageFormatValue.fromRawValue(image.format.raw);
|
||||||
|
if (format == null) {
|
||||||
|
dev.log(
|
||||||
|
'Unsupported image format: ${image.format.raw}',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different plane configurations
|
||||||
|
if (image.planes.isEmpty) {
|
||||||
|
dev.log('No image planes available', name: 'LIVENESS_CONTROLLER');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final plane = image.planes.first;
|
||||||
|
|
||||||
|
return InputImage.fromBytes(
|
||||||
|
bytes: plane.bytes,
|
||||||
|
metadata: InputImageMetadata(
|
||||||
|
size: Size(image.width.toDouble(), image.height.toDouble()),
|
||||||
|
rotation: rotation,
|
||||||
|
format: format,
|
||||||
|
bytesPerRow: plane.bytesPerRow,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error converting camera image: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process face detection results
|
||||||
|
Future<void> _processFaceDetection(List<Face> faces) async {
|
||||||
|
if (faces.isEmpty) {
|
||||||
|
isFaceInFrame.value = false;
|
||||||
|
_resetFaceStates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faces.length > 1) {
|
||||||
|
dev.log('Multiple faces detected, ignoring', name: 'LIVENESS_CONTROLLER');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final face = faces.first;
|
||||||
|
isFaceInFrame.value = true;
|
||||||
|
|
||||||
|
// Check face rotation (head pose)
|
||||||
|
final rotY = face.headEulerAngleY ?? 0.0;
|
||||||
|
final rotX = face.headEulerAngleX ?? 0.0;
|
||||||
|
final rotZ = face.headEulerAngleZ ?? 0.0;
|
||||||
|
|
||||||
|
// Update face orientation states
|
||||||
|
isFaceLeft.value = rotY < leftRotationThreshold;
|
||||||
|
isFaceRight.value = rotY > rightRotationThreshold;
|
||||||
|
|
||||||
|
// Check eyes open probability
|
||||||
|
final leftEyeOpen = face.leftEyeOpenProbability ?? 0.0;
|
||||||
|
final rightEyeOpen = face.rightEyeOpenProbability ?? 0.0;
|
||||||
|
isEyeOpen.value =
|
||||||
|
(leftEyeOpen > eyeOpenThreshold && rightEyeOpen > eyeOpenThreshold);
|
||||||
|
|
||||||
|
// Check smile probability
|
||||||
|
final smilingProbability = face.smilingProbability ?? 0.0;
|
||||||
|
isSmiled.value = smilingProbability > smileThreshold;
|
||||||
|
|
||||||
|
dev.log(
|
||||||
|
'Face detection - RotY: ${rotY.toStringAsFixed(1)}, '
|
||||||
|
'RotX: ${rotX.toStringAsFixed(1)}, '
|
||||||
|
'RotZ: ${rotZ.toStringAsFixed(1)}, '
|
||||||
|
'Eyes: L=${leftEyeOpen.toStringAsFixed(2)} R=${rightEyeOpen.toStringAsFixed(2)}, '
|
||||||
|
'Smile: ${smilingProbability.toStringAsFixed(2)}',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process current verification step
|
||||||
|
await _processVerificationStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process current verification step
|
||||||
|
Future<void> _processVerificationStep() async {
|
||||||
|
switch (status.value) {
|
||||||
|
case LivenessStatus.detectingFace:
|
||||||
|
if (isFaceInFrame.value) {
|
||||||
|
_startNextVerificationStep();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LivenessStatus.checkLeftRotation:
|
||||||
|
if (isFaceLeft.value) {
|
||||||
|
_completeCurrentStep('✓ Looked left');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LivenessStatus.checkRightRotation:
|
||||||
|
if (isFaceRight.value) {
|
||||||
|
_completeCurrentStep('✓ Looked right');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LivenessStatus.checkSmile:
|
||||||
|
if (isSmiled.value) {
|
||||||
|
_completeCurrentStep('✓ Smiled detected');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LivenessStatus.checkEyesOpen:
|
||||||
|
if (isEyeOpen.value) {
|
||||||
|
_completeCurrentStep('✓ Eyes open confirmed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start next verification step
|
||||||
|
void _startNextVerificationStep() {
|
||||||
|
stepTimer?.cancel();
|
||||||
|
|
||||||
|
if (currentStepIndex >= verificationSteps.length) {
|
||||||
|
_allStepsCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status based on current step
|
||||||
|
switch (currentStepIndex) {
|
||||||
|
case 0:
|
||||||
|
status.value = LivenessStatus.checkLeftRotation;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
status.value = LivenessStatus.checkRightRotation;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
status.value = LivenessStatus.checkSmile;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
status.value = LivenessStatus.checkEyesOpen;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
dev.log(
|
||||||
|
'Starting verification step: ${currentStepIndex + 1}/${verificationSteps.length}',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set timeout for current step
|
||||||
|
stepTimer = Timer(stepTimeout, () {
|
||||||
|
dev.log(
|
||||||
|
'Step ${currentStepIndex + 1} timed out',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
_handleStepTimeout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete current verification step
|
||||||
|
void _completeCurrentStep(String stepDescription) {
|
||||||
|
stepTimer?.cancel();
|
||||||
|
stabilityTimer?.cancel();
|
||||||
|
|
||||||
|
// Add stability check to prevent false positives
|
||||||
|
stabilityTimer = Timer(stabilityDuration, () {
|
||||||
|
if (!successfulSteps.contains(stepDescription)) {
|
||||||
|
successfulSteps.add(stepDescription);
|
||||||
|
currentStepIndex++;
|
||||||
|
|
||||||
|
dev.log(
|
||||||
|
'Step completed: $stepDescription',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move to next step
|
||||||
|
_startNextVerificationStep();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle step timeout
|
||||||
|
void _handleStepTimeout() {
|
||||||
|
dev.log('Step timeout - forcing next step', name: 'LIVENESS_CONTROLLER');
|
||||||
|
// For demo purposes, we'll be lenient and move to next step
|
||||||
|
// In production, you might want to be stricter
|
||||||
|
successfulSteps.add('⚠ ${verificationSteps[currentStepIndex]} (timeout)');
|
||||||
|
currentStepIndex++;
|
||||||
|
_startNextVerificationStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All verification steps completed
|
||||||
|
void _allStepsCompleted() {
|
||||||
|
dev.log('All verification steps completed', name: 'LIVENESS_CONTROLLER');
|
||||||
|
status.value = LivenessStatus.readyForPhoto;
|
||||||
|
isFaceReadyForPhoto.value = true;
|
||||||
|
|
||||||
|
// Auto-capture after a short delay
|
||||||
|
Timer(Duration(seconds: 1), () {
|
||||||
|
if (!isCaptured.value) {
|
||||||
|
captureImage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture image
|
||||||
|
Future<void> captureImage() async {
|
||||||
|
try {
|
||||||
|
if (cameraController == null || !cameraController!.value.isInitialized) {
|
||||||
|
dev.log('Camera not ready for capture', name: 'LIVENESS_CONTROLLER');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dev.log('Capturing image...', name: 'LIVENESS_CONTROLLER');
|
||||||
|
|
||||||
|
// Stop image stream before capture with error handling
|
||||||
|
try {
|
||||||
|
await cameraController?.stopImageStream();
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error stopping image stream: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
// Continue with capture anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
status.value = LivenessStatus.photoTaken;
|
||||||
|
|
||||||
|
// Capture image with retry logic
|
||||||
|
int retryCount = 0;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
while (retryCount < maxRetries) {
|
||||||
|
try {
|
||||||
|
capturedImage = await cameraController!.takePicture();
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
retryCount++;
|
||||||
|
dev.log(
|
||||||
|
'Capture attempt $retryCount failed: $e',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (retryCount >= maxRetries) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retry
|
||||||
|
await Future.delayed(Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dev.log(
|
||||||
|
'Image captured: ${capturedImage?.path}',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update states
|
||||||
|
isCaptured.value = true;
|
||||||
|
status.value = LivenessStatus.completed;
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error capturing image: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
status.value = LivenessStatus.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force capture (for debugging)
|
||||||
|
Future<void> forceCaptureImage() async {
|
||||||
|
dev.log('Force capturing image...', name: 'LIVENESS_CONTROLLER');
|
||||||
|
await captureImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset face detection states
|
||||||
|
void _resetFaceStates() {
|
||||||
|
isFaceLeft.value = false;
|
||||||
|
isFaceRight.value = false;
|
||||||
|
isEyeOpen.value = false;
|
||||||
|
isSmiled.value = false;
|
||||||
|
isFaceReadyForPhoto.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current direction/instruction
|
||||||
|
String getCurrentDirection() {
|
||||||
|
switch (status.value) {
|
||||||
|
case LivenessStatus.preparing:
|
||||||
|
return 'Preparing camera...';
|
||||||
|
case LivenessStatus.detectingFace:
|
||||||
|
return 'Position your face in the frame';
|
||||||
|
case LivenessStatus.checkLeftRotation:
|
||||||
|
return 'Slowly turn your head to the left';
|
||||||
|
case LivenessStatus.checkRightRotation:
|
||||||
|
return 'Now turn your head to the right';
|
||||||
|
case LivenessStatus.checkSmile:
|
||||||
|
return 'Please smile for the camera';
|
||||||
|
case LivenessStatus.checkEyesOpen:
|
||||||
|
return 'Keep your eyes wide open';
|
||||||
|
case LivenessStatus.readyForPhoto:
|
||||||
|
return 'Perfect! Hold still for photo capture';
|
||||||
|
case LivenessStatus.photoTaken:
|
||||||
|
return 'Processing your photo...';
|
||||||
|
case LivenessStatus.completed:
|
||||||
|
return 'Verification completed successfully!';
|
||||||
|
case LivenessStatus.failed:
|
||||||
|
return 'Verification failed. Please try again.';
|
||||||
|
default:
|
||||||
|
return 'Follow the instructions on screen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancellation (called when user goes back)
|
||||||
|
void handleCancellation() {
|
||||||
|
dev.log('Handling cancellation...', name: 'LIVENESS_CONTROLLER');
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the entire process
|
||||||
|
void resetProcess() {
|
||||||
|
dev.log(
|
||||||
|
'Resetting liveness detection process...',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset all states
|
||||||
|
status.value = LivenessStatus.preparing;
|
||||||
|
isFaceInFrame.value = false;
|
||||||
|
isFaceLeft.value = false;
|
||||||
|
isFaceRight.value = false;
|
||||||
|
isEyeOpen.value = false;
|
||||||
|
isSmiled.value = false;
|
||||||
|
isFaceReadyForPhoto.value = false;
|
||||||
|
isCaptured.value = false;
|
||||||
|
successfulSteps.clear();
|
||||||
|
|
||||||
|
// Reset step tracking
|
||||||
|
currentStepIndex = 0;
|
||||||
|
capturedImage = null;
|
||||||
|
|
||||||
|
// Cancel timers
|
||||||
|
stepTimer?.cancel();
|
||||||
|
stabilityTimer?.cancel();
|
||||||
|
|
||||||
|
// Restart the process
|
||||||
|
status.value = LivenessStatus.detectingFace;
|
||||||
|
if (cameraController?.value.isInitialized == true) {
|
||||||
|
_startImageStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug methods
|
||||||
|
void skipAllVerificationSteps() {
|
||||||
|
dev.log(
|
||||||
|
'DEBUG: Skipping all verification steps',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
|
||||||
|
stepTimer?.cancel();
|
||||||
|
stabilityTimer?.cancel();
|
||||||
|
|
||||||
|
// Add all steps as completed
|
||||||
|
successfulSteps.clear();
|
||||||
|
successfulSteps.addAll([
|
||||||
|
'✓ Looked left (debug skip)',
|
||||||
|
'✓ Looked right (debug skip)',
|
||||||
|
'✓ Smiled detected (debug skip)',
|
||||||
|
'✓ Eyes open confirmed (debug skip)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
currentStepIndex = verificationSteps.length;
|
||||||
|
_allStepsCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
void forceAdvanceToNextStep() {
|
||||||
|
dev.log('DEBUG: Forcing advance to next step', name: 'LIVENESS_CONTROLLER');
|
||||||
|
|
||||||
|
if (currentStepIndex < verificationSteps.length) {
|
||||||
|
successfulSteps.add(
|
||||||
|
'⚠ ${verificationSteps[currentStepIndex]} (debug skip)',
|
||||||
|
);
|
||||||
|
currentStepIndex++;
|
||||||
|
_startNextVerificationStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void testFaceDetection() {
|
||||||
|
dev.log(
|
||||||
|
'DEBUG: Face Detection Test - '
|
||||||
|
'Face in frame: ${isFaceInFrame.value}, '
|
||||||
|
'Face left: ${isFaceLeft.value}, '
|
||||||
|
'Face right: ${isFaceRight.value}, '
|
||||||
|
'Eyes open: ${isEyeOpen.value}, '
|
||||||
|
'Smiled: ${isSmiled.value}',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void debugCameraStream() {
|
||||||
|
dev.log(
|
||||||
|
'DEBUG: Camera Stream - '
|
||||||
|
'Controller initialized: ${cameraController?.value.isInitialized}, '
|
||||||
|
// Removed StreamSubscription check as it's no longer used
|
||||||
|
'Processing: $isProcessingImage',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup resources
|
||||||
|
void _cleanup() {
|
||||||
|
dev.log('Cleaning up resources...', name: 'LIVENESS_CONTROLLER');
|
||||||
|
|
||||||
|
// Cancel timers
|
||||||
|
stepTimer?.cancel();
|
||||||
|
stabilityTimer?.cancel();
|
||||||
|
|
||||||
|
// Stop image stream with error handling
|
||||||
|
try {
|
||||||
|
cameraController?.stopImageStream();
|
||||||
|
} catch (e) {
|
||||||
|
dev.log(
|
||||||
|
'Error stopping image stream during cleanup: $e',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispose camera with error handling
|
||||||
|
try {
|
||||||
|
cameraController?.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error disposing camera: $e', name: 'LIVENESS_CONTROLLER');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ML Kit detectors
|
||||||
|
try {
|
||||||
|
faceDetector.close();
|
||||||
|
faceMeshDetector.close();
|
||||||
|
} catch (e) {
|
||||||
|
dev.log(
|
||||||
|
'Error closing ML Kit detectors: $e',
|
||||||
|
name: 'LIVENESS_CONTROLLER',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate face model
|
||||||
|
FaceModel generateFaceModel() {
|
||||||
|
if (capturedImage == null) {
|
||||||
|
return FaceModel.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate id
|
||||||
|
final faceId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
|
||||||
|
return FaceModel(
|
||||||
|
imagePath: capturedImage!.path,
|
||||||
|
faceId: faceId,
|
||||||
|
confidence: 0.95,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
).withLiveness(
|
||||||
|
isLive: true,
|
||||||
|
confidence: 0.92,
|
||||||
|
message: 'Liveness check passed successfully',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/edge_function_service.dart';
|
import 'package:sigap/src/cores/services/edge_function_service.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart';
|
||||||
|
|
||||||
/// Service for handling facial verification
|
/// Service for handling facial verification
|
||||||
/// This class serves as a bridge between UI controllers and face detection functionality
|
/// This class serves as a bridge between UI controllers and face detection functionality
|
||||||
|
@ -45,12 +45,22 @@ class FacialVerificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare faces between two images using edge function
|
/// Compare faces between two images using edge function
|
||||||
Future<FaceComparisonResult> compareFaces(XFile source, XFile target) async {
|
Future<FaceComparisonResult> compareFaces(
|
||||||
|
XFile source,
|
||||||
|
XFile target, {
|
||||||
|
FaceModel? sourceModel,
|
||||||
|
FaceModel? targetModel,
|
||||||
|
}) async {
|
||||||
if (skipFaceVerification) {
|
if (skipFaceVerification) {
|
||||||
return _createDummyComparisonResult(source.path, target.path);
|
return _createDummyComparisonResult(source.path, target.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _edgeFunctionService.compareFaces(source, target);
|
return await _edgeFunctionService.compareFaces(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceModel: sourceModel,
|
||||||
|
targetModel: targetModel,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start liveness check - this will navigate to the liveness check screen
|
/// Start liveness check - this will navigate to the liveness check screen
|
||||||
|
|
|
@ -1,498 +0,0 @@
|
||||||
import 'dart:io' as i;
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:math' as Math;
|
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
|
||||||
import 'package:google_mlkit_face_mesh_detection/google_mlkit_face_mesh_detection.dart';
|
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
|
||||||
|
|
||||||
class FaceLivenessController extends GetxController
|
|
||||||
with WidgetsBindingObserver {
|
|
||||||
// Camera
|
|
||||||
CameraController? _cameraController;
|
|
||||||
late FaceDetector _faceDetector;
|
|
||||||
var frontCamera;
|
|
||||||
|
|
||||||
// Face Detection States
|
|
||||||
final _isFaceInFrame = false.obs;
|
|
||||||
final _isFaceLeft = false.obs;
|
|
||||||
final _isFaceRight = false.obs;
|
|
||||||
final _isEyeOpen = false.obs;
|
|
||||||
final _isNoFace = false.obs;
|
|
||||||
final _isMultiFace = false.obs;
|
|
||||||
final _isCaptured = false.obs;
|
|
||||||
final _isSmiled = false.obs;
|
|
||||||
final _isFaceReadyForPhoto = false.obs;
|
|
||||||
final _isDifferentPerson = false.obs;
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
bool get isFaceInFrame => _isFaceInFrame.value;
|
|
||||||
bool get isFaceLeft => _isFaceLeft.value;
|
|
||||||
bool get isFaceRight => _isFaceRight.value;
|
|
||||||
bool get isEyeOpen => _isEyeOpen.value;
|
|
||||||
bool get isNoFace => _isNoFace.value;
|
|
||||||
bool get isMultiFace => _isMultiFace.value;
|
|
||||||
bool get isCaptured => _isCaptured.value;
|
|
||||||
bool get isSmiled => _isSmiled.value;
|
|
||||||
bool get isFaceReadyForPhoto => _isFaceReadyForPhoto.value;
|
|
||||||
bool get isDifferentPerson => _isDifferentPerson.value;
|
|
||||||
|
|
||||||
CameraController? get cameraController => _cameraController;
|
|
||||||
|
|
||||||
// Face Mesh Detector
|
|
||||||
final FaceMeshDetector _faceMeshDetector = FaceMeshDetector(
|
|
||||||
option: FaceMeshDetectorOptions.faceMesh,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Face Comparison
|
|
||||||
List<double>? _firstPersonEmbedding;
|
|
||||||
|
|
||||||
// Captured Image
|
|
||||||
final _capturedImage = Rxn<XFile>();
|
|
||||||
XFile? get capturedImage => _capturedImage.value;
|
|
||||||
|
|
||||||
// Successful Steps
|
|
||||||
final _successfulSteps = <String>[].obs;
|
|
||||||
List<String> get successfulSteps => _successfulSteps;
|
|
||||||
|
|
||||||
// Face Detector Options
|
|
||||||
final FaceDetectorOptions options = FaceDetectorOptions(
|
|
||||||
performanceMode:
|
|
||||||
Platform.isAndroid ? FaceDetectorMode.fast : FaceDetectorMode.accurate,
|
|
||||||
enableClassification: true,
|
|
||||||
enableLandmarks: true,
|
|
||||||
enableTracking: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Device Orientations
|
|
||||||
final orientations = {
|
|
||||||
DeviceOrientation.portraitUp: 0,
|
|
||||||
DeviceOrientation.landscapeLeft: 90,
|
|
||||||
DeviceOrientation.portraitDown: 180,
|
|
||||||
DeviceOrientation.landscapeRight: 270,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onInit() {
|
|
||||||
super.onInit();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
_initializeCamera();
|
|
||||||
_faceDetector = FaceDetector(options: options);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initializeCamera() async {
|
|
||||||
try {
|
|
||||||
final cameras = await availableCameras();
|
|
||||||
final frontCameras = cameras.firstWhere(
|
|
||||||
(camera) => camera.lensDirection == CameraLensDirection.front,
|
|
||||||
);
|
|
||||||
|
|
||||||
frontCamera = frontCameras;
|
|
||||||
|
|
||||||
_cameraController = CameraController(
|
|
||||||
frontCamera,
|
|
||||||
ResolutionPreset.medium,
|
|
||||||
imageFormatGroup:
|
|
||||||
Platform.isAndroid
|
|
||||||
? ImageFormatGroup.nv21
|
|
||||||
: ImageFormatGroup.bgra8888,
|
|
||||||
);
|
|
||||||
|
|
||||||
await _cameraController!.initialize();
|
|
||||||
|
|
||||||
_cameraController!.startImageStream((CameraImage img) {
|
|
||||||
_processCameraImage(img);
|
|
||||||
});
|
|
||||||
|
|
||||||
update(); // Notify GetX to rebuild UI
|
|
||||||
} catch (e) {
|
|
||||||
print('Error initializing camera: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _processCameraImage(CameraImage img) async {
|
|
||||||
try {
|
|
||||||
final inputImage = _getInputImageFromCameraImage(img);
|
|
||||||
if (inputImage == null) return;
|
|
||||||
|
|
||||||
final List<Face> faces = await _faceDetector.processImage(inputImage);
|
|
||||||
|
|
||||||
if (faces.length > 1) {
|
|
||||||
_isMultiFace.value = true;
|
|
||||||
_successfulSteps.clear();
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
} else if (faces.isEmpty) {
|
|
||||||
_isNoFace.value = true;
|
|
||||||
_successfulSteps.clear();
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
} else if (faces.isNotEmpty) {
|
|
||||||
_isMultiFace.value = false;
|
|
||||||
_isNoFace.value = false;
|
|
||||||
final Face face = faces.first;
|
|
||||||
await _compareFaces(face);
|
|
||||||
|
|
||||||
if (_isDifferentPerson.value) {
|
|
||||||
_duplicatePersonFaceDetect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_handleFaceDetection(face);
|
|
||||||
} else {
|
|
||||||
_handleNoFaceDetected();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error processing camera image: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleFaceDetection(Face face) {
|
|
||||||
if (!_isCaptured.value) {
|
|
||||||
final double? rotY = face.headEulerAngleY;
|
|
||||||
final double leftEyeOpen = face.leftEyeOpenProbability ?? -1.0;
|
|
||||||
final double rightEyeOpen = face.rightEyeOpenProbability ?? -1.0;
|
|
||||||
final double smileProb = face.smilingProbability ?? -1.0;
|
|
||||||
|
|
||||||
print("Head angle: $rotY");
|
|
||||||
print("Left eye open: $leftEyeOpen");
|
|
||||||
print("Right eye open: $rightEyeOpen");
|
|
||||||
print("Smiling probability: $smileProb");
|
|
||||||
|
|
||||||
_updateFaceInFrameStatus();
|
|
||||||
_updateHeadRotationStatus(rotY);
|
|
||||||
_updateSmilingStatus(smileProb);
|
|
||||||
_updateEyeOpenStatus(leftEyeOpen, rightEyeOpen);
|
|
||||||
_updateFaceInFrameForPhotoStatus(rotY, smileProb);
|
|
||||||
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
_isFaceRight.value &&
|
|
||||||
_isSmiled.value &&
|
|
||||||
_isFaceReadyForPhoto.value &&
|
|
||||||
_isEyeOpen.value) {
|
|
||||||
if (!_isCaptured.value) {
|
|
||||||
_captureImage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleNoFaceDetected() {
|
|
||||||
if (_isFaceInFrame.value) {
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _duplicatePersonFaceDetect() {
|
|
||||||
if (_isDifferentPerson.value) {
|
|
||||||
_addSuccessfulStep('Different person Found');
|
|
||||||
_resetFaceDetectionStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateFaceInFrameStatus() {
|
|
||||||
if (!_isFaceInFrame.value) {
|
|
||||||
_isFaceInFrame.value = true;
|
|
||||||
_addSuccessfulStep('Face in frame');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateFaceInFrameForPhotoStatus(double? rotY, double? smileProb) {
|
|
||||||
if (_isFaceRight.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
rotY != null &&
|
|
||||||
rotY > -2 &&
|
|
||||||
rotY < 2 &&
|
|
||||||
smileProb! < 0.2) {
|
|
||||||
_isFaceReadyForPhoto.value = true;
|
|
||||||
_addSuccessfulStep('Face Ready For Photo');
|
|
||||||
} else {
|
|
||||||
_isFaceReadyForPhoto.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateHeadRotationStatus(double? rotY) {
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
!_isFaceLeft.value &&
|
|
||||||
rotY != null &&
|
|
||||||
rotY < -7) {
|
|
||||||
_isFaceLeft.value = true;
|
|
||||||
_addSuccessfulStep('Face rotated left');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isFaceLeft.value && !_isFaceRight.value && rotY != null && rotY > 7) {
|
|
||||||
_isFaceRight.value = true;
|
|
||||||
_addSuccessfulStep('Face rotated right');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateEyeOpenStatus(double leftEyeOpen, double rightEyeOpen) {
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
_isFaceRight.value &&
|
|
||||||
_isSmiled.value &&
|
|
||||||
!_isEyeOpen.value) {
|
|
||||||
if (leftEyeOpen > 0.3 && rightEyeOpen > 0.3) {
|
|
||||||
_isEyeOpen.value = true;
|
|
||||||
_addSuccessfulStep('Eyes Open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateSmilingStatus(double smileProb) {
|
|
||||||
if (_isFaceInFrame.value &&
|
|
||||||
_isFaceLeft.value &&
|
|
||||||
_isFaceRight.value &&
|
|
||||||
!_isSmiled.value &&
|
|
||||||
smileProb > 0.3) {
|
|
||||||
_isSmiled.value = true;
|
|
||||||
_addSuccessfulStep('Smiling');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _resetFaceDetectionStatus() {
|
|
||||||
_isFaceInFrame.value = false;
|
|
||||||
_isFaceLeft.value = false;
|
|
||||||
_isFaceRight.value = false;
|
|
||||||
_isEyeOpen.value = false;
|
|
||||||
_isNoFace.value = false;
|
|
||||||
_isMultiFace.value = false;
|
|
||||||
_isSmiled.value = false;
|
|
||||||
_successfulSteps.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addSuccessfulStep(String step) {
|
|
||||||
if (!_successfulSteps.contains(step)) {
|
|
||||||
_successfulSteps.add(step);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InputImage? _getInputImageFromCameraImage(CameraImage image) {
|
|
||||||
final sensorOrientation = frontCamera.sensorOrientation;
|
|
||||||
InputImageRotation? rotation;
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
|
|
||||||
} else if (Platform.isAndroid) {
|
|
||||||
var rotationCompensation =
|
|
||||||
orientations[_cameraController!.value.deviceOrientation];
|
|
||||||
if (rotationCompensation == null) return null;
|
|
||||||
if (frontCamera.lensDirection == CameraLensDirection.front) {
|
|
||||||
rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
|
|
||||||
} else {
|
|
||||||
rotationCompensation =
|
|
||||||
(sensorOrientation - rotationCompensation + 360) % 360;
|
|
||||||
}
|
|
||||||
rotation = InputImageRotationValue.fromRawValue(rotationCompensation!);
|
|
||||||
}
|
|
||||||
if (rotation == null) return null;
|
|
||||||
|
|
||||||
final format = InputImageFormatValue.fromRawValue(image.format.raw);
|
|
||||||
if (format == null ||
|
|
||||||
(Platform.isAndroid && format != InputImageFormat.nv21) ||
|
|
||||||
(Platform.isIOS && format != InputImageFormat.bgra8888))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (image.planes.length != 1) return null;
|
|
||||||
final plane = image.planes.first;
|
|
||||||
|
|
||||||
return InputImage.fromBytes(
|
|
||||||
bytes: plane.bytes,
|
|
||||||
metadata: InputImageMetadata(
|
|
||||||
size: Size(image.width.toDouble(), image.height.toDouble()),
|
|
||||||
rotation: rotation,
|
|
||||||
format: format,
|
|
||||||
bytesPerRow: plane.bytesPerRow,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _captureImage() async {
|
|
||||||
if (_cameraController!.value.isTakingPicture) return;
|
|
||||||
try {
|
|
||||||
final XFile file = await _cameraController!.takePicture();
|
|
||||||
_isCaptured.value = true;
|
|
||||||
_capturedImage.value = file;
|
|
||||||
final bytes = i.File(file.path).readAsBytesSync();
|
|
||||||
_faceDetector.close();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error capturing image: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Face comparison methods
|
|
||||||
Future<List<double>> _extractFaceEmbeddings(Face face) async {
|
|
||||||
return [
|
|
||||||
face.boundingBox.left,
|
|
||||||
face.boundingBox.top,
|
|
||||||
face.boundingBox.right,
|
|
||||||
face.boundingBox.bottom,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _compareFaces(Face currentFace) async {
|
|
||||||
final currentEmbedding = await _extractFaceEmbeddings(currentFace);
|
|
||||||
|
|
||||||
if (_firstPersonEmbedding == null) {
|
|
||||||
_firstPersonEmbedding = currentEmbedding;
|
|
||||||
} else {
|
|
||||||
final double similarity = _calculateSimilarity(
|
|
||||||
_firstPersonEmbedding!,
|
|
||||||
currentEmbedding,
|
|
||||||
);
|
|
||||||
_isDifferentPerson.value = similarity < 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _calculateSimilarity(
|
|
||||||
List<double> embedding1,
|
|
||||||
List<double> embedding2,
|
|
||||||
) {
|
|
||||||
double dotProduct = 0.0;
|
|
||||||
double norm1 = 0.0;
|
|
||||||
double norm2 = 0.0;
|
|
||||||
|
|
||||||
for (int i = 0; i < embedding1.length; i++) {
|
|
||||||
dotProduct += embedding1[i] * embedding2[i];
|
|
||||||
norm1 += embedding1[i] * embedding1[i];
|
|
||||||
norm2 += embedding2[i] * embedding2[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
|
|
||||||
}
|
|
||||||
|
|
||||||
String getCurrentDirection() {
|
|
||||||
if (!_isFaceInFrame.value) {
|
|
||||||
return 'Enter your face in the frame';
|
|
||||||
} else if (_isNoFace.value) {
|
|
||||||
return 'No Faces in Camera';
|
|
||||||
} else if (_isMultiFace.value) {
|
|
||||||
return 'Multi Faces in Camera';
|
|
||||||
} else if (!_isFaceLeft.value) {
|
|
||||||
return 'Rotate your face to the left (10° & 5 Sec)';
|
|
||||||
} else if (!_isFaceRight.value) {
|
|
||||||
return 'Rotate your face to the right (10° & 5 Sec)';
|
|
||||||
} else if (!_isSmiled.value) {
|
|
||||||
return 'Keep One Smile ';
|
|
||||||
} else if (!_isEyeOpen.value) {
|
|
||||||
return 'Open Your Eyes';
|
|
||||||
} else if (!_isFaceReadyForPhoto.value) {
|
|
||||||
return 'Ready For capture Photo, don\'t laughing and keep strait your photo';
|
|
||||||
} else {
|
|
||||||
return 'Liveness detected! Image captured.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isFaceInsideFrame(Rect boundingBox) {
|
|
||||||
const double previewWidth = 300;
|
|
||||||
const double previewHeight = 300;
|
|
||||||
|
|
||||||
return boundingBox.left >= 0 &&
|
|
||||||
boundingBox.top >= 0 &&
|
|
||||||
boundingBox.right <= previewWidth &&
|
|
||||||
boundingBox.bottom <= previewHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
final CameraController? cameraController = _cameraController;
|
|
||||||
if (cameraController == null || !cameraController.value.isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state == AppLifecycleState.inactive) {
|
|
||||||
cameraController.dispose();
|
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
|
||||||
_initializeCamera();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onClose() {
|
|
||||||
_faceDetector.close();
|
|
||||||
if (_cameraController != null) _cameraController!.dispose();
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_faceMeshDetector.close();
|
|
||||||
super.onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a FaceModel from the captured image
|
|
||||||
FaceModel generateFaceModel() {
|
|
||||||
if (_capturedImage.value == null) {
|
|
||||||
return FaceModel.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
return FaceModel(
|
|
||||||
imagePath: _capturedImage.value!.path,
|
|
||||||
faceId: 'live-face-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.95,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
).withLiveness(
|
|
||||||
isLive: true,
|
|
||||||
confidence: 0.92,
|
|
||||||
message: 'Liveness check passed successfully',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compare faces between two images
|
|
||||||
Future<FaceComparisonResult> compareFaces(
|
|
||||||
XFile source,
|
|
||||||
XFile target, {
|
|
||||||
bool skipVerification = false,
|
|
||||||
}) async {
|
|
||||||
if (skipVerification) {
|
|
||||||
// Return dummy successful result for development
|
|
||||||
final sourceFace = FaceModel(
|
|
||||||
imagePath: source.path,
|
|
||||||
faceId: 'source-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.95,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
);
|
|
||||||
|
|
||||||
final targetFace = FaceModel(
|
|
||||||
imagePath: target.path,
|
|
||||||
faceId: 'target-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.95,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
);
|
|
||||||
|
|
||||||
return FaceComparisonResult(
|
|
||||||
sourceFace: sourceFace,
|
|
||||||
targetFace: targetFace,
|
|
||||||
isMatch: true,
|
|
||||||
confidence: 0.91,
|
|
||||||
message: 'Face matching successful (development mode)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In real implementation, this would call a backend service
|
|
||||||
// For now, simulate a match with random confidence
|
|
||||||
final confidence = 0.85 + (DateTime.now().millisecond % 10) / 100;
|
|
||||||
final isMatch = confidence > 0.85;
|
|
||||||
|
|
||||||
final sourceFace = FaceModel(
|
|
||||||
imagePath: source.path,
|
|
||||||
faceId: 'source-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.9,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
);
|
|
||||||
|
|
||||||
final targetFace = FaceModel(
|
|
||||||
imagePath: target.path,
|
|
||||||
faceId: 'target-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.9,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
);
|
|
||||||
|
|
||||||
return FaceComparisonResult(
|
|
||||||
sourceFace: sourceFace,
|
|
||||||
targetFace: targetFace,
|
|
||||||
isMatch: isMatch,
|
|
||||||
confidence: confidence,
|
|
||||||
message:
|
|
||||||
isMatch
|
|
||||||
? 'Face matching successful with ${(confidence * 100).toStringAsFixed(1)}% confidence'
|
|
||||||
: 'Face matching failed. The faces do not appear to be the same person.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,8 +4,11 @@ import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||||
|
import 'package:sigap/src/utils/helpers/error_handler.dart';
|
||||||
|
import 'package:sigap/src/utils/helpers/error_utils.dart';
|
||||||
|
|
||||||
class SelfieVerificationController extends GetxController {
|
class SelfieVerificationController extends GetxController {
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
@ -83,6 +86,11 @@ class SelfieVerificationController extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process the image captured during liveness detection - public for debugging
|
||||||
|
Future<void> processCapturedLivenessImage() async {
|
||||||
|
return _processCapturedLivenessImage();
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Validate the selfie data for form submission
|
/// Validate the selfie data for form submission
|
||||||
|
@ -118,137 +126,20 @@ class SelfieVerificationController extends GetxController {
|
||||||
_resetVerificationData();
|
_resetVerificationData();
|
||||||
|
|
||||||
// Navigate to liveness detection page
|
// Navigate to liveness detection page
|
||||||
Get.toNamed('/liveness-detection');
|
final result = await Get.toNamed(AppRoutes.livenessDetection);
|
||||||
|
|
||||||
// Processing will continue when liveness detection is complete, handled by _processCapturedLivenessImage()
|
// If user cancelled or closed the screen without completing
|
||||||
|
if (result == null) {
|
||||||
|
_setLoading(isPerformingLivenessCheck: false);
|
||||||
|
}
|
||||||
|
// Processing will continue when liveness detection is complete,
|
||||||
|
// handled by _processCapturedLivenessImage() via the status listener
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_handleError('Failed to start liveness detection', e);
|
_handleError('Failed to start liveness detection', e);
|
||||||
_setLoading(isPerformingLivenessCheck: false);
|
_setLoading(isPerformingLivenessCheck: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Take or pick selfie image manually (fallback)
|
|
||||||
Future<void> pickSelfieImage(ImageSource source) async {
|
|
||||||
try {
|
|
||||||
_setLoading(isUploadingSelfie: true);
|
|
||||||
_resetVerificationData();
|
|
||||||
|
|
||||||
final XFile? image = await _pickImage(source);
|
|
||||||
if (image == null) return;
|
|
||||||
|
|
||||||
if (!await _isFileSizeValid(image)) {
|
|
||||||
selfieError.value =
|
|
||||||
'Image size exceeds 4MB limit. Please take a lower resolution photo.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selfieImage.value = image;
|
|
||||||
await validateSelfieImage();
|
|
||||||
} catch (e) {
|
|
||||||
_handleError('Failed to capture selfie', e);
|
|
||||||
} finally {
|
|
||||||
_setLoading(isUploadingSelfie: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manual validation (for images taken without liveness check)
|
|
||||||
Future<void> validateSelfieImage() async {
|
|
||||||
clearErrors();
|
|
||||||
|
|
||||||
if (selfieImage.value == null) {
|
|
||||||
selfieError.value = 'Please take a selfie first';
|
|
||||||
isSelfieValid.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_facialVerificationService.skipFaceVerification) {
|
|
||||||
await _handleDevelopmentModeValidation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_setLoading(isVerifyingFace: true);
|
|
||||||
|
|
||||||
// Detect faces using EdgeFunction via FacialVerificationService
|
|
||||||
final bool faceDetected = await _facialVerificationService
|
|
||||||
.detectFaceInImage(selfieImage.value!);
|
|
||||||
|
|
||||||
if (faceDetected) {
|
|
||||||
// Create a face model - but mark as not live verified since it was taken manually
|
|
||||||
final faces = await _facialVerificationService.detectFaces(
|
|
||||||
selfieImage.value!,
|
|
||||||
);
|
|
||||||
if (faces.isNotEmpty) {
|
|
||||||
selfieFace.value = faces.first.withLiveness(
|
|
||||||
isLive: false,
|
|
||||||
confidence: 0.0,
|
|
||||||
message: 'Face detected, but liveness not verified',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selfieFace.value = FaceModel(
|
|
||||||
imagePath: selfieImage.value!.path,
|
|
||||||
faceId: 'manual-face-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
confidence: 0.7,
|
|
||||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
|
||||||
).withLiveness(
|
|
||||||
isLive: false,
|
|
||||||
confidence: 0.0,
|
|
||||||
message: 'Basic face detection passed, but liveness not verified',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
selfieImageFaceId.value = selfieFace.value.faceId;
|
|
||||||
isSelfieValid.value = true;
|
|
||||||
selfieValidationMessage.value =
|
|
||||||
'Face detected, but liveness not verified. For better security, use liveness detection.';
|
|
||||||
|
|
||||||
// Compare with ID card even though no liveness check
|
|
||||||
await compareWithIDCardPhoto();
|
|
||||||
} else {
|
|
||||||
isSelfieValid.value = false;
|
|
||||||
selfieValidationMessage.value =
|
|
||||||
'No face detected in the image. Please try again with a clearer photo.';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_handleError('Validation failed', e);
|
|
||||||
} finally {
|
|
||||||
_setLoading(isVerifyingFace: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compare selfie with ID card photo
|
|
||||||
Future<void> compareWithIDCardPhoto() async {
|
|
||||||
final idCardController = Get.find<IdCardVerificationController>();
|
|
||||||
|
|
||||||
if (selfieImage.value == null ||
|
|
||||||
idCardController.idCardImage.value == null) {
|
|
||||||
print('Cannot compare faces: Missing images');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_setLoading(isComparingWithIDCard: true);
|
|
||||||
|
|
||||||
if (_facialVerificationService.skipFaceVerification) {
|
|
||||||
await _handleDevelopmentModeComparison(idCardController);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare faces using EdgeFunction via FacialVerificationService
|
|
||||||
final comparisonResult = await _facialVerificationService.compareFaces(
|
|
||||||
idCardController.idCardImage.value!,
|
|
||||||
selfieImage.value!,
|
|
||||||
);
|
|
||||||
|
|
||||||
_updateComparisonResult(comparisonResult);
|
|
||||||
} catch (e) {
|
|
||||||
print('Face comparison error: $e');
|
|
||||||
selfieValidationMessage.value = 'Face comparison error: $e';
|
|
||||||
} finally {
|
|
||||||
_setLoading(isComparingWithIDCard: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear Selfie Image and reset all verification data
|
/// Clear Selfie Image and reset all verification data
|
||||||
void clearSelfieImage() {
|
void clearSelfieImage() {
|
||||||
selfieImage.value = null;
|
selfieImage.value = null;
|
||||||
|
@ -257,12 +148,12 @@ class SelfieVerificationController extends GetxController {
|
||||||
|
|
||||||
/// Confirm the selfie image after validation
|
/// Confirm the selfie image after validation
|
||||||
void confirmSelfieImage() {
|
void confirmSelfieImage() {
|
||||||
if (isSelfieValid.value) {
|
if (isSelfieValid.value && isMatchWithIDCard.value) {
|
||||||
hasConfirmedSelfie.value = true;
|
hasConfirmedSelfie.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manually trigger face match verification with ID card
|
/// Manual trigger for comparing with ID card
|
||||||
Future<void> verifyFaceMatchWithIDCard() async {
|
Future<void> verifyFaceMatchWithIDCard() async {
|
||||||
if (selfieImage.value == null) {
|
if (selfieImage.value == null) {
|
||||||
selfieError.value = 'Please take a selfie first';
|
selfieError.value = 'Please take a selfie first';
|
||||||
|
@ -343,7 +234,7 @@ class SelfieVerificationController extends GetxController {
|
||||||
/// Handle errors in a consistent way
|
/// Handle errors in a consistent way
|
||||||
void _handleError(String baseMessage, dynamic error) {
|
void _handleError(String baseMessage, dynamic error) {
|
||||||
print('$baseMessage: $error');
|
print('$baseMessage: $error');
|
||||||
selfieError.value = '$baseMessage: $error';
|
selfieError.value = ErrorHandler.getUIErrorMessage(error);
|
||||||
isSelfieValid.value = false;
|
isSelfieValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -363,6 +254,80 @@ class SelfieVerificationController extends GetxController {
|
||||||
this.isComparingWithIDCard.value = isComparingWithIDCard;
|
this.isComparingWithIDCard.value = isComparingWithIDCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compare selfie with ID card photo
|
||||||
|
Future<void> compareWithIDCardPhoto() async {
|
||||||
|
final idCardController = Get.find<IdCardVerificationController>();
|
||||||
|
|
||||||
|
if (selfieImage.value == null ||
|
||||||
|
idCardController.idCardImage.value == null) {
|
||||||
|
print('Cannot compare faces: Missing images');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_setLoading(isComparingWithIDCard: true);
|
||||||
|
|
||||||
|
if (_facialVerificationService.skipFaceVerification) {
|
||||||
|
await _handleDevelopmentModeComparison(idCardController);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the existing face models if available to avoid redundant detection
|
||||||
|
FaceModel? sourceFace =
|
||||||
|
idCardController.idCardFace.value.hasValidFace
|
||||||
|
? idCardController.idCardFace.value
|
||||||
|
: null;
|
||||||
|
FaceModel? targetFace =
|
||||||
|
selfieFace.value.hasValidFace ? selfieFace.value : null;
|
||||||
|
|
||||||
|
// Compare faces using EdgeFunction via FacialVerificationService
|
||||||
|
final comparisonResult = await _facialVerificationService.compareFaces(
|
||||||
|
idCardController.idCardImage.value!,
|
||||||
|
selfieImage.value!,
|
||||||
|
sourceModel: sourceFace,
|
||||||
|
targetModel: targetFace,
|
||||||
|
);
|
||||||
|
|
||||||
|
_updateComparisonResult(comparisonResult);
|
||||||
|
} on EdgeFunctionException catch (e) {
|
||||||
|
// Handle specific errors with user-friendly messages
|
||||||
|
ErrorHandler.logError('Face comparison', e);
|
||||||
|
|
||||||
|
faceComparisonResult.value = FaceComparisonResult.error(
|
||||||
|
FaceModel.empty(),
|
||||||
|
FaceModel.empty(),
|
||||||
|
e.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
isMatchWithIDCard.value = false;
|
||||||
|
matchConfidence.value = 0.0;
|
||||||
|
selfieValidationMessage.value = e.message;
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Face comparison', e);
|
||||||
|
selfieValidationMessage.value = ErrorHandler.getUIErrorMessage(e);
|
||||||
|
isMatchWithIDCard.value = false;
|
||||||
|
matchConfidence.value = 0.0;
|
||||||
|
} finally {
|
||||||
|
_setLoading(isComparingWithIDCard: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Also clear loading states when closing liveness detection
|
||||||
|
Future<void> cancelLivenessDetection() async {
|
||||||
|
_setLoading(isPerformingLivenessCheck: false, isVerifyingFace: false);
|
||||||
|
selfieValidationMessage.value = 'Liveness check was cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Di SelfieVerificationController
|
||||||
|
void resetVerificationState() {
|
||||||
|
isLivenessCheckPassed.value = false;
|
||||||
|
faceComparisonResult.value = null;
|
||||||
|
matchConfidence.value = 0.0;
|
||||||
|
selfieError.value = '';
|
||||||
|
hasConfirmedSelfie.value = false;
|
||||||
|
// Reset other relevant states
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle development mode dummy validation
|
/// Handle development mode dummy validation
|
||||||
Future<void> _handleDevelopmentModeValidation() async {
|
Future<void> _handleDevelopmentModeValidation() async {
|
||||||
isSelfieValid.value = true;
|
isSelfieValid.value = true;
|
||||||
|
|
|
@ -119,6 +119,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
hasConfirmed: controller.hasConfirmedIdCard.value,
|
hasConfirmed: controller.hasConfirmedIdCard.value,
|
||||||
onConfirm: controller.confirmIdCardImage,
|
onConfirm: controller.confirmIdCardImage,
|
||||||
onTryAnother: controller.clearIdCardImage,
|
onTryAnother: controller.clearIdCardImage,
|
||||||
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,13 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer/officer_info_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/personal_info_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/personal_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer/officer_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer/unit_info_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer/unit_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||||
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
||||||
|
@ -34,8 +33,7 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor:
|
backgroundColor: dark ? TColors.darkContainer : TColors.lightContainer,
|
||||||
dark ? Theme.of(context).scaffoldBackgroundColor : TColors.light,
|
|
||||||
appBar: _buildAppBar(context, dark),
|
appBar: _buildAppBar(context, dark),
|
||||||
body: Obx(() {
|
body: Obx(() {
|
||||||
// Show loading state while controller initializes
|
// Show loading state while controller initializes
|
||||||
|
@ -171,7 +169,7 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
case 1:
|
case 1:
|
||||||
return const IdCardVerificationStep();
|
return const IdCardVerificationStep();
|
||||||
case 2:
|
case 2:
|
||||||
return const SelfieVerificationStep();
|
return const LivenessDetectionPage();
|
||||||
case 3:
|
case 3:
|
||||||
return isOfficer
|
return isOfficer
|
||||||
? const OfficerInfoStep()
|
? const OfficerInfoStep()
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
||||||
|
@ -9,13 +8,25 @@ import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
||||||
|
|
||||||
|
// Enum untuk tracking status verifikasi
|
||||||
|
enum VerificationStatus {
|
||||||
|
initial,
|
||||||
|
preparingCamera,
|
||||||
|
detectingFace,
|
||||||
|
performingLiveness,
|
||||||
|
livenessCompleted,
|
||||||
|
comparingWithID,
|
||||||
|
verificationComplete,
|
||||||
|
verificationFailed,
|
||||||
|
}
|
||||||
|
|
||||||
class SelfieVerificationStep extends StatelessWidget {
|
class SelfieVerificationStep extends StatelessWidget {
|
||||||
const SelfieVerificationStep({super.key});
|
const SelfieVerificationStep({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Initialize form key
|
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
final controller = Get.find<SelfieVerificationController>();
|
final controller = Get.find<SelfieVerificationController>();
|
||||||
final mainController = Get.find<FormRegistrationController>();
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
@ -27,11 +38,29 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(context),
|
_buildHeader(),
|
||||||
|
|
||||||
// Development mode indicator
|
// Development mode indicator
|
||||||
if (facialVerificationService.skipFaceVerification)
|
_buildDevelopmentModeIndicator(facialVerificationService),
|
||||||
Container(
|
|
||||||
|
// Main verification flow
|
||||||
|
_buildVerificationFlow(controller),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections / 2),
|
||||||
|
|
||||||
|
// Tips container
|
||||||
|
_buildSelfieTips(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDevelopmentModeIndicator(FacialVerificationService service) {
|
||||||
|
if (!service.skipFaceVerification) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
BuildContext context = Get.context!;
|
||||||
|
|
||||||
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
margin: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
||||||
padding: const EdgeInsets.all(TSizes.sm),
|
padding: const EdgeInsets.all(TSizes.sm),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
@ -41,7 +70,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.code, color: Colors.amber, size: TSizes.iconSm),
|
const Icon(Icons.code, color: Colors.amber, size: TSizes.iconSm),
|
||||||
const SizedBox(width: TSizes.xs),
|
const SizedBox(width: TSizes.xs),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
@ -53,251 +82,503 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Liveness Detection Button
|
Widget _buildVerificationFlow(SelfieVerificationController controller) {
|
||||||
Padding(
|
return Obx(() {
|
||||||
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
// Get current verification status
|
||||||
child: Obx(
|
final status = _getVerificationStatus(controller);
|
||||||
() => ElevatedButton.icon(
|
|
||||||
onPressed:
|
BuildContext context = Get.context!;
|
||||||
controller.isPerformingLivenessCheck.value
|
|
||||||
? null
|
return Column(
|
||||||
: controller.performLivenessDetection,
|
children: [
|
||||||
icon:
|
// Step indicator
|
||||||
controller.isPerformingLivenessCheck.value
|
_buildStepIndicator(status, context),
|
||||||
? SizedBox(
|
|
||||||
width: 20,
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
// Main content based on status
|
||||||
strokeWidth: 2,
|
_buildMainContent(status, controller, context),
|
||||||
color: Colors.white,
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Status messages
|
||||||
|
_buildStatusMessage(status, controller, context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
VerificationStatus _getVerificationStatus(
|
||||||
|
SelfieVerificationController controller,
|
||||||
|
) {
|
||||||
|
// Determine current status based on controller state
|
||||||
|
if (controller.selfieImage.value == null) {
|
||||||
|
if (controller.isPerformingLivenessCheck.value) {
|
||||||
|
return VerificationStatus.performingLiveness;
|
||||||
|
}
|
||||||
|
return VerificationStatus.initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.isVerifyingFace.value) {
|
||||||
|
return VerificationStatus.detectingFace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.isComparingWithIDCard.value) {
|
||||||
|
return VerificationStatus.comparingWithID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.faceComparisonResult.value != null) {
|
||||||
|
return controller.isMatchWithIDCard.value
|
||||||
|
? VerificationStatus.verificationComplete
|
||||||
|
: VerificationStatus.verificationFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.isLivenessCheckPassed.value) {
|
||||||
|
return VerificationStatus.livenessCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationStatus.initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepIndicator(VerificationStatus status, BuildContext context) {
|
||||||
|
final steps = [
|
||||||
|
{'icon': Icons.camera_alt, 'label': 'Liveness'},
|
||||||
|
{'icon': Icons.face, 'label': 'Detection'},
|
||||||
|
{'icon': Icons.compare, 'label': 'Compare'},
|
||||||
|
{'icon': Icons.verified, 'label': 'Complete'},
|
||||||
|
];
|
||||||
|
|
||||||
|
final isDark = THelperFunctions.isDarkMode(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.xs,
|
||||||
|
vertical: TSizes.md,
|
||||||
),
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? Colors.grey.shade900 : Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.grey.shade700 : Colors.grey.shade200,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children:
|
||||||
|
steps.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final step = entry.value;
|
||||||
|
final isActive = _isStepActive(index, status);
|
||||||
|
final isCompleted = _isStepCompleted(index, status);
|
||||||
|
final isLast = index == steps.length - 1;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Step circle and label
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getStepColor(
|
||||||
|
isCompleted,
|
||||||
|
isActive,
|
||||||
|
isDark,
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border:
|
||||||
|
isActive && !isCompleted
|
||||||
|
? Border.all(
|
||||||
|
color: TColors.primary,
|
||||||
|
width: 2,
|
||||||
)
|
)
|
||||||
: Icon(Icons.security),
|
: null,
|
||||||
label: Text(
|
|
||||||
controller.isPerformingLivenessCheck.value
|
|
||||||
? 'Processing...'
|
|
||||||
: 'Perform Liveness Detection',
|
|
||||||
),
|
),
|
||||||
|
child: Icon(
|
||||||
|
isCompleted
|
||||||
|
? Icons.check
|
||||||
|
: step['icon'] as IconData,
|
||||||
|
color: _getIconColor(
|
||||||
|
isCompleted,
|
||||||
|
isActive,
|
||||||
|
isDark,
|
||||||
|
),
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
step['label'] as String,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(
|
||||||
|
color: _getTextColor(
|
||||||
|
isCompleted,
|
||||||
|
isActive,
|
||||||
|
isDark,
|
||||||
|
),
|
||||||
|
fontWeight:
|
||||||
|
isActive
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStepColor(bool isCompleted, bool isActive, bool isDark) {
|
||||||
|
if (isCompleted) {
|
||||||
|
return TColors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return isDark
|
||||||
|
? TColors.primary.withOpacity(0.2)
|
||||||
|
: TColors.primary.withOpacity(0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDark ? Colors.grey.shade700 : Colors.grey.shade200;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getIconColor(bool isCompleted, bool isActive, bool isDark) {
|
||||||
|
if (isCompleted) {
|
||||||
|
return Colors.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return TColors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDark ? TColors.grey : Colors.grey.shade500;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getTextColor(bool isCompleted, bool isActive, bool isDark) {
|
||||||
|
if (isCompleted || isActive) {
|
||||||
|
return TColors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDark ? TColors.grey : Colors.grey.shade600;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isStepActive(int stepIndex, VerificationStatus status) {
|
||||||
|
switch (stepIndex) {
|
||||||
|
case 0:
|
||||||
|
return status == VerificationStatus.performingLiveness;
|
||||||
|
case 1:
|
||||||
|
return status == VerificationStatus.detectingFace;
|
||||||
|
case 2:
|
||||||
|
return status == VerificationStatus.comparingWithID;
|
||||||
|
case 3:
|
||||||
|
return status == VerificationStatus.verificationComplete;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isStepCompleted(int stepIndex, VerificationStatus status) {
|
||||||
|
switch (stepIndex) {
|
||||||
|
case 0:
|
||||||
|
return status.index > VerificationStatus.performingLiveness.index;
|
||||||
|
case 1:
|
||||||
|
return status.index > VerificationStatus.detectingFace.index;
|
||||||
|
case 2:
|
||||||
|
return status.index > VerificationStatus.comparingWithID.index;
|
||||||
|
case 3:
|
||||||
|
return status == VerificationStatus.verificationComplete;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainContent(
|
||||||
|
VerificationStatus status,
|
||||||
|
SelfieVerificationController controller,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case VerificationStatus.initial:
|
||||||
|
return _buildInitialState(controller, context);
|
||||||
|
|
||||||
|
case VerificationStatus.performingLiveness:
|
||||||
|
return _buildLivenessState(controller);
|
||||||
|
|
||||||
|
case VerificationStatus.detectingFace:
|
||||||
|
case VerificationStatus.livenessCompleted:
|
||||||
|
case VerificationStatus.comparingWithID:
|
||||||
|
case VerificationStatus.verificationComplete:
|
||||||
|
case VerificationStatus.verificationFailed:
|
||||||
|
return _buildImageDisplay(controller);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInitialState(
|
||||||
|
SelfieVerificationController controller,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
final isDark = THelperFunctions.isDarkMode(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? Colors.grey.shade900 : Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.grey.shade800 : Colors.grey.shade300,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.face_retouching_natural,
|
||||||
|
size: 60,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Text(
|
||||||
|
'Ready for Face Verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Tap the button below to start',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: controller.performLivenessDetection,
|
||||||
|
icon: const Icon(Icons.security),
|
||||||
|
label: const Text('Start Face Verification'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: TColors.primary,
|
backgroundColor: TColors.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: Size(double.infinity, 45),
|
minimumSize: const Size(double.infinity, 45),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Selfie Upload Widget (alternative manual method)
|
Widget _buildLivenessState(SelfieVerificationController controller) {
|
||||||
Obx(
|
final isDark = THelperFunctions.isDarkMode(Get.context!);
|
||||||
() =>
|
return Container(
|
||||||
controller.selfieImage.value == null
|
width: double.infinity,
|
||||||
? Container(
|
height: 200,
|
||||||
margin: const EdgeInsets.only(
|
|
||||||
bottom: TSizes.spaceBtwItems,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(TSizes.md),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.withOpacity(0.1),
|
color: isDark ? Colors.grey.shade900 : Colors.grey.shade50,
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
TSizes.borderRadiusMd,
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.grey.shade800 : Colors.grey.shade300,
|
||||||
|
width: 2,
|
||||||
),
|
),
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: TColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
Text(
|
Text(
|
||||||
"Or take a selfie manually",
|
'Performing Liveness Check...',
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: TColors.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
OutlinedButton.icon(
|
Text(
|
||||||
onPressed: () => _captureSelfie(controller),
|
'Please follow the on-screen instructions',
|
||||||
icon: Icon(Icons.camera_alt),
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||||
label: Text('Take Manual Selfie'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
minimumSize: Size(double.infinity, 45),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
: ImageUploader(
|
}
|
||||||
|
|
||||||
|
Widget _buildImageDisplay(SelfieVerificationController controller) {
|
||||||
|
if (controller.selfieImage.value == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return ImageUploader(
|
||||||
image: controller.selfieImage.value,
|
image: controller.selfieImage.value,
|
||||||
title: 'Selfie Verification',
|
title: 'Selfie Verification',
|
||||||
subtitle:
|
subtitle: _getImageSubtitle(controller),
|
||||||
controller.isLivenessCheckPassed.value
|
|
||||||
? 'Liveness check passed!'
|
|
||||||
: 'Your selfie photo',
|
|
||||||
errorMessage: controller.selfieError.value,
|
errorMessage: controller.selfieError.value,
|
||||||
isUploading: controller.isUploadingSelfie.value,
|
isUploading: controller.isUploadingSelfie.value,
|
||||||
isVerifying: controller.isVerifyingFace.value,
|
isVerifying:
|
||||||
|
controller.isVerifyingFace.value ||
|
||||||
|
controller.isComparingWithIDCard.value,
|
||||||
isConfirmed: controller.hasConfirmedSelfie.value,
|
isConfirmed: controller.hasConfirmedSelfie.value,
|
||||||
onTapToSelect: () => _captureSelfie(controller),
|
onTapToSelect: () {}, // Disabled
|
||||||
onClear: controller.clearSelfieImage,
|
onClear:
|
||||||
onValidate: controller.validateSelfieImage,
|
controller.hasConfirmedSelfie.value
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
controller.clearSelfieImage();
|
||||||
|
// Reset all states when clearing
|
||||||
|
controller.resetVerificationState();
|
||||||
|
},
|
||||||
|
onValidate: null,
|
||||||
placeholderIcon: Icons.face,
|
placeholderIcon: Icons.face,
|
||||||
isSuccess: controller.isLivenessCheckPassed.value,
|
isSuccess: controller.isMatchWithIDCard.value,
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
|
|
||||||
// Verification Status for Selfie
|
String _getImageSubtitle(SelfieVerificationController controller) {
|
||||||
Obx(
|
if (controller.isVerifyingFace.value) {
|
||||||
() =>
|
return 'Analyzing your selfie...';
|
||||||
controller.isVerifyingFace.value &&
|
}
|
||||||
!controller.isUploadingSelfie.value
|
if (controller.isComparingWithIDCard.value) {
|
||||||
? const Padding(
|
return 'Comparing with ID card...';
|
||||||
padding: EdgeInsets.symmetric(
|
}
|
||||||
vertical: TSizes.spaceBtwItems,
|
if (controller.isMatchWithIDCard.value) {
|
||||||
),
|
return 'Verification successful!';
|
||||||
child: Center(
|
}
|
||||||
child: Column(
|
if (controller.faceComparisonResult.value != null &&
|
||||||
children: [
|
!controller.isMatchWithIDCard.value) {
|
||||||
CircularProgressIndicator(),
|
return 'Verification failed - please try again';
|
||||||
SizedBox(height: TSizes.sm),
|
}
|
||||||
Text('Validating your selfie...'),
|
if (controller.isLivenessCheckPassed.value) {
|
||||||
],
|
return 'Liveness check passed!';
|
||||||
),
|
}
|
||||||
),
|
return 'Your selfie photo';
|
||||||
)
|
}
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Verification Message for Selfie
|
Widget _buildStatusMessage(
|
||||||
Obx(
|
VerificationStatus status,
|
||||||
() =>
|
SelfieVerificationController controller,
|
||||||
controller.selfieValidationMessage.value.isNotEmpty
|
BuildContext context,
|
||||||
? Padding(
|
) {
|
||||||
padding: const EdgeInsets.symmetric(
|
switch (status) {
|
||||||
vertical: TSizes.spaceBtwItems,
|
case VerificationStatus.initial:
|
||||||
),
|
case VerificationStatus.performingLiveness:
|
||||||
child: ValidationMessageCard(
|
return const SizedBox.shrink();
|
||||||
message: controller.selfieValidationMessage.value,
|
|
||||||
isValid: controller.isSelfieValid.value,
|
case VerificationStatus.detectingFace:
|
||||||
|
return ValidationMessageCard(
|
||||||
|
message: 'Analyzing your selfie image for face detection...',
|
||||||
|
isValid: false,
|
||||||
|
isLoading: true,
|
||||||
|
title: 'Detecting Face',
|
||||||
|
icon: Icons.face_retouching_natural,
|
||||||
|
customColor: Colors.blue,
|
||||||
|
);
|
||||||
|
|
||||||
|
case VerificationStatus.comparingWithID:
|
||||||
|
return ValidationMessageCard(
|
||||||
|
message: 'Comparing your selfie with your ID card photo...',
|
||||||
|
isValid: false,
|
||||||
|
isLoading: true,
|
||||||
|
title: 'Face Matching',
|
||||||
|
icon: Icons.compare,
|
||||||
|
customColor: Colors.blue,
|
||||||
|
);
|
||||||
|
|
||||||
|
case VerificationStatus.livenessCompleted:
|
||||||
|
return ValidationMessageCard(
|
||||||
|
message: 'Liveness check passed! Proceeding with ID comparison...',
|
||||||
|
isValid: true,
|
||||||
|
title: 'Liveness Check Passed',
|
||||||
|
icon: Icons.security_update_good,
|
||||||
|
);
|
||||||
|
|
||||||
|
case VerificationStatus.verificationComplete:
|
||||||
|
return ValidationMessageCard(
|
||||||
|
message:
|
||||||
|
'Your selfie matches your ID card photo with ${(controller.matchConfidence.value * 100).toStringAsFixed(1)}% confidence.',
|
||||||
|
isValid: true,
|
||||||
hasConfirmed: controller.hasConfirmedSelfie.value,
|
hasConfirmed: controller.hasConfirmedSelfie.value,
|
||||||
|
title: 'Face Match Successful',
|
||||||
|
icon: Icons.verified_user,
|
||||||
onConfirm: controller.confirmSelfieImage,
|
onConfirm: controller.confirmSelfieImage,
|
||||||
onTryAnother: controller.clearSelfieImage,
|
onTryAnother: () {
|
||||||
),
|
controller.clearSelfieImage();
|
||||||
)
|
controller.resetVerificationState();
|
||||||
: const SizedBox.shrink(),
|
},
|
||||||
),
|
);
|
||||||
|
|
||||||
// Face match with ID card indicator
|
case VerificationStatus.verificationFailed:
|
||||||
Obx(() {
|
return ValidationMessageCard(
|
||||||
if (controller.selfieImage.value != null &&
|
message:
|
||||||
controller.isSelfieValid.value) {
|
'Your selfie doesn\'t appear to match your ID card photo. Please try taking another selfie.',
|
||||||
final isMatch = controller.isMatchWithIDCard.value;
|
isValid: false,
|
||||||
final isComparing = controller.isComparingWithIDCard.value;
|
title: 'Face Match Failed',
|
||||||
|
icon: Icons.face_retouching_off,
|
||||||
// Define colors based on match status
|
customColor: TColors.warning,
|
||||||
final Color baseColor = isMatch ? Colors.green : TColors.warning;
|
customAction: TextButton.icon(
|
||||||
final IconData statusIcon =
|
onPressed: () {
|
||||||
isMatch ? Icons.check_circle : Icons.face;
|
controller.clearSelfieImage();
|
||||||
|
controller.resetVerificationState();
|
||||||
// Message based on status
|
controller.performLivenessDetection();
|
||||||
final String message =
|
},
|
||||||
isMatch
|
|
||||||
? 'Your selfie matches with your ID card photo (${(controller.matchConfidence.value * 100).toStringAsFixed(1)}% confidence)'
|
|
||||||
: isComparing
|
|
||||||
? 'Comparing your selfie with your ID card photo...'
|
|
||||||
: 'Your selfie doesn\'t match with your ID card photo.';
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: TSizes.sm),
|
|
||||||
padding: const EdgeInsets.all(TSizes.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: baseColor.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
|
||||||
border: Border.all(color: baseColor.withOpacity(0.3)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(statusIcon, color: baseColor, size: TSizes.iconMd),
|
|
||||||
const SizedBox(width: TSizes.sm),
|
|
||||||
Text(
|
|
||||||
'Face ID Match',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: baseColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: TSizes.sm),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: TSizes.fontSizeSm,
|
|
||||||
color: baseColor.withOpacity(0.8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Show retry button if needed
|
|
||||||
if (!isComparing && !isMatch) ...[
|
|
||||||
const SizedBox(height: TSizes.sm),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: controller.verifyFaceMatchWithIDCard,
|
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.refresh,
|
Icons.refresh,
|
||||||
color: baseColor,
|
color: TColors.warning,
|
||||||
size: TSizes.iconSm,
|
size: TSizes.iconSm,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text('Try Again', style: TextStyle(color: TColors.warning)),
|
||||||
'Try Face Matching Again',
|
|
||||||
style: TextStyle(color: baseColor),
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: TSizes.md,
|
horizontal: TSizes.md,
|
||||||
vertical: TSizes.xs,
|
vertical: TSizes.xs,
|
||||||
),
|
),
|
||||||
backgroundColor: baseColor.withOpacity(0.1),
|
backgroundColor: TColors.warning.withOpacity(0.1),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
TSizes.borderRadiusSm,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
default:
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}),
|
}
|
||||||
|
|
||||||
// Error Messages
|
|
||||||
Obx(
|
|
||||||
() =>
|
|
||||||
controller.selfieError.value.isNotEmpty
|
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
|
||||||
child: Text(
|
|
||||||
controller.selfieError.value,
|
|
||||||
style: TextStyle(color: TColors.error),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: TSizes.spaceBtwSections / 2),
|
|
||||||
|
|
||||||
// Tips for taking a good selfie
|
|
||||||
_buildSelfieTips(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
Widget _buildHeader() {
|
||||||
|
BuildContext context = Get.context!;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -305,9 +586,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
'Selfie Verification',
|
'Selfie Verification',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.headlineSmall?.copyWith(
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
Text(
|
Text(
|
||||||
|
@ -319,9 +598,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
'We need to verify that it\'s really you by performing a liveness check',
|
'We need to verify that it\'s really you by performing a liveness check',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.copyWith(
|
).textTheme.bodySmall?.copyWith(fontStyle: FontStyle.italic),
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.spaceBtwItems),
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
],
|
],
|
||||||
|
@ -330,14 +607,16 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildSelfieTips() {
|
Widget _buildSelfieTips() {
|
||||||
return TipsContainer(
|
return TipsContainer(
|
||||||
title: 'Tips for Liveness Detection:',
|
title: 'Tips for Better Face Detection:',
|
||||||
tips: [
|
tips: [
|
||||||
'Find a well-lit area with even lighting',
|
'Find a well-lit area with even lighting',
|
||||||
'Remove glasses and face coverings',
|
'Remove glasses and face coverings if possible',
|
||||||
'Look directly at the camera',
|
'Look directly at the camera',
|
||||||
'Follow the on-screen instructions',
|
'Keep a neutral expression initially',
|
||||||
'Rotate your head slowly when prompted',
|
'Follow all on-screen instructions carefully',
|
||||||
'Keep your face within the frame'
|
'Keep your face centered within the frame',
|
||||||
|
'Ensure your entire face is visible',
|
||||||
|
'Avoid shadows on your face',
|
||||||
],
|
],
|
||||||
backgroundColor: TColors.primary.withOpacity(0.1),
|
backgroundColor: TColors.primary.withOpacity(0.1),
|
||||||
textColor: TColors.primary,
|
textColor: TColors.primary,
|
||||||
|
@ -345,8 +624,4 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
borderColor: TColors.primary.withOpacity(0.3),
|
borderColor: TColors.primary.withOpacity(0.3),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _captureSelfie(SelfieVerificationController controller) {
|
|
||||||
controller.pickSelfieImage(ImageSource.camera);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,7 +85,7 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
|
|
||||||
SliverAppBar _buildSliverAppBar(
|
SliverAppBar _buildSliverAppBar(
|
||||||
SignupWithRoleController controller,
|
SignupWithRoleController controller,
|
||||||
BuildContext context
|
BuildContext context,
|
||||||
) {
|
) {
|
||||||
bool isOfficer = controller.roleType.value == RoleType.officer;
|
bool isOfficer = controller.roleType.value == RoleType.officer;
|
||||||
final isDark = THelperFunctions.isDarkMode(context);
|
final isDark = THelperFunctions.isDarkMode(context);
|
||||||
|
@ -100,7 +100,7 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Stack(
|
background: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Background gradient
|
// Background gradient with rounded bottom corners
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
@ -112,6 +112,10 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
isDark ? TColors.dark : TColors.primary.withOpacity(0.8),
|
isDark ? TColors.dark : TColors.primary.withOpacity(0.8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(30),
|
||||||
|
bottomRight: Radius.circular(30),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -147,22 +151,67 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Back button in the app bar
|
// Back button with rounded container
|
||||||
leading: Padding(
|
leading: Padding(
|
||||||
padding: EdgeInsets.only(top: topPadding * 0.2),
|
padding: EdgeInsets.only(top: topPadding * 0.2),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => Get.back(),
|
onTap: () => Get.back(),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(left: TSizes.sm),
|
margin: const EdgeInsets.only(left: TSizes.md),
|
||||||
padding: const EdgeInsets.all(TSizes.xs),
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.2),
|
color: Colors.white.withOpacity(0.2),
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Add rounded action button in top right corner
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: topPadding * 0.2, right: TSizes.md),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.help_outline, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
// Show help information
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(context) => AlertDialog(
|
||||||
|
title: Text('Account Types'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Viewer: Regular user account for general app access',
|
||||||
|
),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Officer: Security personnel account with additional features and permissions',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text('Got it'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,7 +223,7 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? TColors.dark : TColors.white,
|
color: isDark ? TColors.darkContainer : TColors.lightContainer,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.05),
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
@ -182,6 +231,10 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
offset: const Offset(0, 3),
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(30),
|
||||||
|
topRight: Radius.circular(30),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
TSizes.defaultSpace,
|
TSizes.defaultSpace,
|
||||||
|
@ -498,5 +551,3 @@ class SignupWithRoleScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class CustomTextField extends StatelessWidget {
|
class CustomTextField extends StatelessWidget {
|
||||||
|
@ -54,11 +55,7 @@ class CustomTextField extends StatelessWidget {
|
||||||
|
|
||||||
// Determine the effective fill color
|
// Determine the effective fill color
|
||||||
final Color effectiveFillColor =
|
final Color effectiveFillColor =
|
||||||
fillColor ??
|
fillColor ?? (isDark ? TColors.darkContainer : TColors.lightContainer);
|
||||||
(isDark
|
|
||||||
? Theme.of(context).cardColor
|
|
||||||
: Theme.of(context).inputDecorationTheme.fillColor ??
|
|
||||||
Colors.grey[100]!);
|
|
||||||
|
|
||||||
// Get the common input decoration for both cases
|
// Get the common input decoration for both cases
|
||||||
final inputDecoration = _getInputDecoration(
|
final inputDecoration = _getInputDecoration(
|
||||||
|
@ -128,6 +125,7 @@ class CustomTextField extends StatelessWidget {
|
||||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||||
),
|
),
|
||||||
errorText: errorText != null && errorText!.isNotEmpty ? errorText : null,
|
errorText: errorText != null && errorText!.isNotEmpty ? errorText : null,
|
||||||
|
errorStyle: TextStyle(color: TColors.error, fontSize: 12),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: TSizes.md,
|
horizontal: TSizes.md,
|
||||||
vertical: TSizes.md,
|
vertical: TSizes.md,
|
||||||
|
@ -152,14 +150,14 @@ class CustomTextField extends StatelessWidget {
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: TColors.error,
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedErrorBorder: OutlineInputBorder(
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: TColors.error,
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -31,6 +31,7 @@ class OcrResultCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class ValidationMessageCard extends StatelessWidget {
|
class ValidationMessageCard extends StatelessWidget {
|
||||||
|
@ -7,6 +8,11 @@ class ValidationMessageCard extends StatelessWidget {
|
||||||
final bool hasConfirmed;
|
final bool hasConfirmed;
|
||||||
final VoidCallback? onConfirm;
|
final VoidCallback? onConfirm;
|
||||||
final VoidCallback? onTryAnother;
|
final VoidCallback? onTryAnother;
|
||||||
|
final String? title;
|
||||||
|
final IconData? icon;
|
||||||
|
final bool isLoading;
|
||||||
|
final Widget? customAction;
|
||||||
|
final Color? customColor;
|
||||||
|
|
||||||
const ValidationMessageCard({
|
const ValidationMessageCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -15,89 +21,247 @@ class ValidationMessageCard extends StatelessWidget {
|
||||||
this.hasConfirmed = false,
|
this.hasConfirmed = false,
|
||||||
this.onConfirm,
|
this.onConfirm,
|
||||||
this.onTryAnother,
|
this.onTryAnother,
|
||||||
|
this.title,
|
||||||
|
this.icon,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.customAction,
|
||||||
|
this.customColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Determine the appropriate color based on status
|
||||||
|
final Color baseColor =
|
||||||
|
customColor ??
|
||||||
|
(isValid ? Colors.green : (isLoading ? Colors.orange : TColors.error));
|
||||||
|
|
||||||
|
// Determine appropriate icon
|
||||||
|
final IconData statusIcon =
|
||||||
|
icon ??
|
||||||
|
(isValid
|
||||||
|
? Icons.check_circle
|
||||||
|
: (isLoading ? Icons.hourglass_top : Icons.error));
|
||||||
|
|
||||||
|
// Determine appropriate title
|
||||||
|
final String statusTitle =
|
||||||
|
title ??
|
||||||
|
(isValid
|
||||||
|
? (hasConfirmed
|
||||||
|
? 'Verified & Confirmed'
|
||||||
|
: 'Verification Successful')
|
||||||
|
: (isLoading ? 'Processing' : 'Verification Failed'));
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: TSizes.sm),
|
||||||
padding: const EdgeInsets.all(TSizes.md),
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: baseColor.withOpacity(0.1),
|
||||||
isValid
|
|
||||||
? Colors.green.withOpacity(0.1)
|
|
||||||
: Colors.red.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(color: baseColor.withOpacity(0.3)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Header row with icon and title
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
isLoading
|
||||||
padding: const EdgeInsets.only(top: 2),
|
? SizedBox(
|
||||||
child: Icon(
|
width: 20,
|
||||||
isValid ? Icons.check_circle : Icons.error,
|
height: 20,
|
||||||
color: isValid ? Colors.green : Colors.red,
|
child: CircularProgressIndicator(
|
||||||
),
|
strokeWidth: 2,
|
||||||
|
color: baseColor,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: Icon(statusIcon, color: baseColor, size: TSizes.iconMd),
|
||||||
const SizedBox(width: TSizes.sm),
|
const SizedBox(width: TSizes.sm),
|
||||||
|
// Wrap title in Expanded to prevent overflow
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
message,
|
statusTitle,
|
||||||
style: TextStyle(color: isValid ? Colors.green : Colors.red),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: baseColor,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis, // Handle long titles
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
// Message with proper text wrapping
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: baseColor.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
// Enable text wrapping
|
||||||
|
softWrap: true,
|
||||||
|
overflow: TextOverflow.visible,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Show confirm/try another buttons if valid but not confirmed
|
||||||
if (isValid &&
|
if (isValid &&
|
||||||
!hasConfirmed &&
|
!hasConfirmed &&
|
||||||
onConfirm != null &&
|
onConfirm != null &&
|
||||||
onTryAnother != null) ...[
|
onTryAnother != null) ...[
|
||||||
const SizedBox(height: TSizes.md),
|
const SizedBox(height: TSizes.md),
|
||||||
Row(
|
// Make buttons responsive to screen width
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// If width is too narrow, stack buttons vertically
|
||||||
|
if (constraints.maxWidth < 300) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green.withOpacity(0.1),
|
||||||
|
foregroundColor: Colors.green, // Ubah warna teks
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.sm,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
Colors.green, // Ubah border jadi hijau solid
|
||||||
|
width: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Confirm Image'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onTryAnother,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.sm,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: TColors.warning.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
child: const Text(
|
||||||
|
'Try Another',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Otherwise use row layout
|
||||||
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: onConfirm,
|
onPressed: onConfirm,
|
||||||
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green.withOpacity(0.1),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.green, // Warna teks
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.sm,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
Colors
|
||||||
|
.green, // Ubah border jadi hijau solid
|
||||||
|
width: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Confirm',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: Colors.green, // Pastikan warna teks hijau
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Confirm Image'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: TSizes.sm),
|
const SizedBox(width: TSizes.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextButton(
|
child: OutlinedButton(
|
||||||
onPressed: onTryAnother,
|
onPressed: onTryAnother,
|
||||||
child: const Text('Try Another Image'),
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.sm,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: TColors.warning.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Try Another',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: TSizes.fontSizeSm),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (hasConfirmed)
|
|
||||||
const Padding(
|
// Show custom action if provided
|
||||||
padding: EdgeInsets.only(top: TSizes.sm),
|
if (customAction != null) ...[
|
||||||
child: Row(
|
const SizedBox(height: TSizes.sm),
|
||||||
|
customAction!,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Show confirmed status if confirmed
|
||||||
|
if (hasConfirmed) ...[
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.check_circle,
|
Icons.verified_user,
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
size: TSizes.iconSm,
|
size: TSizes.iconSm,
|
||||||
),
|
),
|
||||||
SizedBox(width: TSizes.xs),
|
const SizedBox(width: TSizes.xs),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
'Image confirmed',
|
'Image confirmed',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/image_validator.dart';
|
||||||
|
|
||||||
|
class ImageFormatTester extends StatelessWidget {
|
||||||
|
const ImageFormatTester({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Image Format Tester')),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Test image format validation',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _testImageFromCamera(context),
|
||||||
|
child: const Text('Test Camera Image'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _testImageFromGallery(context),
|
||||||
|
child: const Text('Test Gallery Image'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Image Validation Rules:',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('• Allowed types: JPG, JPEG, PNG'),
|
||||||
|
const Text('• Maximum file size: 4MB'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testImageFromCamera(BuildContext context) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(source: ImageSource.camera);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
_validateAndShowResult(context, image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testImageFromGallery(BuildContext context) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
_validateAndShowResult(context, image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _validateAndShowResult(BuildContext context, XFile image) async {
|
||||||
|
final bool isValidExtension = ImageValidator.isValidImageExtension(
|
||||||
|
image.path,
|
||||||
|
);
|
||||||
|
final bool isValidSize = await ImageValidator.isFileSizeValid(image.path);
|
||||||
|
final mimeType = ImageValidator.getMimeType(image.path).toString();
|
||||||
|
|
||||||
|
Get.dialog(
|
||||||
|
AlertDialog(
|
||||||
|
title: const Text('Image Validation Results'),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Filename: ${image.name}'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Path: ${image.path}'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('MIME Type: $mimeType'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('Valid Extension: '),
|
||||||
|
Icon(
|
||||||
|
isValidExtension ? Icons.check_circle : Icons.cancel,
|
||||||
|
color: isValidExtension ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('Valid Size: '),
|
||||||
|
Icon(
|
||||||
|
isValidSize ? Icons.check_circle : Icons.cancel,
|
||||||
|
color: isValidSize ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('Overall Valid: '),
|
||||||
|
Icon(
|
||||||
|
(isValidExtension && isValidSize)
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.cancel,
|
||||||
|
color:
|
||||||
|
(isValidExtension && isValidSize)
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
import 'dart:developer' as dev;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
||||||
|
|
||||||
|
/// Utility class for debugging the liveness detection and verification process
|
||||||
|
class LivenessDebugUtils {
|
||||||
|
static const String _logName = 'LIVENESS_DEBUG';
|
||||||
|
|
||||||
|
/// Log controller states to console
|
||||||
|
static void logControllerStates() {
|
||||||
|
try {
|
||||||
|
final hasLivenessController = Get.isRegistered<FaceLivenessController>();
|
||||||
|
final hasSelfieController =
|
||||||
|
Get.isRegistered<SelfieVerificationController>();
|
||||||
|
|
||||||
|
dev.log(
|
||||||
|
'Controllers registered: Liveness=$hasLivenessController, Selfie=$hasSelfieController',
|
||||||
|
name: _logName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasLivenessController) {
|
||||||
|
final livenessController = Get.find<FaceLivenessController>();
|
||||||
|
dev.log(
|
||||||
|
'Liveness controller state: '
|
||||||
|
'Status=${livenessController.status.value}, '
|
||||||
|
'Camera initialized=${livenessController.cameraController?.value.isInitialized}, '
|
||||||
|
'Face in frame=${livenessController.isFaceInFrame}, '
|
||||||
|
'Steps=${livenessController.successfulSteps.length}',
|
||||||
|
name: _logName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSelfieController) {
|
||||||
|
final selfieController = Get.find<SelfieVerificationController>();
|
||||||
|
dev.log(
|
||||||
|
'Selfie controller state: '
|
||||||
|
'HasImage=${selfieController.selfieImage.value != null}, '
|
||||||
|
'IsValid=${selfieController.isSelfieValid.value}, '
|
||||||
|
'LivenessCheck=${selfieController.isLivenessCheckPassed.value}, '
|
||||||
|
'IsPerformingCheck=${selfieController.isPerformingLivenessCheck.value}',
|
||||||
|
name: _logName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error logging controller states: $e', name: _logName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a debug dialog with controller states
|
||||||
|
static void showDebugDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(context) => AlertDialog(
|
||||||
|
title: Text('Liveness Debug Info'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: _buildDebugInfo(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text('Close'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
logControllerStates();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Debug info logged to console')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text('Log to Console'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build debug information widgets
|
||||||
|
static List<Widget> _buildDebugInfo() {
|
||||||
|
final List<Widget> info = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if controllers are registered
|
||||||
|
final hasLivenessController = Get.isRegistered<FaceLivenessController>();
|
||||||
|
final hasSelfieController =
|
||||||
|
Get.isRegistered<SelfieVerificationController>();
|
||||||
|
|
||||||
|
info.add(
|
||||||
|
Text('FaceLivenessController: ${hasLivenessController ? "✓" : "✗"}'),
|
||||||
|
);
|
||||||
|
info.add(
|
||||||
|
Text(
|
||||||
|
'SelfieVerificationController: ${hasSelfieController ? "✓" : "✗"}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
info.add(Divider());
|
||||||
|
|
||||||
|
// Add controller state details if available
|
||||||
|
if (hasLivenessController) {
|
||||||
|
final controller = Get.find<FaceLivenessController>();
|
||||||
|
info.add(
|
||||||
|
Text(
|
||||||
|
'Liveness Controller',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
info.add(Text('Status: ${controller.status.value}'));
|
||||||
|
info.add(
|
||||||
|
Text(
|
||||||
|
'Camera initialized: ${controller.cameraController?.value.isInitialized}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
info.add(Text('Detected face: ${controller.isFaceInFrame}'));
|
||||||
|
info.add(Text('Steps completed: ${controller.successfulSteps.length}'));
|
||||||
|
info.add(Text('Is captured: ${controller.isCaptured}'));
|
||||||
|
info.add(Divider());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSelfieController) {
|
||||||
|
final controller = Get.find<SelfieVerificationController>();
|
||||||
|
info.add(
|
||||||
|
Text(
|
||||||
|
'Selfie Controller',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
info.add(Text('Has image: ${controller.selfieImage.value != null}'));
|
||||||
|
info.add(Text('Is valid: ${controller.isSelfieValid.value}'));
|
||||||
|
info.add(
|
||||||
|
Text('Liveness passed: ${controller.isLivenessCheckPassed.value}'),
|
||||||
|
);
|
||||||
|
info.add(
|
||||||
|
Text(
|
||||||
|
'Performing check: ${controller.isPerformingLivenessCheck.value}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
info.add(
|
||||||
|
Text(
|
||||||
|
'Error getting debug info: $e',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force reset the controllers for debugging purposes
|
||||||
|
static void forceClearControllers() {
|
||||||
|
try {
|
||||||
|
if (Get.isRegistered<FaceLivenessController>()) {
|
||||||
|
final controller = Get.find<FaceLivenessController>();
|
||||||
|
controller.resetProcess();
|
||||||
|
dev.log('Reset FaceLivenessController', name: _logName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get.isRegistered<SelfieVerificationController>()) {
|
||||||
|
final controller = Get.find<SelfieVerificationController>();
|
||||||
|
controller.cancelLivenessDetection();
|
||||||
|
controller.clearSelfieImage();
|
||||||
|
dev.log('Reset SelfieVerificationController', name: _logName);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
dev.log('Error resetting controllers: $e', name: _logName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/// Custom exception for edge function errors
|
||||||
|
class EdgeFunctionException implements Exception {
|
||||||
|
final String code;
|
||||||
|
final String message;
|
||||||
|
final dynamic details;
|
||||||
|
|
||||||
|
EdgeFunctionException({
|
||||||
|
required this.code,
|
||||||
|
required this.message,
|
||||||
|
this.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'EdgeFunctionException: $message (code: $code)';
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:sigap/src/utils/helpers/error_utils.dart';
|
||||||
|
|
||||||
|
/// Utility class for handling and formatting API and service errors
|
||||||
|
class ErrorHandler {
|
||||||
|
static final Logger _logger = Logger();
|
||||||
|
|
||||||
|
/// Format error messages for UI display
|
||||||
|
static String getUIErrorMessage(dynamic error) {
|
||||||
|
// Log detailed error for debugging
|
||||||
|
_logger.e('Original error: $error');
|
||||||
|
|
||||||
|
String message = 'An unknown error occurred';
|
||||||
|
|
||||||
|
if (error is EdgeFunctionException) {
|
||||||
|
return error.message; // Already user-friendly
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific error types and messages
|
||||||
|
if (error.toString().contains('server_config_error') ||
|
||||||
|
error.toString().contains('environment variables')) {
|
||||||
|
message =
|
||||||
|
'The service is temporarily unavailable. Please try again later.';
|
||||||
|
} else if (error.toString().contains('network') ||
|
||||||
|
error.toString().contains('SocketException') ||
|
||||||
|
error.toString().contains('connection')) {
|
||||||
|
message =
|
||||||
|
'Network connection issue. Please check your internet connection.';
|
||||||
|
} else if (error.toString().contains('timeout')) {
|
||||||
|
message = 'The operation timed out. Please try again.';
|
||||||
|
} else if (error.toString().contains('decode') ||
|
||||||
|
error.toString().contains('Body can not be decoded') ||
|
||||||
|
error.toString().contains('invalid_request_format')) {
|
||||||
|
message = 'There was a problem with the image format. Please try again.';
|
||||||
|
} else if (error.toString().contains('Camera initialization failed')) {
|
||||||
|
message =
|
||||||
|
'Unable to access camera. Please check your camera permissions.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log error with context information
|
||||||
|
static void logError(
|
||||||
|
String context,
|
||||||
|
dynamic error, [
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
]) {
|
||||||
|
_logger.e(
|
||||||
|
'Error in $context: $error',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
/// Helper class for handling errors consistently across the app
|
||||||
|
class ErrorUtils {
|
||||||
|
static final Logger _logger = Logger();
|
||||||
|
|
||||||
|
/// Formats an error into a user-friendly message while logging technical details
|
||||||
|
static String getUserFriendlyMessage(
|
||||||
|
dynamic error, {
|
||||||
|
String defaultMessage = 'An unexpected error occurred',
|
||||||
|
}) {
|
||||||
|
// Log the actual error for debugging
|
||||||
|
_logger.e('Error: $error');
|
||||||
|
|
||||||
|
// Default friendly message
|
||||||
|
String friendlyMessage = defaultMessage;
|
||||||
|
|
||||||
|
// Format specific error types
|
||||||
|
if (error is EdgeFunctionException) {
|
||||||
|
// Already user-friendly from our custom exception
|
||||||
|
friendlyMessage = error.message;
|
||||||
|
_logger.d('EdgeFunctionException: ${error.code} - ${error.details}');
|
||||||
|
} else if (error.toString().contains('SocketException') ||
|
||||||
|
error.toString().contains('network')) {
|
||||||
|
friendlyMessage =
|
||||||
|
'Network connection issue. Please check your internet connection.';
|
||||||
|
} else if (error.toString().contains('timeout')) {
|
||||||
|
friendlyMessage = 'Request timed out. Please try again later.';
|
||||||
|
} else if (error.toString().contains('server_config_error') ||
|
||||||
|
error.toString().contains('server configuration')) {
|
||||||
|
friendlyMessage =
|
||||||
|
'The service is temporarily unavailable due to maintenance.';
|
||||||
|
} else if (error.toString().contains('permission')) {
|
||||||
|
friendlyMessage =
|
||||||
|
'Missing permission. Please check app permissions in settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return friendlyMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to determine if an error is a server-side configuration issue
|
||||||
|
static bool isServerConfigError(dynamic error) {
|
||||||
|
if (error is EdgeFunctionException) {
|
||||||
|
return error.code == 'server_config_error';
|
||||||
|
}
|
||||||
|
return error.toString().contains('server_config_error') ||
|
||||||
|
error.toString().contains('environment variables') ||
|
||||||
|
error.toString().contains('configuration error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to determine if an error is a network-related issue
|
||||||
|
static bool isNetworkError(dynamic error) {
|
||||||
|
return error.toString().contains('SocketException') ||
|
||||||
|
error.toString().contains('network_error') ||
|
||||||
|
error.toString().contains('connection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom exception for edge function errors
|
||||||
|
class EdgeFunctionException implements Exception {
|
||||||
|
final String code;
|
||||||
|
final String message;
|
||||||
|
final dynamic details;
|
||||||
|
|
||||||
|
EdgeFunctionException({
|
||||||
|
required this.code,
|
||||||
|
required this.message,
|
||||||
|
this.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'EdgeFunctionException: $message (code: $code)';
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
/// Utility class for validating images
|
||||||
|
class ImageValidator {
|
||||||
|
// Allowed image extensions
|
||||||
|
static const List<String> allowedExtensions = ['.jpg', '.jpeg', '.png'];
|
||||||
|
|
||||||
|
// Allowed MIME types
|
||||||
|
static const List<String> allowedMimeTypes = ['image/jpeg', 'image/png'];
|
||||||
|
|
||||||
|
/// Maximum file size in bytes (4MB)
|
||||||
|
static const int maxFileSizeBytes = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Validate if the file is a valid image based on extension
|
||||||
|
static bool isValidImageExtension(String filePath) {
|
||||||
|
final extension = path.extension(filePath).toLowerCase();
|
||||||
|
return allowedExtensions.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the MIME type for a file
|
||||||
|
static DioMediaType getMimeType(String filePath) {
|
||||||
|
final extension = path.extension(filePath).toLowerCase();
|
||||||
|
|
||||||
|
if (extension == '.jpg' || extension == '.jpeg') {
|
||||||
|
return DioMediaType.parse('image/jpeg');
|
||||||
|
} else if (extension == '.png') {
|
||||||
|
return DioMediaType.parse('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mime package for detecting MIME type
|
||||||
|
final mimeType = lookupMimeType(filePath) ?? 'application/octet-stream';
|
||||||
|
return DioMediaType.parse(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a file is within size limits
|
||||||
|
static Future<bool> isFileSizeValid(String filePath) async {
|
||||||
|
try {
|
||||||
|
final file = File(filePath);
|
||||||
|
final size = await file.length();
|
||||||
|
return size <= maxFileSizeBytes;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete validation of an image file
|
||||||
|
static Future<bool> isValidImage(String filePath) async {
|
||||||
|
// Check extension
|
||||||
|
if (!isValidImageExtension(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if (!await isFileSizeValid(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous validation for extension and format
|
||||||
|
static bool isValidImageFile(String filePath) {
|
||||||
|
return isValidImageExtension(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format error for invalid images
|
||||||
|
static String getInvalidImageError(String imageType) {
|
||||||
|
return 'Please upload a valid $imageType image (JPG, JPEG, or PNG under 4MB)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -693,6 +693,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.3+1"
|
version: "0.3.3+1"
|
||||||
|
google_ml_kit:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_ml_kit
|
||||||
|
sha256: a2da12a62353a6cad71534b52ada3af14a5b842e6c9b1014ce4d243652b30f4b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.0"
|
||||||
|
google_mlkit_barcode_scanning:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_barcode_scanning
|
||||||
|
sha256: b38505df2d3fdf7830979d60fee55039c2f442d189b2e06fcb2fe494ba65d0db
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.1"
|
||||||
google_mlkit_commons:
|
google_mlkit_commons:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -701,6 +717,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.0"
|
version: "0.11.0"
|
||||||
|
google_mlkit_digital_ink_recognition:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_digital_ink_recognition
|
||||||
|
sha256: "8d2b89401bdeeba97158377167429dbc5cb339ebbd21e0889dca773f1c79a884"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.1"
|
||||||
|
google_mlkit_entity_extraction:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_entity_extraction
|
||||||
|
sha256: "145bc26422b7e62d50cc4eca1ac394d13ac6a97e4c09b8baf7ff058b64d2f9cc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.1"
|
||||||
google_mlkit_face_detection:
|
google_mlkit_face_detection:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -717,6 +749,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
version: "0.4.1"
|
||||||
|
google_mlkit_image_labeling:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_image_labeling
|
||||||
|
sha256: "2cac5f7a02dcc23cd3357f89bf1a79df793ae3afce5035a896de467ffa0192e8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.1"
|
||||||
|
google_mlkit_language_id:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_language_id
|
||||||
|
sha256: fc57bca69cb1dcd8ef67b929f0315e9a8baa80c03c75f7a1226becd7ad2529ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
google_mlkit_object_detection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_object_detection
|
||||||
|
sha256: "0f740f046d74faf81d9c44cdbe4accf33888ed9f877e30efbfad4578d45ebfcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.0"
|
||||||
|
google_mlkit_pose_detection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_pose_detection
|
||||||
|
sha256: "5ff5fe2a325427c49c02a884a2a888d2d10cbfe414f7ebf2af9777a5155171eb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.0"
|
||||||
|
google_mlkit_selfie_segmentation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_selfie_segmentation
|
||||||
|
sha256: e05fc255265595a0fb11cd6a6a5393f106d6ec4d3a40cbc57ff22894eef235f1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.0"
|
||||||
|
google_mlkit_smart_reply:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_smart_reply
|
||||||
|
sha256: "0c3d737e46f20aa4d4953860ee5757e5250e58f90351f8e2afdeb1d609c7047e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
google_mlkit_text_recognition:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_text_recognition
|
||||||
|
sha256: "96173ad4dd7fd06c660e22ac3f9e9f1798a517fe7e48bee68eeec83853224224"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.0"
|
||||||
|
google_mlkit_translation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_mlkit_translation
|
||||||
|
sha256: "7287444a0abd994087a0b354dee952fcd198e57619ded4bba65496d418c9d84b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
google_sign_in:
|
google_sign_in:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -117,6 +117,7 @@ dependencies:
|
||||||
# --- Machine Learning ---
|
# --- Machine Learning ---
|
||||||
google_mlkit_face_detection: ^0.13.1
|
google_mlkit_face_detection: ^0.13.1
|
||||||
google_mlkit_face_mesh_detection: ^0.4.1
|
google_mlkit_face_mesh_detection: ^0.4.1
|
||||||
|
google_ml_kit: ^0.20.0
|
||||||
|
|
||||||
# --- Localization ---
|
# --- Localization ---
|
||||||
# (add localization dependencies here if needed)
|
# (add localization dependencies here if needed)
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 689 KiB |
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
After Width: | Height: | Size: 487 KiB |
|
@ -81,6 +81,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||||
|
|
||||||
logger.debug(`AWS Region: ${credentials.region} [ID: ${requestId}]`);
|
logger.debug(`AWS Region: ${credentials.region} [ID: ${requestId}]`);
|
||||||
|
|
||||||
|
|
||||||
// Initialize Rekognition client
|
// Initialize Rekognition client
|
||||||
logger.debug(`Initializing Rekognition client [ID: ${requestId}]`);
|
logger.debug(`Initializing Rekognition client [ID: ${requestId}]`);
|
||||||
const rekognitionClient = new RekognitionClient({
|
const rekognitionClient = new RekognitionClient({
|
||||||
|
|
Loading…
Reference in New Issue