feat: Implement registration form and verification steps
- Added FormRegistrationScreen for user profile completion with step navigation. - Created SelfieVerificationStep for capturing and validating user selfies. - Implemented OfficerInfoStep and UnitInfoStep for officer-specific information input. - Introduced PanicButtonController and PanicButtonPage for emergency alert functionality. - Developed CustomElevatedButton and CustomBottomNavigationBar for enhanced UI components.
This commit is contained in:
parent
512b29c54d
commit
5c3faac8c3
|
@ -1,11 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||
import 'package:sigap/app.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'navigation_menu.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
@ -43,3 +48,4 @@ Future<void> main() async {
|
|||
|
||||
runApp(const App());
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter/material.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/search/presentation/pages/search_page.dart';
|
||||
import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart';
|
||||
|
||||
class NavigationMenu extends StatelessWidget {
|
||||
const NavigationMenu({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Using GetX controller to manage navigation state
|
||||
final controller = Get.put(NavigationController());
|
||||
|
||||
return Scaffold(
|
||||
body: Obx(
|
||||
() => IndexedStack(
|
||||
index: controller.selectedIndex.value,
|
||||
children: const [
|
||||
HomePage(),
|
||||
SearchPage(),
|
||||
PanicButtonPage(),
|
||||
HistoryPage(),
|
||||
AccountPage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const CustomBottomNavigationBar(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationController extends GetxController {
|
||||
static NavigationController get instance => Get.find();
|
||||
|
||||
// Observable variable to track the current selected index
|
||||
final Rx<int> selectedIndex = 2.obs; // Start with PanicButtonPage (index 2)
|
||||
|
||||
// Method to change selected index
|
||||
void changeIndex(int index) {
|
||||
selectedIndex.value = index;
|
||||
}
|
||||
}
|
|
@ -1,27 +1,23 @@
|
|||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/cores/services/background_service.dart';
|
||||
import 'package:sigap/src/cores/services/biometric_service.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
import 'package:sigap/src/cores/services/location_service.dart';
|
||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||
|
||||
|
||||
class ServiceBindings extends Bindings {
|
||||
@override
|
||||
Future<void> dependencies() async {
|
||||
|
||||
// Initialize background service
|
||||
|
||||
|
||||
final locationService = await BackgroundService.instance
|
||||
.compute<LocationService, void>((message) => LocationService(), null);
|
||||
final biometricService = await BackgroundService.instance
|
||||
.compute<BiometricService, void>((message) => BiometricService(), null);
|
||||
|
||||
|
||||
// Initialize services
|
||||
await Get.putAsync(() => SupabaseService().init(), permanent: true);
|
||||
await Get.putAsync(() => biometricService.init(), permanent: true);
|
||||
await Get.putAsync(() => locationService.init(), permanent: true);
|
||||
Get.putAsync<FacialVerificationService>(
|
||||
() async => FacialVerificationService.instance,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import 'package:get/get.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/forgot-password/forgot_password.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.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/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/signup/signup_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/onboarding/onboarding_screen.dart';
|
||||
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
|
||||
|
@ -33,13 +36,11 @@ class AppPages {
|
|||
|
||||
GetPage(name: AppRoutes.signIn, page: () => const SignInScreen()),
|
||||
|
||||
GetPage(name: AppRoutes.signUp, page: () => const SignUpScreen()),
|
||||
|
||||
GetPage(
|
||||
name: AppRoutes.signupWithRole,
|
||||
page: () => const SignupWithRoleScreen(),
|
||||
),
|
||||
|
||||
|
||||
GetPage(
|
||||
name: AppRoutes.emailVerification,
|
||||
page: () => const EmailVerificationScreen(),
|
||||
|
@ -50,11 +51,16 @@ class AppPages {
|
|||
page: () => const ForgotPasswordScreen(),
|
||||
),
|
||||
|
||||
|
||||
GetPage(
|
||||
name: AppRoutes.locationWarning,
|
||||
page: () => const LocationWarningScreen(),
|
||||
)
|
||||
),
|
||||
|
||||
GetPage(name: AppRoutes.navigationMenu, page: () => const NavigationMenu()),
|
||||
|
||||
GetPage(
|
||||
name: AppRoutes.livenessDetection,
|
||||
page: () => const LivenessDetectionPage(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -80,90 +80,6 @@ class EdgeFunctionService {
|
|||
throw lastException ?? Exception('Face detection failed');
|
||||
}
|
||||
|
||||
/// Performs liveness detection on a selfie using edge functions with retries
|
||||
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||
int retries = 0;
|
||||
Exception? lastException;
|
||||
|
||||
while (retries <= _maxRetries) {
|
||||
try {
|
||||
// First detect the face
|
||||
final faces = await detectFaces(selfieImage);
|
||||
|
||||
if (faces.isEmpty) {
|
||||
return FaceModel.empty().withLiveness(
|
||||
isLive: false,
|
||||
confidence: 0.0,
|
||||
message: 'No face detected in the selfie.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get the primary face
|
||||
FaceModel face = faces.first;
|
||||
|
||||
// Prepare liveness check payload
|
||||
final bytes = await File(selfieImage.path).readAsBytes();
|
||||
final base64Image = base64Encode(bytes);
|
||||
|
||||
final payload = {
|
||||
'image': base64Image,
|
||||
'options': {'performLiveness': true},
|
||||
};
|
||||
|
||||
// Call the Supabase Edge Function
|
||||
final res = await supabase.functions.invoke(
|
||||
_detectFaceFunction,
|
||||
body: payload,
|
||||
);
|
||||
|
||||
// Process the response
|
||||
final data = res.data;
|
||||
|
||||
// Extract liveness information
|
||||
bool isLive = data['isLive'] ?? false;
|
||||
double confidence = 0.0;
|
||||
|
||||
if (data['livenessScore'] != null) {
|
||||
// Normalize to 0-1 range
|
||||
confidence =
|
||||
(data['livenessScore'] is int || data['livenessScore'] > 1.0)
|
||||
? data['livenessScore'] / 100.0
|
||||
: data['livenessScore'];
|
||||
}
|
||||
|
||||
String message =
|
||||
data['message'] ??
|
||||
(isLive
|
||||
? 'Liveness check passed.'
|
||||
: 'Liveness check failed. Please try again.');
|
||||
|
||||
return face.withLiveness(
|
||||
isLive: isLive,
|
||||
confidence: confidence,
|
||||
message: message,
|
||||
);
|
||||
} catch (e) {
|
||||
lastException = e is Exception ? e : Exception(e.toString());
|
||||
retries++;
|
||||
|
||||
// Wait before retrying
|
||||
if (retries <= _maxRetries) {
|
||||
await Future.delayed(Duration(seconds: retries * 2));
|
||||
print('Retrying liveness check (attempt $retries)...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
print('Liveness check failed after $_maxRetries retries: $lastException');
|
||||
return FaceModel.empty().withLiveness(
|
||||
isLive: false,
|
||||
confidence: 0.0,
|
||||
message:
|
||||
'Liveness check failed after multiple attempts. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Compares two face images and returns a comparison result with retries
|
||||
Future<FaceComparisonResult> compareFaces(
|
||||
XFile sourceImage,
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/edge_function_service.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||
|
||||
/// Service that exclusively handles facial verification operations using edge functions
|
||||
class FacialVerificationService {
|
||||
// Singleton instance
|
||||
static final FacialVerificationService instance =
|
||||
FacialVerificationService._();
|
||||
FacialVerificationService._();
|
||||
|
||||
// Service for face operations - only using edge functions
|
||||
final EdgeFunctionService _edgeFunctionService = EdgeFunctionService.instance;
|
||||
|
||||
// Flag to bypass actual verification (for development/testing)
|
||||
bool skipFaceVerification = true; // Set to true to skip verification
|
||||
|
||||
/// Detect faces in an image
|
||||
Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
||||
if (skipFaceVerification) {
|
||||
// Return dummy successful result
|
||||
return [
|
||||
FaceModel(
|
||||
imagePath: imageFile.path,
|
||||
faceId: 'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.99,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return await _edgeFunctionService.detectFaces(imageFile);
|
||||
}
|
||||
|
||||
/// Perform liveness check
|
||||
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||
if (skipFaceVerification) {
|
||||
// Return dummy successful liveness check
|
||||
return FaceModel(
|
||||
imagePath: selfieImage.path,
|
||||
faceId: 'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.99,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
).withLiveness(
|
||||
isLive: true,
|
||||
confidence: 0.95,
|
||||
message: 'Liveness check passed (development mode)',
|
||||
);
|
||||
}
|
||||
|
||||
return await _edgeFunctionService.performLivenessCheck(selfieImage);
|
||||
}
|
||||
|
||||
/// Compare faces
|
||||
Future<FaceComparisonResult> compareFaces(
|
||||
XFile sourceImage,
|
||||
XFile targetImage,
|
||||
) async {
|
||||
if (skipFaceVerification) {
|
||||
// Create dummy source and target faces
|
||||
final sourceFace = FaceModel(
|
||||
imagePath: sourceImage.path,
|
||||
faceId: 'dummy-source-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.98,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
);
|
||||
|
||||
final targetFace = FaceModel(
|
||||
imagePath: targetImage.path,
|
||||
faceId: 'dummy-target-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.97,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
);
|
||||
|
||||
// Return dummy successful comparison
|
||||
return FaceComparisonResult(
|
||||
sourceFace: sourceFace,
|
||||
targetFace: targetFace,
|
||||
isMatch: true,
|
||||
confidence: 0.92,
|
||||
message: 'Faces match (development mode)',
|
||||
);
|
||||
}
|
||||
|
||||
return await _edgeFunctionService.compareFaces(sourceImage, targetImage);
|
||||
}
|
||||
}
|
|
@ -43,7 +43,7 @@ class LocationService extends GetxService {
|
|||
//when going to the background
|
||||
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
||||
notificationText:
|
||||
"Example app will continue to receive your location even when you aren't using it",
|
||||
"Sigap app will continue to receive your location even when you aren't using it",
|
||||
notificationTitle: "Running in Background",
|
||||
enableWakeLock: true,
|
||||
),
|
||||
|
|
|
@ -22,6 +22,33 @@ class FaceModel {
|
|||
final String? gender;
|
||||
final double? genderConfidence;
|
||||
|
||||
/// Facial expressions
|
||||
final bool? isSmiling;
|
||||
final double? smileConfidence;
|
||||
final bool? areEyesOpen;
|
||||
final double? eyesOpenConfidence;
|
||||
final bool? isMouthOpen;
|
||||
final double? mouthOpenConfidence;
|
||||
|
||||
/// Accessories
|
||||
final bool? hasEyeglasses;
|
||||
final bool? hasSunglasses;
|
||||
final bool? hasBeard;
|
||||
final bool? hasMustache;
|
||||
|
||||
/// Emotions (primary emotion)
|
||||
final String? primaryEmotion;
|
||||
final double? emotionConfidence;
|
||||
|
||||
/// Face pose
|
||||
final double? roll;
|
||||
final double? yaw;
|
||||
final double? pitch;
|
||||
|
||||
/// Image quality
|
||||
final double? brightness;
|
||||
final double? sharpness;
|
||||
|
||||
/// Liveness detection data
|
||||
final bool isLive;
|
||||
final double livenessConfidence;
|
||||
|
@ -41,71 +68,226 @@ class FaceModel {
|
|||
this.maxAge,
|
||||
this.gender,
|
||||
this.genderConfidence,
|
||||
this.isSmiling,
|
||||
this.smileConfidence,
|
||||
this.areEyesOpen,
|
||||
this.eyesOpenConfidence,
|
||||
this.isMouthOpen,
|
||||
this.mouthOpenConfidence,
|
||||
this.hasEyeglasses,
|
||||
this.hasSunglasses,
|
||||
this.hasBeard,
|
||||
this.hasMustache,
|
||||
this.primaryEmotion,
|
||||
this.emotionConfidence,
|
||||
this.roll,
|
||||
this.yaw,
|
||||
this.pitch,
|
||||
this.brightness,
|
||||
this.sharpness,
|
||||
this.isLive = false,
|
||||
this.livenessConfidence = 0.0,
|
||||
this.attributes,
|
||||
this.message = '',
|
||||
});
|
||||
|
||||
/// Constructor from edge function response
|
||||
/// Constructor from edge function response based on Amazon Rekognition format
|
||||
factory FaceModel.fromEdgeFunction(
|
||||
XFile image,
|
||||
Map<String, dynamic> response,
|
||||
) {
|
||||
// Check if we're parsing a face from the faceDetails array
|
||||
if (response.containsKey('BoundingBox')) {
|
||||
return _parseRekognitionFace(image, response);
|
||||
}
|
||||
|
||||
// Check if we have success and faceDetails
|
||||
if (response.containsKey('success') &&
|
||||
response['success'] == true &&
|
||||
response.containsKey('faceDetails')) {
|
||||
final faceDetails = response['faceDetails'];
|
||||
if (faceDetails is List && faceDetails.isNotEmpty) {
|
||||
return _parseRekognitionFace(image, faceDetails[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for unknown format
|
||||
return FaceModel(
|
||||
imagePath: image.path,
|
||||
faceId: 'face-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence:
|
||||
response['Confidence'] != null
|
||||
? (response['Confidence'] as num).toDouble() / 100.0
|
||||
: 0.0,
|
||||
boundingBox: {'x': 0.0, 'y': 0.0, 'width': 0.0, 'height': 0.0},
|
||||
message: 'Unknown face format',
|
||||
);
|
||||
}
|
||||
|
||||
// Private helper to parse a Rekognition face detail
|
||||
static FaceModel _parseRekognitionFace(
|
||||
XFile image,
|
||||
Map<String, dynamic> faceData,
|
||||
) {
|
||||
// Extract faceId if available
|
||||
final String faceId = faceData['faceId'] ?? faceData['face_id'] ?? '';
|
||||
|
||||
// Extract confidence
|
||||
final double confidence =
|
||||
(faceData['confidence'] ?? faceData['detection_confidence'] ?? 0.0) /
|
||||
100.0;
|
||||
|
||||
// Extract bounding box if available
|
||||
// Extract bounding box
|
||||
Map<String, double> boundingBox = {
|
||||
'x': 0.0,
|
||||
'y': 0.0,
|
||||
'width': 0.0,
|
||||
'height': 0.0,
|
||||
};
|
||||
if (faceData['boundingBox'] != null || faceData['bounding_box'] != null) {
|
||||
final box = faceData['boundingBox'] ?? faceData['bounding_box'];
|
||||
if (faceData['BoundingBox'] != null) {
|
||||
final box = faceData['BoundingBox'];
|
||||
boundingBox = {
|
||||
'x': (box['left'] ?? box['x'] ?? 0.0).toDouble(),
|
||||
'y': (box['top'] ?? box['y'] ?? 0.0).toDouble(),
|
||||
'width': (box['width'] ?? 0.0).toDouble(),
|
||||
'height': (box['height'] ?? 0.0).toDouble(),
|
||||
'x': (box['Left'] ?? 0.0).toDouble(),
|
||||
'y': (box['Top'] ?? 0.0).toDouble(),
|
||||
'width': (box['Width'] ?? 0.0).toDouble(),
|
||||
'height': (box['Height'] ?? 0.0).toDouble(),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract age information if available
|
||||
|
||||
// Extract age range
|
||||
int? minAge;
|
||||
int? maxAge;
|
||||
if (faceData['age'] != null) {
|
||||
if (faceData['age'] is Map && faceData['age']['range'] != null) {
|
||||
minAge = faceData['age']['range']['low'];
|
||||
maxAge = faceData['age']['range']['high'];
|
||||
} else if (faceData['age'] is num) {
|
||||
// Single age value
|
||||
final age = (faceData['age'] as num).toInt();
|
||||
minAge = age - 5;
|
||||
maxAge = age + 5;
|
||||
}
|
||||
if (faceData['AgeRange'] != null) {
|
||||
minAge =
|
||||
faceData['AgeRange']['Low'] is num
|
||||
? (faceData['AgeRange']['Low'] as num).toInt()
|
||||
: null;
|
||||
maxAge =
|
||||
faceData['AgeRange']['High'] is num
|
||||
? (faceData['AgeRange']['High'] as num).toInt()
|
||||
: null;
|
||||
}
|
||||
|
||||
// Extract gender if available
|
||||
|
||||
// Extract gender
|
||||
String? gender;
|
||||
double? genderConfidence;
|
||||
if (faceData['gender'] != null) {
|
||||
if (faceData['gender'] is Map) {
|
||||
gender = faceData['gender']['value'];
|
||||
genderConfidence = faceData['gender']['confidence'] / 100.0;
|
||||
} else if (faceData['gender'] is String) {
|
||||
gender = faceData['gender'];
|
||||
genderConfidence = 0.9; // Default confidence
|
||||
}
|
||||
if (faceData['Gender'] != null) {
|
||||
gender = faceData['Gender']['Value'];
|
||||
genderConfidence =
|
||||
faceData['Gender']['Confidence'] is num
|
||||
? (faceData['Gender']['Confidence'] as num).toDouble() / 100.0
|
||||
: null;
|
||||
}
|
||||
|
||||
// Create the face model
|
||||
// Extract expressions
|
||||
bool? isSmiling;
|
||||
double? smileConfidence;
|
||||
if (faceData['Smile'] != null) {
|
||||
isSmiling = faceData['Smile']['Value'];
|
||||
smileConfidence =
|
||||
faceData['Smile']['Confidence'] is num
|
||||
? (faceData['Smile']['Confidence'] as num).toDouble() / 100.0
|
||||
: null;
|
||||
}
|
||||
|
||||
bool? eyesOpen;
|
||||
double? eyesOpenConfidence;
|
||||
if (faceData['EyesOpen'] != null) {
|
||||
eyesOpen = faceData['EyesOpen']['Value'];
|
||||
eyesOpenConfidence =
|
||||
faceData['EyesOpen']['Confidence'] is num
|
||||
? (faceData['EyesOpen']['Confidence'] as num).toDouble() / 100.0
|
||||
: null;
|
||||
}
|
||||
|
||||
bool? mouthOpen;
|
||||
double? mouthOpenConfidence;
|
||||
if (faceData['MouthOpen'] != null) {
|
||||
mouthOpen = faceData['MouthOpen']['Value'];
|
||||
mouthOpenConfidence =
|
||||
faceData['MouthOpen']['Confidence'] is num
|
||||
? (faceData['MouthOpen']['Confidence'] as num).toDouble() / 100.0
|
||||
: null;
|
||||
}
|
||||
|
||||
// Extract accessories
|
||||
bool? hasEyeglasses;
|
||||
if (faceData['Eyeglasses'] != null) {
|
||||
hasEyeglasses = faceData['Eyeglasses']['Value'];
|
||||
}
|
||||
|
||||
bool? hasSunglasses;
|
||||
if (faceData['Sunglasses'] != null) {
|
||||
hasSunglasses = faceData['Sunglasses']['Value'];
|
||||
}
|
||||
|
||||
bool? hasBeard;
|
||||
if (faceData['Beard'] != null) {
|
||||
hasBeard = faceData['Beard']['Value'];
|
||||
}
|
||||
|
||||
bool? hasMustache;
|
||||
if (faceData['Mustache'] != null) {
|
||||
hasMustache = faceData['Mustache']['Value'];
|
||||
}
|
||||
|
||||
// Extract emotions
|
||||
String? primaryEmotion;
|
||||
double? emotionConfidence;
|
||||
if (faceData['Emotions'] != null &&
|
||||
faceData['Emotions'] is List &&
|
||||
(faceData['Emotions'] as List).isNotEmpty) {
|
||||
final topEmotion = faceData['Emotions'][0];
|
||||
primaryEmotion = topEmotion['Type'];
|
||||
emotionConfidence =
|
||||
topEmotion['Confidence'] is num
|
||||
? (topEmotion['Confidence'] as num).toDouble() / 100.0
|
||||
: null;
|
||||
}
|
||||
|
||||
// Extract pose
|
||||
double? roll;
|
||||
double? yaw;
|
||||
double? pitch;
|
||||
if (faceData['Pose'] != null) {
|
||||
roll =
|
||||
faceData['Pose']['Roll'] is num
|
||||
? (faceData['Pose']['Roll'] as num).toDouble()
|
||||
: null;
|
||||
yaw =
|
||||
faceData['Pose']['Yaw'] is num
|
||||
? (faceData['Pose']['Yaw'] as num).toDouble()
|
||||
: null;
|
||||
pitch =
|
||||
faceData['Pose']['Pitch'] is num
|
||||
? (faceData['Pose']['Pitch'] as num).toDouble()
|
||||
: null;
|
||||
}
|
||||
|
||||
// Extract quality
|
||||
double? brightness;
|
||||
double? sharpness;
|
||||
if (faceData['Quality'] != null) {
|
||||
brightness =
|
||||
faceData['Quality']['Brightness'] is num
|
||||
? (faceData['Quality']['Brightness'] as num).toDouble()
|
||||
: null;
|
||||
sharpness =
|
||||
faceData['Quality']['Sharpness'] is num
|
||||
? (faceData['Quality']['Sharpness'] as num).toDouble()
|
||||
: null;
|
||||
}
|
||||
|
||||
// Get the confidence score
|
||||
double confidence =
|
||||
faceData['Confidence'] is num
|
||||
? (faceData['Confidence'] as num).toDouble() / 100.0
|
||||
: 0.7;
|
||||
|
||||
// Generate a unique face ID
|
||||
final faceId = 'face-${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
// Create message about face quality
|
||||
String message = 'Face detected successfully';
|
||||
if (eyesOpen == true &&
|
||||
isSmiling == true &&
|
||||
sharpness != null &&
|
||||
sharpness > 80) {
|
||||
message = 'High quality face detected';
|
||||
}
|
||||
|
||||
return FaceModel(
|
||||
imagePath: image.path,
|
||||
faceId: faceId,
|
||||
|
@ -115,7 +297,25 @@ class FaceModel {
|
|||
maxAge: maxAge,
|
||||
gender: gender,
|
||||
genderConfidence: genderConfidence,
|
||||
isSmiling: isSmiling,
|
||||
smileConfidence: smileConfidence,
|
||||
areEyesOpen: eyesOpen,
|
||||
eyesOpenConfidence: eyesOpenConfidence,
|
||||
isMouthOpen: mouthOpen,
|
||||
mouthOpenConfidence: mouthOpenConfidence,
|
||||
hasEyeglasses: hasEyeglasses,
|
||||
hasSunglasses: hasSunglasses,
|
||||
hasBeard: hasBeard,
|
||||
hasMustache: hasMustache,
|
||||
primaryEmotion: primaryEmotion,
|
||||
emotionConfidence: emotionConfidence,
|
||||
roll: roll,
|
||||
yaw: yaw,
|
||||
pitch: pitch,
|
||||
brightness: brightness,
|
||||
sharpness: sharpness,
|
||||
attributes: faceData,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -182,6 +382,23 @@ class FaceModel {
|
|||
'maxAge': maxAge,
|
||||
'gender': gender,
|
||||
'genderConfidence': genderConfidence,
|
||||
'isSmiling': isSmiling,
|
||||
'smileConfidence': smileConfidence,
|
||||
'areEyesOpen': areEyesOpen,
|
||||
'eyesOpenConfidence': eyesOpenConfidence,
|
||||
'isMouthOpen': isMouthOpen,
|
||||
'mouthOpenConfidence': mouthOpenConfidence,
|
||||
'hasEyeglasses': hasEyeglasses,
|
||||
'hasSunglasses': hasSunglasses,
|
||||
'hasBeard': hasBeard,
|
||||
'hasMustache': hasMustache,
|
||||
'primaryEmotion': primaryEmotion,
|
||||
'emotionConfidence': emotionConfidence,
|
||||
'roll': roll,
|
||||
'yaw': yaw,
|
||||
'pitch': pitch,
|
||||
'brightness': brightness,
|
||||
'sharpness': sharpness,
|
||||
'isLive': isLive,
|
||||
'livenessConfidence': livenessConfidence,
|
||||
'message': message,
|
||||
|
@ -203,6 +420,15 @@ class FaceComparisonResult {
|
|||
|
||||
/// Confidence level of the match (0.0-1.0)
|
||||
final double confidence;
|
||||
|
||||
/// Similarity score (0-100)
|
||||
final double similarity;
|
||||
|
||||
/// Threshold used for matching
|
||||
final double similarityThreshold;
|
||||
|
||||
/// Confidence level as text (HIGH, MEDIUM, LOW)
|
||||
final String? confidenceLevel;
|
||||
|
||||
/// Message describing the comparison result
|
||||
final String message;
|
||||
|
@ -213,6 +439,9 @@ class FaceComparisonResult {
|
|||
required this.targetFace,
|
||||
required this.isMatch,
|
||||
required this.confidence,
|
||||
this.similarity = 0.0,
|
||||
this.similarityThreshold = 0.0,
|
||||
this.confidenceLevel,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
|
@ -222,25 +451,52 @@ class FaceComparisonResult {
|
|||
FaceModel targetFace,
|
||||
Map<String, dynamic> response,
|
||||
) {
|
||||
bool isMatch = response['isMatch'] ?? false;
|
||||
double confidence = 0.0;
|
||||
|
||||
if (response['confidence'] != null || response['similarity'] != null) {
|
||||
confidence =
|
||||
((response['confidence'] ?? response['similarity']) ?? 0.0) / 100.0;
|
||||
// Check if the response is valid
|
||||
if (!response.containsKey('success') || response['success'] != true) {
|
||||
return FaceComparisonResult.error(
|
||||
sourceFace,
|
||||
targetFace,
|
||||
'Invalid or failed response',
|
||||
);
|
||||
}
|
||||
|
||||
// Extract match result
|
||||
final bool isMatch = response['matched'] ?? false;
|
||||
|
||||
// Extract similarity and threshold
|
||||
final double similarity = (response['similarity'] ?? 0.0).toDouble();
|
||||
final double similarityThreshold =
|
||||
(response['similarityThreshold'] ?? 0.0).toDouble();
|
||||
|
||||
// Calculate normalized confidence (0.0-1.0)
|
||||
final double confidence = similarity / 100.0;
|
||||
|
||||
// Get confidence level if available
|
||||
final String? confidenceLevel = response['confidence'];
|
||||
|
||||
String message =
|
||||
response['message'] ??
|
||||
(isMatch
|
||||
? 'Faces match with ${(confidence * 100).toStringAsFixed(1)}% confidence'
|
||||
: 'Faces do not match');
|
||||
// Generate appropriate message
|
||||
String message;
|
||||
if (isMatch) {
|
||||
message = 'Faces match with ${similarity.toStringAsFixed(1)}% similarity';
|
||||
if (confidenceLevel != null) {
|
||||
message += ' ($confidenceLevel confidence)';
|
||||
}
|
||||
} else {
|
||||
message =
|
||||
'Faces do not match. Similarity: ${similarity.toStringAsFixed(1)}%';
|
||||
if (similarityThreshold > 0) {
|
||||
message += ' (threshold: ${similarityThreshold.toStringAsFixed(1)}%)';
|
||||
}
|
||||
}
|
||||
|
||||
return FaceComparisonResult(
|
||||
sourceFace: sourceFace,
|
||||
targetFace: targetFace,
|
||||
isMatch: isMatch,
|
||||
confidence: confidence,
|
||||
similarity: similarity,
|
||||
similarityThreshold: similarityThreshold,
|
||||
confidenceLevel: confidenceLevel,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@ import 'package:logger/logger.dart';
|
|||
import 'package:sigap/src/cores/services/biometric_service.dart';
|
||||
import 'package:sigap/src/cores/services/location_service.dart';
|
||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
import 'package:sigap/src/utils/exceptions/exceptions.dart';
|
||||
import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
|
||||
|
@ -192,6 +192,68 @@ class AuthenticationRepository extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// Login with email and password
|
||||
Future<User?> loginWithEmailPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _supabase.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Store user in session
|
||||
final user = response.user;
|
||||
|
||||
// Check for errors
|
||||
if (user == null) {
|
||||
throw 'User is null after sign in';
|
||||
}
|
||||
|
||||
// Return user
|
||||
return user;
|
||||
} on AuthException catch (e) {
|
||||
throw TExceptions(e.message).message;
|
||||
} on FormatException catch (_) {
|
||||
throw const TFormatException().message;
|
||||
} on PlatformException catch (e) {
|
||||
throw TPlatformException(e.code).message;
|
||||
} catch (e) {
|
||||
throw e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Sign in with Google
|
||||
// Future<User?> signInWithGoogle() async {
|
||||
// try {
|
||||
// // Use Supabase auth to sign in with Google
|
||||
// final response = await _supabase.auth.signInWithOAuth(
|
||||
// Provider.google,
|
||||
// redirectTo: kIsWeb ? null : 'io.supabase.sigap://login-callback/',
|
||||
// queryParams: {'access_type': 'offline'},
|
||||
// );
|
||||
|
||||
// // Check if sign in was successful
|
||||
// if (!response.isSuccess()) {
|
||||
// throw 'Google sign in failed';
|
||||
// }
|
||||
|
||||
// // Get session
|
||||
// final session = _supabase.auth.currentSession;
|
||||
// if (session == null) {
|
||||
// throw 'No session after Google sign in';
|
||||
// }
|
||||
|
||||
// // Return user
|
||||
// return session.user;
|
||||
// } on AuthException catch (e) {
|
||||
// throw TExceptions(e.message).message;
|
||||
// } catch (e) {
|
||||
// throw e.toString();
|
||||
// }
|
||||
// }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SESSION MANAGEMENT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/email_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/forgot_password_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/signin_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/signup_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/signup_with_role_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart';
|
||||
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart';
|
||||
|
||||
class AuthControllerBindings extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
// Register all feature auth controllers
|
||||
Get.lazyPut(() => SignInController(), fenix: true);
|
||||
Get.lazyPut(() => SignUpController(), fenix: true);
|
||||
Get.lazyPut(() => SignupWithRoleController(), fenix: true);
|
||||
Get.lazyPut(() => FormRegistrationController(), fenix: true);
|
||||
Get.lazyPut(() => EmailVerificationController(), fenix: true);
|
||||
|
|
|
@ -4,17 +4,16 @@ import 'package:get_storage/get_storage.dart';
|
|||
import 'package:logger/logger.dart';
|
||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_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/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/unit_info_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/daily-ops/data/models/index.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/index.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
|
||||
import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
import 'package:sigap/src/utils/constants/num_int.dart';
|
||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||
|
||||
|
@ -55,6 +54,11 @@ class FormRegistrationController extends GetxController {
|
|||
// Loading state
|
||||
final RxBool isLoading = false.obs;
|
||||
|
||||
// Form submission states
|
||||
final RxBool isSubmitting = RxBool(false);
|
||||
final RxString submitMessage = RxString('');
|
||||
final RxBool isSubmitSuccess = RxBool(false);
|
||||
|
||||
// Data to be passed between steps
|
||||
final Rx<dynamic> idCardData = Rx<dynamic>(null);
|
||||
|
||||
|
@ -460,63 +464,12 @@ class FormRegistrationController extends GetxController {
|
|||
idCardVerificationController.hasConfirmedIdCard.value) {
|
||||
// Get the model from the controller
|
||||
idCardData.value = idCardVerificationController.verifiedIdCardModel;
|
||||
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error passing ID card data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Go to next step
|
||||
// void nextStep() async {
|
||||
// final isValid = formKey.currentState?.validate() ?? false;
|
||||
// if (isValid) {
|
||||
// // Validate based on the current step
|
||||
// if (currentStep.value == 0) {
|
||||
// // Personal Info Step
|
||||
// personalInfoController.validate();
|
||||
// if (!personalInfoController.isFormValid.value) return;
|
||||
// } else if (currentStep.value == 1) {
|
||||
// // ID Card Verification Step
|
||||
// final idCardController = Get.find<IdCardVerificationController>();
|
||||
// if (!idCardController.validate()) return;
|
||||
|
||||
// // Pass data to next step if validation succeeded
|
||||
// passIdCardDataToNextStep();
|
||||
// } else if (currentStep.value == 2) {
|
||||
// // Selfie Verification Step
|
||||
// final selfieController = Get.find<SelfieVerificationController>();
|
||||
// if (!selfieController.validate()) return;
|
||||
// } else if (currentStep.value == 3) {
|
||||
// if (selectedRole.value!.isOfficer) {
|
||||
// // Officer Info Step
|
||||
// final officerInfoController = Get.find<OfficerInfoController>();
|
||||
// if (!officerInfoController.validate()) return;
|
||||
// } else {
|
||||
// // Identity Verification Step
|
||||
// final identityVerificationController =
|
||||
// Get.find<IdentityVerificationController>();
|
||||
// if (!identityVerificationController.validate()) return;
|
||||
// }
|
||||
// } else if (currentStep.value == 4 && selectedRole.value!.isOfficer) {
|
||||
// // Unit Info Step
|
||||
// final unitInfoController = Get.find<UnitInfoController>();
|
||||
// if (!unitInfoController.validate()) return;
|
||||
// }
|
||||
|
||||
// if (currentStep.value == totalSteps - 1) {
|
||||
// // This is the last step, submit the form
|
||||
// _submitForm();
|
||||
// } else {
|
||||
// // Move to the next step
|
||||
// if (currentStep.value < totalSteps - 1) {
|
||||
// currentStep.value++;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Go to next step - fixed implementation
|
||||
void nextStep() {
|
||||
// Special case for step 1 (ID Card step)
|
||||
|
@ -568,6 +521,7 @@ class FormRegistrationController extends GetxController {
|
|||
|
||||
// Proceed to next step
|
||||
if (currentStep.value < totalSteps - 1) {
|
||||
// Fixed missing parenthesis
|
||||
currentStep.value++;
|
||||
} else {
|
||||
submitForm();
|
||||
|
@ -625,60 +579,6 @@ class FormRegistrationController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// Submit the complete form
|
||||
Future<void> submitForm() async {
|
||||
// Validate all steps
|
||||
bool isFormValid = true;
|
||||
for (int i = 0; i < totalSteps; i++) {
|
||||
currentStep.value = i;
|
||||
if (!validateCurrentStep()) {
|
||||
isFormValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFormValid) return;
|
||||
|
||||
try {
|
||||
isLoading.value = false;
|
||||
|
||||
// Prepare UserMetadataModel with all collected data
|
||||
collectAllFormData();
|
||||
|
||||
// Complete the user profile using AuthenticationRepository
|
||||
await AuthenticationRepository.instance.completeUserProfile(
|
||||
userMetadata.value,
|
||||
);
|
||||
|
||||
// Show success
|
||||
Get.toNamed(
|
||||
AppRoutes.stateScreen,
|
||||
arguments: {
|
||||
'type': 'success',
|
||||
'title': 'Registration Completed',
|
||||
'message': 'Your profile has been successfully created.',
|
||||
'buttonText': 'Continue',
|
||||
'onButtonPressed':
|
||||
() => AuthenticationRepository.instance.screenRedirect(),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
Get.toNamed(
|
||||
AppRoutes.stateScreen,
|
||||
arguments: {
|
||||
'type': 'error',
|
||||
'title': 'Registration Failed',
|
||||
'message':
|
||||
'There was an error completing your profile: ${e.toString()}',
|
||||
'buttonText': 'Try Again',
|
||||
'onButtonPressed': () => Get.back(),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add this method to collect all form data
|
||||
void collectAllFormData() {
|
||||
final isOfficerRole = selectedRole.value?.isOfficer ?? false;
|
||||
|
@ -771,4 +671,45 @@ class FormRegistrationController extends GetxController {
|
|||
extractedName = idCardController.ktaModel.value?.name ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Submit the entire form
|
||||
Future<bool> submitForm() async {
|
||||
if (!validateCurrentStep()) {
|
||||
print('Form validation failed for step ${currentStep.value}');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentStep.value < totalSteps - 1) {
|
||||
// Move to next step if we're not at the last step
|
||||
nextStep();
|
||||
return false;
|
||||
}
|
||||
|
||||
// We're at the last step, submit the form
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
submitMessage.value = 'Submitting your registration...';
|
||||
|
||||
// Save all registration data using the identity verification controller
|
||||
final identityController = Get.find<IdentityVerificationController>();
|
||||
final result = await identityController.saveRegistrationData();
|
||||
|
||||
if (result) {
|
||||
isSubmitSuccess.value = true;
|
||||
submitMessage.value = 'Registration completed successfully!';
|
||||
} else {
|
||||
isSubmitSuccess.value = false;
|
||||
submitMessage.value = 'Registration failed. Please try again.';
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
print('Error submitting form: $e');
|
||||
isSubmitSuccess.value = false;
|
||||
submitMessage.value = 'Error during registration: $e';
|
||||
return false;
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||
|
||||
class SignInController extends GetxController {
|
||||
static SignInController get instance => Get.find();
|
||||
|
||||
final _logger = Logger();
|
||||
final _authRepo = Get.find<AuthenticationRepository>();
|
||||
|
||||
// Form controllers
|
||||
final email = TextEditingController();
|
||||
final password = TextEditingController();
|
||||
|
||||
// Form error messages
|
||||
final RxString emailError = RxString('');
|
||||
final RxString passwordError = RxString('');
|
||||
|
||||
// States
|
||||
final RxBool isLoading = RxBool(false);
|
||||
final RxBool isPasswordVisible = RxBool(false);
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
email.dispose();
|
||||
password.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
void togglePasswordVisibility() {
|
||||
isPasswordVisible.value = !isPasswordVisible.value;
|
||||
}
|
||||
|
||||
// Navigate to forgot password screen
|
||||
void goToForgotPassword() {
|
||||
Get.toNamed(AppRoutes.forgotPassword);
|
||||
}
|
||||
|
||||
// Navigate to sign up screen
|
||||
void goToSignUp() {
|
||||
Get.toNamed(AppRoutes.signUp);
|
||||
}
|
||||
|
||||
// Clear error messages
|
||||
void clearErrors() {
|
||||
emailError.value = '';
|
||||
passwordError.value = '';
|
||||
}
|
||||
|
||||
// Sign in with email and password
|
||||
Future<void> signIn(GlobalKey<FormState> formKey) async {
|
||||
// Clear previous errors
|
||||
clearErrors();
|
||||
|
||||
// Validate form
|
||||
final isValid = formKey.currentState?.validate() ?? false;
|
||||
if (!isValid) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// Attempt to sign in
|
||||
final signInResult = await _authRepo.loginWithEmailPassword(
|
||||
email: email.text.trim(),
|
||||
password: password.text.trim(),
|
||||
);
|
||||
|
||||
// Handle result
|
||||
_logger.i('Sign in successful: $signInResult');
|
||||
|
||||
// Redirect based on user's profile status
|
||||
_authRepo.screenRedirect();
|
||||
|
||||
} catch (e) {
|
||||
isLoading.value = false;
|
||||
|
||||
// Handle specific errors
|
||||
if (e.toString().contains('user-not-found')) {
|
||||
emailError.value = 'No user found with this email';
|
||||
} else if (e.toString().contains('wrong-password')) {
|
||||
passwordError.value = 'Incorrect password';
|
||||
} else if (e.toString().contains('invalid-email')) {
|
||||
emailError.value = 'Invalid email format';
|
||||
} else {
|
||||
// Show general error
|
||||
TLoaders.errorSnackBar(title: 'Sign In Failed', message: e.toString());
|
||||
}
|
||||
|
||||
_logger.e('Sign in error: $e');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sign in with Google
|
||||
Future<void> googleSignIn() async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// Attempt to sign in with Google
|
||||
await _authRepo.signInWithGoogle();
|
||||
|
||||
// Redirect based on user's profile status
|
||||
_authRepo.screenRedirect();
|
||||
|
||||
} catch (e) {
|
||||
isLoading.value = false;
|
||||
|
||||
// Show error
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Google Sign In Failed',
|
||||
message: e.toString(),
|
||||
);
|
||||
|
||||
_logger.e('Google sign in error: $e');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,183 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||
|
||||
/// Controller for handling face recognition operations using Edge Functions
|
||||
class FaceRecognitionController extends GetxController {
|
||||
// Singleton instance
|
||||
static FaceRecognitionController get instance => Get.find();
|
||||
|
||||
// Service for face operations - use FacialVerificationService instead of EdgeFunctionService
|
||||
final FacialVerificationService _faceService =
|
||||
FacialVerificationService.instance;
|
||||
|
||||
// Maximum allowed file size in bytes (4MB)
|
||||
final int maxFileSizeBytes = 4 * 1024 * 1024;
|
||||
|
||||
// Operation status
|
||||
final RxBool isProcessing = RxBool(false);
|
||||
final RxString processingMessage = RxString('');
|
||||
final RxString errorMessage = RxString('');
|
||||
|
||||
// Face detection results
|
||||
final RxList<FaceModel> detectedFaces = RxList<FaceModel>([]);
|
||||
final Rx<FaceModel> primaryFace = Rx<FaceModel>(FaceModel.empty());
|
||||
|
||||
// Face comparison results
|
||||
final Rx<FaceComparisonResult?> comparisonResult = Rx<FaceComparisonResult?>(
|
||||
null,
|
||||
);
|
||||
|
||||
/// Validates an image file for processing
|
||||
Future<bool> validateImageFile(XFile imageFile) async {
|
||||
try {
|
||||
// Check file size
|
||||
final File file = File(imageFile.path);
|
||||
final int fileSize = await file.length();
|
||||
|
||||
if (fileSize > maxFileSizeBytes) {
|
||||
errorMessage.value =
|
||||
'Image size exceeds 4MB limit. Please choose a smaller image or lower resolution.';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorMessage.value = 'Error validating image file: $e';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears any previous results and errors
|
||||
void clearResults() {
|
||||
detectedFaces.clear();
|
||||
primaryFace.value = FaceModel.empty();
|
||||
comparisonResult.value = null;
|
||||
errorMessage.value = '';
|
||||
processingMessage.value = '';
|
||||
}
|
||||
|
||||
/// Detects faces in the provided image
|
||||
Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
||||
try {
|
||||
clearResults();
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = 'Detecting faces...';
|
||||
|
||||
// Validate the image file
|
||||
if (!await validateImageFile(imageFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Detect faces using FacialVerificationService
|
||||
final faces = await _faceService.detectFaces(imageFile);
|
||||
|
||||
if (faces.isEmpty) {
|
||||
errorMessage.value = 'No faces detected in the image.';
|
||||
} else {
|
||||
detectedFaces.assignAll(faces);
|
||||
primaryFace.value = faces.first;
|
||||
processingMessage.value = 'Detected ${faces.length} face(s)';
|
||||
}
|
||||
|
||||
return faces;
|
||||
} catch (e) {
|
||||
errorMessage.value = 'Face detection failed: $e';
|
||||
return [];
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs liveness check on a selfie image
|
||||
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||
try {
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = 'Performing liveness check...';
|
||||
|
||||
// Validate the image file
|
||||
if (!await validateImageFile(selfieImage)) {
|
||||
return FaceModel.empty().withLiveness(
|
||||
isLive: false,
|
||||
confidence: 0.0,
|
||||
message: errorMessage.value,
|
||||
);
|
||||
}
|
||||
|
||||
// Perform liveness check using FacialVerificationService
|
||||
final faceWithLiveness = await _faceService.performLivenessCheck(
|
||||
selfieImage,
|
||||
);
|
||||
|
||||
// Update the primary face
|
||||
if (faceWithLiveness.faceId.isNotEmpty) {
|
||||
primaryFace.value = faceWithLiveness;
|
||||
if (faceWithLiveness.isLive) {
|
||||
processingMessage.value = 'Liveness check passed';
|
||||
} else {
|
||||
errorMessage.value = faceWithLiveness.message;
|
||||
}
|
||||
} else {
|
||||
errorMessage.value = 'No face detected for liveness check';
|
||||
}
|
||||
|
||||
return faceWithLiveness;
|
||||
} catch (e) {
|
||||
errorMessage.value = 'Liveness check failed: $e';
|
||||
return FaceModel.empty().withLiveness(
|
||||
isLive: false,
|
||||
confidence: 0.0,
|
||||
message: 'Error: ${e.toString()}',
|
||||
);
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares two face images
|
||||
Future<FaceComparisonResult> compareFaces(
|
||||
XFile sourceImage,
|
||||
XFile targetImage,
|
||||
) async {
|
||||
try {
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = 'Comparing faces...';
|
||||
|
||||
// Validate both images
|
||||
if (!await validateImageFile(sourceImage) ||
|
||||
!await validateImageFile(targetImage)) {
|
||||
return FaceComparisonResult.error(
|
||||
FaceModel.empty(),
|
||||
FaceModel.empty(),
|
||||
errorMessage.value,
|
||||
);
|
||||
}
|
||||
|
||||
// Compare faces using FacialVerificationService
|
||||
final result = await _faceService.compareFaces(sourceImage, targetImage);
|
||||
|
||||
// Store the result
|
||||
comparisonResult.value = result;
|
||||
|
||||
if (result.isMatch) {
|
||||
processingMessage.value = 'Face verification successful';
|
||||
} else {
|
||||
errorMessage.value = result.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
errorMessage.value = 'Face comparison failed: $e';
|
||||
return FaceComparisonResult.error(
|
||||
FaceModel.empty(),
|
||||
FaceModel.empty(),
|
||||
'Error: ${e.toString()}',
|
||||
);
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +1,21 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
||||
|
||||
|
||||
class IdCardVerificationController extends GetxController {
|
||||
// Singleton instance
|
||||
static IdCardVerificationController get instance => Get.find();
|
||||
|
||||
// Services
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
// Using FacialVerificationService instead of direct EdgeFunction
|
||||
final FacialVerificationService _faceService =
|
||||
FacialVerificationService.instance;
|
||||
|
||||
|
||||
final bool isOfficer;
|
||||
|
||||
// Maximum allowed file size in bytes (4MB)
|
||||
|
@ -25,6 +23,11 @@ class IdCardVerificationController extends GetxController {
|
|||
|
||||
IdCardVerificationController({required this.isOfficer});
|
||||
|
||||
// Local storage keys
|
||||
static const String _kOcrResultsKey = 'ocr_results';
|
||||
static const String _kOcrModelKey = 'ocr_model';
|
||||
static const String _kIdCardTypeKey = 'id_card_type';
|
||||
|
||||
// ID Card variables
|
||||
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
|
||||
final RxString idCardError = RxString('');
|
||||
|
@ -46,7 +49,7 @@ class IdCardVerificationController extends GetxController {
|
|||
// Add model variables for the extracted data
|
||||
final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null);
|
||||
final Rx<KtaModel?> ktaModel = Rx<KtaModel?>(null);
|
||||
|
||||
|
||||
// Use FaceModel to store face details from ID card
|
||||
final Rx<FaceModel> idCardFace = Rx<FaceModel>(FaceModel.empty());
|
||||
|
||||
|
@ -54,6 +57,51 @@ class IdCardVerificationController extends GetxController {
|
|||
final RxString idCardFaceId = RxString('');
|
||||
final RxBool hasFaceDetected = RxBool(false);
|
||||
|
||||
// Save OCR results to local storage
|
||||
Future<void> _saveOcrResultsToLocalStorage(
|
||||
Map<String, String> results,
|
||||
) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String jsonData = jsonEncode(results);
|
||||
await prefs.setString(_kOcrResultsKey, jsonData);
|
||||
await prefs.setString(_kIdCardTypeKey, isOfficer ? 'KTA' : 'KTP');
|
||||
|
||||
// Also save the model
|
||||
if (isOfficer && ktaModel.value != null) {
|
||||
await prefs.setString(
|
||||
_kOcrModelKey,
|
||||
jsonEncode(ktaModel.value!.toJson()),
|
||||
);
|
||||
} else if (!isOfficer && ktpModel.value != null) {
|
||||
await prefs.setString(
|
||||
_kOcrModelKey,
|
||||
jsonEncode(ktpModel.value!.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
print('OCR results saved to local storage: ${results.length} items');
|
||||
print('ID Card Type saved: ${isOfficer ? 'KTA' : 'KTP'}');
|
||||
} catch (e) {
|
||||
print('Error saving OCR results to local storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load OCR results from local storage
|
||||
Future<Map<String, String>> loadOcrResultsFromLocalStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? jsonData = prefs.getString(_kOcrResultsKey);
|
||||
if (jsonData != null) {
|
||||
final Map<String, dynamic> decodedData = jsonDecode(jsonData);
|
||||
return decodedData.map((key, value) => MapEntry(key, value.toString()));
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error loading OCR results from local storage: $e');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool validate() {
|
||||
clearErrors();
|
||||
|
||||
|
@ -131,7 +179,7 @@ class IdCardVerificationController extends GetxController {
|
|||
ktpModel.value = null;
|
||||
ktaModel.value = null;
|
||||
|
||||
// Reset face detection data
|
||||
// Initialize face data with empty model (just to maintain compatibility)
|
||||
idCardFace.value = FaceModel.empty();
|
||||
idCardFaceId.value = '';
|
||||
hasFaceDetected.value = false;
|
||||
|
@ -160,6 +208,12 @@ class IdCardVerificationController extends GetxController {
|
|||
extractedInfo.assignAll(result);
|
||||
hasExtractedInfo.value = result.isNotEmpty;
|
||||
|
||||
// Save the OCR results to local storage
|
||||
if (result.isNotEmpty) {
|
||||
print('Saving OCR results to local storage...');
|
||||
await _saveOcrResultsToLocalStorage(result);
|
||||
}
|
||||
|
||||
// Check if the extracted information is valid using our validation methods
|
||||
if (isOfficer) {
|
||||
isImageValid = _ocrService.isKtaValid(result);
|
||||
|
@ -174,44 +228,8 @@ class IdCardVerificationController extends GetxController {
|
|||
ktpModel.value = _ocrService.createKtpModel(result);
|
||||
}
|
||||
|
||||
// Try to detect faces in the ID card image using FacialVerificationService
|
||||
if (isImageValid) {
|
||||
try {
|
||||
// Skip actual face detection in development mode
|
||||
if (_faceService.skipFaceVerification) {
|
||||
// Create dummy face detection result
|
||||
idCardFace.value = FaceModel(
|
||||
imagePath: idCardImage.value!.path,
|
||||
faceId:
|
||||
'dummy-id-card-face-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.95,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
);
|
||||
|
||||
// For backward compatibility
|
||||
idCardFaceId.value = idCardFace.value.faceId;
|
||||
hasFaceDetected.value = true;
|
||||
print(
|
||||
'Dummy face detected in ID card: ${idCardFace.value.faceId}',
|
||||
);
|
||||
} else {
|
||||
// Use FacialVerificationService to detect faces
|
||||
final faces = await _faceService.detectFaces(idCardImage.value!);
|
||||
if (faces.isNotEmpty) {
|
||||
// Store the face model
|
||||
idCardFace.value = faces.first;
|
||||
|
||||
// For backward compatibility
|
||||
idCardFaceId.value = faces.first.faceId;
|
||||
hasFaceDetected.value = idCardFace.value.hasValidFace;
|
||||
print('Face detected in ID card: ${idCardFace.value.faceId}');
|
||||
}
|
||||
}
|
||||
} catch (faceError) {
|
||||
print('Face detection failed: $faceError');
|
||||
// Don't fail validation if face detection fails
|
||||
}
|
||||
|
||||
// Instead of detecting faces here, we just save the image reference for later comparison
|
||||
isIdCardValid.value = true;
|
||||
idCardValidationMessage.value =
|
||||
'$idCardType image looks valid. Please confirm this is your $idCardType.';
|
||||
|
@ -260,12 +278,12 @@ class IdCardVerificationController extends GetxController {
|
|||
|
||||
// Get the ID card image path for face comparison
|
||||
String? get idCardImagePath => idCardImage.value?.path;
|
||||
|
||||
|
||||
// Check if the ID card has a detected face
|
||||
bool get hasDetectedFace => idCardFace.value.hasValidFace;
|
||||
|
||||
// Clear ID Card Image
|
||||
void clearIdCardImage() {
|
||||
void clearIdCardImage() async {
|
||||
idCardImage.value = null;
|
||||
idCardError.value = '';
|
||||
isIdCardValid.value = false;
|
||||
|
@ -278,12 +296,34 @@ class IdCardVerificationController extends GetxController {
|
|||
idCardFace.value = FaceModel.empty();
|
||||
idCardFaceId.value = '';
|
||||
hasFaceDetected.value = false;
|
||||
|
||||
// Also clear local storage
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kOcrResultsKey);
|
||||
await prefs.remove(_kOcrModelKey);
|
||||
await prefs.remove(_kIdCardTypeKey);
|
||||
} catch (e) {
|
||||
print('Error clearing OCR results from local storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm ID Card Image
|
||||
void confirmIdCardImage() {
|
||||
if (isIdCardValid.value) {
|
||||
hasConfirmedIdCard.value = true;
|
||||
|
||||
// Log storage data for debugging
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
print('Storage check on confirmation:');
|
||||
print(
|
||||
'OCR results: ${prefs.getString(_kOcrResultsKey)?.substring(0, 50)}...',
|
||||
);
|
||||
print(
|
||||
'OCR model: ${prefs.getString(_kOcrModelKey)?.substring(0, 50)}...',
|
||||
);
|
||||
print('ID card type: ${prefs.getString(_kIdCardTypeKey)}');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
// Remove AWS rekognition import completely
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/data/bindings/registration_binding.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/data/services/registration_service.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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/selfie_verification_controller.dart';
|
||||
|
||||
class IdentityVerificationController extends GetxController {
|
||||
// Singleton instance
|
||||
|
@ -21,6 +25,11 @@ class IdentityVerificationController extends GetxController {
|
|||
final FacialVerificationService _faceService =
|
||||
FacialVerificationService.instance;
|
||||
|
||||
// Local storage keys (matching those in IdCardVerificationController)
|
||||
static const String _kOcrResultsKey = 'ocr_results';
|
||||
static const String _kOcrModelKey = 'ocr_model';
|
||||
static const String _kIdCardTypeKey = 'id_card_type';
|
||||
|
||||
// Controllers
|
||||
final TextEditingController nikController = TextEditingController();
|
||||
final TextEditingController fullNameController = TextEditingController();
|
||||
|
@ -59,11 +68,22 @@ class IdentityVerificationController extends GetxController {
|
|||
// Flag to prevent infinite loop
|
||||
bool _isApplyingData = false;
|
||||
|
||||
// NIK field readonly status
|
||||
final RxBool isNikReadOnly = RxBool(false);
|
||||
|
||||
// Properties to store extracted ID card data
|
||||
final String? extractedIdCardNumber;
|
||||
final String? extractedName;
|
||||
final RxBool isPreFilledNik = false.obs;
|
||||
|
||||
// Store the loaded OCR data
|
||||
final RxMap<String, dynamic> ocrData = RxMap<String, dynamic>({});
|
||||
|
||||
// Status of data saving
|
||||
final RxBool isSavingData = RxBool(false);
|
||||
final RxBool isDataSaved = RxBool(false);
|
||||
final RxString dataSaveMessage = RxString('');
|
||||
|
||||
IdentityVerificationController({
|
||||
this.extractedIdCardNumber = '',
|
||||
this.extractedName = '',
|
||||
|
@ -76,14 +96,134 @@ class IdentityVerificationController extends GetxController {
|
|||
// Make sure selectedGender has a default value
|
||||
selectedGender.value = selectedGender.value ?? 'Male';
|
||||
|
||||
// Try to apply ID card data after initialization
|
||||
Future.microtask(() => _safeApplyIdCardData());
|
||||
// Load OCR data from local storage with debug info
|
||||
print(
|
||||
'Initializing IdentityVerificationController and loading OCR data...',
|
||||
);
|
||||
loadOcrDataFromLocalStorage();
|
||||
}
|
||||
|
||||
// Safely apply ID card data without risking stack overflow
|
||||
|
||||
// Load OCR data from local storage
|
||||
Future<void> loadOcrDataFromLocalStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Load stored ID card type to verify it matches current flow
|
||||
final String? storedIdCardType = prefs.getString(_kIdCardTypeKey);
|
||||
print(
|
||||
'Stored ID card type: $storedIdCardType, Current isOfficer: $isOfficer',
|
||||
);
|
||||
|
||||
if (storedIdCardType == null ||
|
||||
(isOfficer && storedIdCardType != 'KTA') ||
|
||||
(!isOfficer && storedIdCardType != 'KTP')) {
|
||||
print('No matching ID card data found in storage or type mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load OCR results
|
||||
final String? jsonData = prefs.getString(_kOcrResultsKey);
|
||||
if (jsonData != null) {
|
||||
print('Found OCR data in storage: ${jsonData.length} chars');
|
||||
final Map<String, dynamic> results = jsonDecode(jsonData);
|
||||
ocrData.assignAll(results);
|
||||
print('OCR data loaded: ${results.length} items');
|
||||
|
||||
// Load OCR model
|
||||
final String? modelJson = prefs.getString(_kOcrModelKey);
|
||||
if (modelJson != null) {
|
||||
print('Found OCR model in storage: ${modelJson.length} chars');
|
||||
|
||||
try {
|
||||
if (isOfficer) {
|
||||
final ktaModel = KtaModel.fromJson(jsonDecode(modelJson));
|
||||
print('KTA model loaded successfully');
|
||||
applyKtaDataToForm(ktaModel);
|
||||
} else {
|
||||
final ktpModel = KtpModel.fromJson(jsonDecode(modelJson));
|
||||
print('KTP model loaded successfully - NIK: ${ktpModel.nik}');
|
||||
applyKtpDataToForm(ktpModel);
|
||||
}
|
||||
isNikReadOnly.value = true;
|
||||
print('NIK field set to read-only');
|
||||
} catch (e) {
|
||||
print('Error parsing model JSON: $e');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print('No OCR data found in storage');
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error loading OCR data from local storage: $e');
|
||||
} finally {
|
||||
// If data wasn't loaded from local storage, try from FormRegistrationController
|
||||
if (ocrData.isEmpty) {
|
||||
print('Falling back to FormRegistrationController data');
|
||||
_safeApplyIdCardData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply KTP data to form
|
||||
void applyKtpDataToForm(KtpModel ktpModel) {
|
||||
if (ktpModel.nik.isNotEmpty) {
|
||||
nikController.text = ktpModel.nik;
|
||||
}
|
||||
|
||||
if (ktpModel.name.isNotEmpty) {
|
||||
fullNameController.text = ktpModel.name;
|
||||
}
|
||||
|
||||
if (ktpModel.birthPlace.isNotEmpty) {
|
||||
placeOfBirthController.text = ktpModel.birthPlace;
|
||||
}
|
||||
|
||||
if (ktpModel.birthDate.isNotEmpty) {
|
||||
birthDateController.text = ktpModel.birthDate;
|
||||
}
|
||||
|
||||
if (ktpModel.gender.isNotEmpty) {
|
||||
// Convert gender to the format expected by the dropdown
|
||||
String gender = ktpModel.gender.toLowerCase();
|
||||
if (gender.contains('laki') || gender == 'male') {
|
||||
selectedGender.value = 'Male';
|
||||
} else if (gender.contains('perempuan') || gender == 'female') {
|
||||
selectedGender.value = 'Female';
|
||||
}
|
||||
}
|
||||
|
||||
if (ktpModel.address.isNotEmpty) {
|
||||
addressController.text = ktpModel.address;
|
||||
}
|
||||
|
||||
// Mark as verified since we have validated KTP data
|
||||
isVerified.value = true;
|
||||
verificationMessage.value = 'KTP information loaded successfully';
|
||||
}
|
||||
|
||||
// Apply KTA data to form
|
||||
void applyKtaDataToForm(KtaModel ktaModel) {
|
||||
// For officer, we'd fill in different fields as needed
|
||||
if (ktaModel.name.isNotEmpty) {
|
||||
fullNameController.text = ktaModel.name;
|
||||
}
|
||||
|
||||
// If birthDate is available in extra data
|
||||
if (ktaModel.extraData != null &&
|
||||
ktaModel.extraData!.containsKey('tanggal_lahir') &&
|
||||
ktaModel.extraData!['tanggal_lahir'] != null) {
|
||||
birthDateController.text = ktaModel.extraData!['tanggal_lahir'];
|
||||
}
|
||||
|
||||
// Mark as verified
|
||||
isVerified.value = true;
|
||||
verificationMessage.value = 'KTA information loaded successfully';
|
||||
}
|
||||
|
||||
// Safely apply ID card data without risking stack overflow (fallback method)
|
||||
void _safeApplyIdCardData() {
|
||||
if (_isApplyingData) return; // Guard against recursive calls
|
||||
|
||||
|
||||
try {
|
||||
_isApplyingData = true;
|
||||
|
||||
|
@ -91,70 +231,21 @@ class IdentityVerificationController extends GetxController {
|
|||
if (!Get.isRegistered<FormRegistrationController>()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final formController = Get.find<FormRegistrationController>();
|
||||
if (formController.idCardData.value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final idCardData = formController.idCardData.value;
|
||||
|
||||
if (idCardData != null) {
|
||||
// Fill the form with the extracted data
|
||||
if (!isOfficer && idCardData is KtpModel) {
|
||||
KtpModel ktpModel = idCardData;
|
||||
|
||||
if (ktpModel.nik.isNotEmpty) {
|
||||
nikController.text = ktpModel.nik;
|
||||
}
|
||||
|
||||
if (ktpModel.name.isNotEmpty) {
|
||||
fullNameController.text = ktpModel.name;
|
||||
}
|
||||
|
||||
if (ktpModel.birthPlace.isNotEmpty) {
|
||||
placeOfBirthController.text = ktpModel.birthPlace;
|
||||
}
|
||||
|
||||
if (ktpModel.birthDate.isNotEmpty) {
|
||||
birthDateController.text = ktpModel.birthDate;
|
||||
}
|
||||
|
||||
if (ktpModel.gender.isNotEmpty) {
|
||||
// Convert gender to the format expected by the dropdown
|
||||
String gender = ktpModel.gender.toLowerCase();
|
||||
if (gender.contains('laki') || gender == 'male') {
|
||||
selectedGender.value = 'Male';
|
||||
} else if (gender.contains('perempuan') || gender == 'female') {
|
||||
selectedGender.value = 'Female';
|
||||
}
|
||||
}
|
||||
|
||||
if (ktpModel.address.isNotEmpty) {
|
||||
addressController.text = ktpModel.address;
|
||||
}
|
||||
|
||||
// Mark as verified since we have validated KTP data
|
||||
isVerified.value = true;
|
||||
verificationMessage.value = 'KTP information verified successfully';
|
||||
applyKtpDataToForm(idCardData);
|
||||
isNikReadOnly.value = true;
|
||||
} else if (isOfficer && idCardData is KtaModel) {
|
||||
KtaModel ktaModel = idCardData;
|
||||
|
||||
// For officer, we'd fill in different fields as needed
|
||||
if (ktaModel.name.isNotEmpty) {
|
||||
fullNameController.text = ktaModel.name;
|
||||
}
|
||||
|
||||
// If birthDate is available in extra data
|
||||
if (ktaModel.extraData != null &&
|
||||
ktaModel.extraData!.containsKey('tanggal_lahir') &&
|
||||
ktaModel.extraData!['tanggal_lahir'] != null) {
|
||||
birthDateController.text = ktaModel.extraData!['tanggal_lahir'];
|
||||
}
|
||||
|
||||
// Mark as verified
|
||||
isVerified.value = true;
|
||||
verificationMessage.value = 'KTA information verified successfully';
|
||||
applyKtaDataToForm(idCardData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -195,10 +286,10 @@ class IdentityVerificationController extends GetxController {
|
|||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
if (addressController.text.isEmpty) {
|
||||
addressError.value = 'Address is required';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
// if (addressController.text.isEmpty) {
|
||||
// addressError.value = 'Address is required';
|
||||
// isFormValid.value = false;
|
||||
// }
|
||||
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
@ -416,4 +507,38 @@ class IdentityVerificationController extends GetxController {
|
|||
|
||||
isPreFilledNik.value = true;
|
||||
}
|
||||
|
||||
// Save registration data
|
||||
Future<bool> saveRegistrationData() async {
|
||||
try {
|
||||
isSavingData.value = true;
|
||||
dataSaveMessage.value = 'Saving your registration data...';
|
||||
|
||||
// Ensure the registration service is available();
|
||||
if (!Get.isRegistered<RegistrationService>()) {
|
||||
await Get.putAsync(() async => RegistrationService());
|
||||
} // Get registration service
|
||||
|
||||
final registrationService = Get.find<RegistrationService>();
|
||||
final result = await registrationService.saveRegistrationData();
|
||||
|
||||
if (result) {
|
||||
isDataSaved.value = true;
|
||||
dataSaveMessage.value = 'Registration data saved successfully!';
|
||||
} else {
|
||||
isDataSaved.value = false;
|
||||
dataSaveMessage.value =
|
||||
'Failed to save registration data. Please try again.';
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
isDataSaved.value = false;
|
||||
dataSaveMessage.value = 'Error saving registration data: $e';
|
||||
print('Error saving registration data: $e');
|
||||
return false;
|
||||
} finally {
|
||||
isSavingData.value = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,511 @@
|
|||
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,117 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.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/presentasion/controllers/selfie-verification/face_liveness_detection.dart';
|
||||
|
||||
/// Service for handling facial verification
|
||||
/// This class serves as a bridge between UI controllers and face detection functionality
|
||||
class FacialVerificationService {
|
||||
// Singleton pattern
|
||||
static final FacialVerificationService _instance =
|
||||
FacialVerificationService._();
|
||||
static FacialVerificationService get instance => _instance;
|
||||
|
||||
// Private constructor
|
||||
FacialVerificationService._();
|
||||
|
||||
// Edge Function Service for actual API calls
|
||||
final EdgeFunctionService _edgeFunctionService = EdgeFunctionService.instance;
|
||||
|
||||
// Flag for skipping verification in development mode
|
||||
final bool skipFaceVerification = false;
|
||||
|
||||
/// Get all faces in an image using edge function
|
||||
Future<List<FaceModel>> detectFaces(XFile image) async {
|
||||
if (skipFaceVerification) {
|
||||
return [_createDummyFaceModel(image.path)];
|
||||
}
|
||||
|
||||
return await _edgeFunctionService.detectFaces(image);
|
||||
}
|
||||
|
||||
/// Detect if there is a face in the image
|
||||
Future<bool> detectFaceInImage(XFile image) async {
|
||||
if (skipFaceVerification) return true;
|
||||
|
||||
try {
|
||||
final detectedFaces = await _edgeFunctionService.detectFaces(image);
|
||||
return detectedFaces.isNotEmpty;
|
||||
} catch (e) {
|
||||
print('Error detecting face: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare faces between two images using edge function
|
||||
Future<FaceComparisonResult> compareFaces(XFile source, XFile target) async {
|
||||
if (skipFaceVerification) {
|
||||
return _createDummyComparisonResult(source.path, target.path);
|
||||
}
|
||||
|
||||
return await _edgeFunctionService.compareFaces(source, target);
|
||||
}
|
||||
|
||||
/// Start liveness check - this will navigate to the liveness check screen
|
||||
/// and return a model with the captured image if successful
|
||||
Future<FaceModel> startLivenessCheck(BuildContext context) async {
|
||||
if (skipFaceVerification) {
|
||||
// Mock the process for development mode
|
||||
return _createDummyFaceModel("dummy_path", isLive: true);
|
||||
}
|
||||
|
||||
// In a real implementation, this would navigate to the liveness check screen
|
||||
// and return the result when complete.
|
||||
// For now, we're just returning a placeholder model
|
||||
|
||||
final livenessController = Get.find<FaceLivenessController>();
|
||||
|
||||
// Wait for liveness check to complete
|
||||
// This is a placeholder - in a real implementation we would wait for the
|
||||
// liveness check screen to return a result
|
||||
|
||||
// Return placeholder model
|
||||
return FaceModel.empty().withLiveness(
|
||||
isLive: false,
|
||||
confidence: 0.0,
|
||||
message: 'Liveness check not implemented yet',
|
||||
);
|
||||
}
|
||||
|
||||
// Helper methods for development mode
|
||||
FaceModel _createDummyFaceModel(String imagePath, {bool isLive = false}) {
|
||||
return FaceModel(
|
||||
imagePath: imagePath,
|
||||
faceId: 'dummy-face-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.95,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
).withLiveness(
|
||||
isLive: isLive,
|
||||
confidence: isLive ? 0.92 : 0.0,
|
||||
message:
|
||||
isLive
|
||||
? 'Liveness check passed (development mode)'
|
||||
: 'Basic face detection only (development mode)',
|
||||
);
|
||||
}
|
||||
|
||||
FaceComparisonResult _createDummyComparisonResult(
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) {
|
||||
final sourceFace = _createDummyFaceModel(sourcePath);
|
||||
final targetFace = _createDummyFaceModel(targetPath, isLive: true);
|
||||
|
||||
return FaceComparisonResult(
|
||||
sourceFace: sourceFace,
|
||||
targetFace: targetFace,
|
||||
isMatch: true,
|
||||
confidence: 0.91,
|
||||
similarity: 91.0,
|
||||
similarityThreshold: 75.0,
|
||||
confidenceLevel: "HIGH",
|
||||
message: 'Face matching successful (development mode)',
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,498 @@
|
|||
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.',
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,414 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.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/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/facial_verification_controller.dart';
|
||||
|
||||
class SelfieVerificationController extends GetxController {
|
||||
// MARK: - Dependencies
|
||||
final FacialVerificationService _facialVerificationService =
|
||||
FacialVerificationService.instance;
|
||||
late FaceLivenessController _livenessController;
|
||||
|
||||
// MARK: - Constants
|
||||
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB
|
||||
|
||||
// Form validation
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
final RxString selfieError = RxString('');
|
||||
final RxString selfieValidationMessage = RxString('');
|
||||
|
||||
// Image state
|
||||
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||
final Rx<FaceModel> selfieFace = Rx<FaceModel>(FaceModel.empty());
|
||||
|
||||
// Process state flags
|
||||
final RxBool isVerifyingFace = RxBool(false);
|
||||
final RxBool isUploadingSelfie = RxBool(false);
|
||||
final RxBool isPerformingLivenessCheck = RxBool(false);
|
||||
final RxBool isComparingWithIDCard = RxBool(false);
|
||||
|
||||
// Verification results
|
||||
final RxBool isSelfieValid = RxBool(false);
|
||||
final RxBool isLivenessCheckPassed = RxBool(false);
|
||||
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||
|
||||
// Face comparison results
|
||||
final Rx<FaceComparisonResult?> faceComparisonResult =
|
||||
Rx<FaceComparisonResult?>(null);
|
||||
final RxBool isMatchWithIDCard = RxBool(false);
|
||||
final RxDouble matchConfidence = RxDouble(0.0);
|
||||
final RxString selfieImageFaceId = RxString('');
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_livenessController = Get.put(FaceLivenessController());
|
||||
|
||||
// Listen to liveness detection completion
|
||||
ever(_livenessController.status, (LivenessStatus status) {
|
||||
if (status == LivenessStatus.completed &&
|
||||
_livenessController.capturedImage != null) {
|
||||
// When liveness check completes successfully, update selfie data
|
||||
_processCapturedLivenessImage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process the image captured during liveness detection
|
||||
Future<void> _processCapturedLivenessImage() async {
|
||||
if (_livenessController.capturedImage == null) return;
|
||||
|
||||
try {
|
||||
// Update selfie data
|
||||
selfieImage.value = _livenessController.capturedImage;
|
||||
|
||||
// Generate face model from liveness controller
|
||||
selfieFace.value = _livenessController.generateFaceModel();
|
||||
isLivenessCheckPassed.value = true;
|
||||
isSelfieValid.value = true;
|
||||
selfieImageFaceId.value = selfieFace.value.faceId;
|
||||
selfieValidationMessage.value =
|
||||
'Liveness check passed! Your face is verified.';
|
||||
|
||||
// Now that we have a valid selfie with liveness verification, compare with ID card
|
||||
await compareWithIDCardPhoto();
|
||||
} catch (e) {
|
||||
_handleError('Failed to process liveness image', e);
|
||||
} finally {
|
||||
isPerformingLivenessCheck.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Validate the selfie data for form submission
|
||||
bool validate() {
|
||||
clearErrors();
|
||||
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie for verification';
|
||||
isFormValid.value = false;
|
||||
} else if (!isSelfieValid.value) {
|
||||
selfieError.value = 'Your selfie image is not valid';
|
||||
isFormValid.value = false;
|
||||
} else if (!hasConfirmedSelfie.value) {
|
||||
selfieError.value = 'Please confirm your selfie image';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
/// Clear all error messages
|
||||
void clearErrors() {
|
||||
selfieError.value = '';
|
||||
selfieValidationMessage.value = '';
|
||||
}
|
||||
|
||||
/// Perform liveness detection
|
||||
Future<void> performLivenessDetection() async {
|
||||
try {
|
||||
_setLoading(isPerformingLivenessCheck: true);
|
||||
|
||||
// Reset any existing selfie data
|
||||
_resetVerificationData();
|
||||
|
||||
// Navigate to liveness detection page
|
||||
Get.toNamed('/liveness-detection');
|
||||
|
||||
// Processing will continue when liveness detection is complete, handled by _processCapturedLivenessImage()
|
||||
} catch (e) {
|
||||
_handleError('Failed to start liveness detection', e);
|
||||
_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
|
||||
void clearSelfieImage() {
|
||||
selfieImage.value = null;
|
||||
_resetVerificationData();
|
||||
}
|
||||
|
||||
/// Confirm the selfie image after validation
|
||||
void confirmSelfieImage() {
|
||||
if (isSelfieValid.value) {
|
||||
hasConfirmedSelfie.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually trigger face match verification with ID card
|
||||
Future<void> verifyFaceMatchWithIDCard() async {
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie first';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the ID card controller
|
||||
final idCardController = Get.find<IdCardVerificationController>();
|
||||
|
||||
if (idCardController.idCardImage.value == null) {
|
||||
selfieValidationMessage.value =
|
||||
'ID card image is required for comparison';
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare with ID card photo
|
||||
await compareWithIDCardPhoto();
|
||||
} catch (e) {
|
||||
selfieValidationMessage.value = 'Face verification failed: $e';
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
|
||||
/// Pick an image from the specified source
|
||||
Future<XFile?> _pickImage(ImageSource source) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
return picker.pickImage(
|
||||
source: source,
|
||||
preferredCameraDevice: CameraDevice.front,
|
||||
imageQuality: 80,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a file size is within the allowed limit
|
||||
Future<bool> _isFileSizeValid(XFile file) async {
|
||||
final fileSize = await File(file.path).length();
|
||||
return fileSize <= maxFileSizeBytes;
|
||||
}
|
||||
|
||||
/// Update face data with new liveness check results
|
||||
void _updateFaceData(FaceModel face) {
|
||||
selfieFace.value = face;
|
||||
isLivenessCheckPassed.value = face.isLive;
|
||||
selfieImageFaceId.value = face.faceId;
|
||||
isSelfieValid.value = face.isLive;
|
||||
selfieValidationMessage.value = face.message;
|
||||
}
|
||||
|
||||
/// Update comparison results
|
||||
void _updateComparisonResult(FaceComparisonResult result) {
|
||||
faceComparisonResult.value = result;
|
||||
isMatchWithIDCard.value = result.isMatch;
|
||||
matchConfidence.value = result.confidence;
|
||||
selfieValidationMessage.value = result.message;
|
||||
}
|
||||
|
||||
/// Reset all verification-related data
|
||||
void _resetVerificationData() {
|
||||
// Clear validation state
|
||||
selfieError.value = '';
|
||||
selfieValidationMessage.value = '';
|
||||
isSelfieValid.value = false;
|
||||
isLivenessCheckPassed.value = false;
|
||||
hasConfirmedSelfie.value = false;
|
||||
|
||||
// Clear face data
|
||||
selfieFace.value = FaceModel.empty();
|
||||
|
||||
// Clear comparison data
|
||||
faceComparisonResult.value = null;
|
||||
isMatchWithIDCard.value = false;
|
||||
matchConfidence.value = 0.0;
|
||||
selfieImageFaceId.value = '';
|
||||
}
|
||||
|
||||
/// Handle errors in a consistent way
|
||||
void _handleError(String baseMessage, dynamic error) {
|
||||
print('$baseMessage: $error');
|
||||
selfieError.value = '$baseMessage: $error';
|
||||
isSelfieValid.value = false;
|
||||
}
|
||||
|
||||
/// Set loading states in a consistent way
|
||||
void _setLoading({
|
||||
bool? isVerifyingFace,
|
||||
bool? isUploadingSelfie,
|
||||
bool? isPerformingLivenessCheck,
|
||||
bool? isComparingWithIDCard,
|
||||
}) {
|
||||
if (isVerifyingFace != null) this.isVerifyingFace.value = isVerifyingFace;
|
||||
if (isUploadingSelfie != null)
|
||||
this.isUploadingSelfie.value = isUploadingSelfie;
|
||||
if (isPerformingLivenessCheck != null)
|
||||
this.isPerformingLivenessCheck.value = isPerformingLivenessCheck;
|
||||
if (isComparingWithIDCard != null)
|
||||
this.isComparingWithIDCard.value = isComparingWithIDCard;
|
||||
}
|
||||
|
||||
/// Handle development mode dummy validation
|
||||
Future<void> _handleDevelopmentModeValidation() async {
|
||||
isSelfieValid.value = true;
|
||||
isLivenessCheckPassed.value = true;
|
||||
selfieImageFaceId.value =
|
||||
'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}';
|
||||
selfieValidationMessage.value =
|
||||
'Selfie validation successful (development mode)';
|
||||
|
||||
selfieFace.value = FaceModel(
|
||||
imagePath: selfieImage.value!.path,
|
||||
faceId: selfieImageFaceId.value,
|
||||
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 (development mode)',
|
||||
);
|
||||
|
||||
await compareWithIDCardPhoto();
|
||||
}
|
||||
|
||||
/// Handle development mode comparison dummy data
|
||||
Future<void> _handleDevelopmentModeComparison(
|
||||
IdCardVerificationController idCardController,
|
||||
) async {
|
||||
final sourceFace =
|
||||
idCardController.idCardFace.value.hasValidFace
|
||||
? idCardController.idCardFace.value
|
||||
: FaceModel(
|
||||
imagePath: idCardController.idCardImage.value!.path,
|
||||
faceId:
|
||||
'dummy-id-card-face-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.95,
|
||||
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||
);
|
||||
|
||||
final comparisonResult = FaceComparisonResult(
|
||||
sourceFace: sourceFace,
|
||||
targetFace: selfieFace.value,
|
||||
isMatch: true,
|
||||
confidence: 0.91,
|
||||
message: 'Face matching successful (development mode)',
|
||||
);
|
||||
|
||||
_updateComparisonResult(comparisonResult);
|
||||
}
|
||||
}
|
|
@ -1,165 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
import 'package:sigap/src/utils/helpers/network_manager.dart';
|
||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||
|
||||
class SignInController extends GetxController {
|
||||
// Singleton instance
|
||||
static SignInController get instance => Get.find();
|
||||
|
||||
final rememberMe = false.obs;
|
||||
final isPasswordVisible = false.obs;
|
||||
|
||||
final localStorage = GetStorage();
|
||||
|
||||
final email = TextEditingController();
|
||||
final password = TextEditingController();
|
||||
|
||||
final emailError = ''.obs;
|
||||
final passwordError = ''.obs;
|
||||
|
||||
final isLoading = false.obs;
|
||||
|
||||
// GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
// Check if remember me is checked
|
||||
if (localStorage.read('REMEMBER_ME_CHECK') == true) {
|
||||
email.text = localStorage.read('REMEMBER_ME_EMAIL');
|
||||
password.text = localStorage.read('REMEMBER_ME_PASSWORD');
|
||||
}
|
||||
|
||||
super.onInit();
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
void togglePasswordVisibility() {
|
||||
isPasswordVisible.value = !isPasswordVisible.value;
|
||||
}
|
||||
|
||||
// Sign in method
|
||||
Future<void> credentialsSignIn(GlobalKey<FormState> formKey) async {
|
||||
try {
|
||||
// Start loading
|
||||
// TFullScreenLoader.openLoadingDialog(
|
||||
// 'Logging you in....',
|
||||
// TImages.amongUsLoading,
|
||||
// );
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Check connection
|
||||
final isConected = await NetworkManager.instance.isConnected();
|
||||
if (!isConected) {
|
||||
// TFullScreenLoader.stopLoading();
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Form validation
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// TFullScreenLoader.stopLoading();
|
||||
emailError.value = '';
|
||||
passwordError.value = '';
|
||||
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Store data if remember me is checked
|
||||
if (rememberMe.value) {
|
||||
localStorage.write('REMEMBER_ME_EMAIL', email.text);
|
||||
localStorage.write('REMEMBER_ME_PASSWORD', password.text);
|
||||
localStorage.write('REMEMBER_ME_CHECK', rememberMe.value);
|
||||
}
|
||||
|
||||
// Login with credentials
|
||||
await AuthenticationRepository.instance.signInWithEmailPassword(
|
||||
email: email.text.trim(),
|
||||
password: password.text.trim(),
|
||||
);
|
||||
|
||||
// Stop loading
|
||||
// TFullScreenLoader.stopLoading();
|
||||
isLoading.value = false;
|
||||
|
||||
// Refresh user data
|
||||
// final updatedUser = await UserRepository.instance.getUserDetailById();
|
||||
// UserController.instance.user.value = updatedUser;
|
||||
|
||||
// Redirect to home screen
|
||||
AuthenticationRepository.instance.screenRedirect();
|
||||
} catch (e) {
|
||||
// Remove loading
|
||||
// TFullScreenLoader.stopLoading();
|
||||
isLoading.value = false;
|
||||
|
||||
TLoaders.errorSnackBar(title: 'Oh snap', message: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// -- Google Sign In Authentication
|
||||
Future<void> googleSignIn(GlobalKey<FormState> formKey) async {
|
||||
try {
|
||||
// Start loading
|
||||
// TFullScreenLoader.openLoadingDialog(
|
||||
// 'Logging you in....',
|
||||
// TImages.amongUsLoading,
|
||||
// );
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Check connection
|
||||
final isConected = await NetworkManager.instance.isConnected();
|
||||
if (!isConected) {
|
||||
// TFullScreenLoader.stopLoading();
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Form validation
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// TFullScreenLoader.stopLoading();
|
||||
emailError.value = '';
|
||||
passwordError.value = '';
|
||||
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Login with Google and save user data in Firebase Authtentication
|
||||
await AuthenticationRepository.instance.signInWithGoogle();
|
||||
|
||||
// final role = localStorage.read('TEMP_ROLE');
|
||||
|
||||
// Logger().w(['User Data: $user']);
|
||||
|
||||
// Stop loading
|
||||
// TFullScreenLoader.stopLoading();
|
||||
isLoading.value = false;
|
||||
|
||||
// Redirect to home screen
|
||||
AuthenticationRepository.instance.screenRedirect();
|
||||
} catch (e) {
|
||||
// Remove loading
|
||||
// TFullScreenLoader.stopLoading();
|
||||
isLoading.value = false;
|
||||
|
||||
TLoaders.errorSnackBar(title: 'Oh snap', message: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to sign up screen
|
||||
void goToSignUp() {
|
||||
Get.toNamed(AppRoutes.signupWithRole);
|
||||
}
|
||||
|
||||
// Navigate to forgot password screen
|
||||
void goToForgotPassword() {
|
||||
Get.toNamed(AppRoutes.forgotPassword);
|
||||
}
|
||||
}
|
|
@ -1,186 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
|
||||
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
import 'package:sigap/src/utils/helpers/network_manager.dart';
|
||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class SignUpController extends GetxController {
|
||||
static SignUpController get instance => Get.find();
|
||||
|
||||
// Variable
|
||||
final storage = GetStorage();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
// Privacy policy
|
||||
final privacyPolicy = false.obs;
|
||||
|
||||
// Controllers for form fields
|
||||
final emailController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final confirmPasswordController = TextEditingController();
|
||||
|
||||
// Observable error messages
|
||||
final emailError = ''.obs;
|
||||
final passwordError = ''.obs;
|
||||
final confirmPasswordError = ''.obs;
|
||||
|
||||
// Observable states
|
||||
final isPasswordVisible = false.obs;
|
||||
final isConfirmPasswordVisible = false.obs;
|
||||
final isLoading = false.obs;
|
||||
final userMetadata = Rx<UserMetadataModel?>(null);
|
||||
final selectedRole = Rx<dynamic>(null);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
||||
// Get arguments from StepForm
|
||||
final arguments = Get.arguments;
|
||||
if (arguments != null) {
|
||||
if (arguments['userMetadata'] != null) {
|
||||
userMetadata.value = arguments['userMetadata'] as UserMetadataModel;
|
||||
}
|
||||
if (arguments['role'] != null) {
|
||||
selectedRole.value = arguments['role'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validators
|
||||
String? validateEmail(String? value) {
|
||||
final error = TValidators.validateEmail(value);
|
||||
emailError.value = error ?? '';
|
||||
return error;
|
||||
}
|
||||
|
||||
String? validatePassword(String? value) {
|
||||
final error = TValidators.validatePassword(value);
|
||||
passwordError.value = error ?? '';
|
||||
return error;
|
||||
}
|
||||
|
||||
String? validateConfirmPassword(String? value) {
|
||||
final error = TValidators.validateConfirmPassword(
|
||||
passwordController.text,
|
||||
value,
|
||||
);
|
||||
confirmPasswordError.value = error ?? '';
|
||||
return error;
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
void togglePasswordVisibility() {
|
||||
isPasswordVisible.value = !isPasswordVisible.value;
|
||||
}
|
||||
|
||||
// Toggle confirm password visibility
|
||||
void toggleConfirmPasswordVisibility() {
|
||||
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
|
||||
}
|
||||
|
||||
// Sign Up Function
|
||||
void signUp() async {
|
||||
Logger().i('SignUp process started');
|
||||
try {
|
||||
// Form validation
|
||||
if (!formKey.currentState!.validate()) {
|
||||
Logger().w('Form validation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check privacy policy acceptance
|
||||
if (!privacyPolicy.value) {
|
||||
Logger().w('Privacy policy not accepted');
|
||||
TLoaders.warningSnackBar(
|
||||
title: 'Accept Privacy Policy',
|
||||
message:
|
||||
'In order to create account, you must accept the privacy policy & terms of use.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start loading
|
||||
isLoading.value = true;
|
||||
Logger().i('Starting signup process');
|
||||
|
||||
// Check connection
|
||||
Logger().i('Checking network connection');
|
||||
final isConnected = await NetworkManager.instance.isConnected();
|
||||
if (!isConnected) {
|
||||
Logger().w('No internet connection');
|
||||
isLoading.value = false;
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'No Internet Connection',
|
||||
message: 'Please check your internet connection and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure user metadata is available
|
||||
if (userMetadata.value == null) {
|
||||
Logger().w('User metadata is missing');
|
||||
isLoading.value = false;
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Missing Information',
|
||||
message: 'Please complete your profile information first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add email to user metadata
|
||||
final updatedMetadata = userMetadata.value!.copyWith(
|
||||
email: emailController.text.trim(),
|
||||
);
|
||||
|
||||
// Register user with Supabase Auth
|
||||
Logger().i('Registering user with Supabase Auth');
|
||||
final authResponse = await AuthenticationRepository.instance
|
||||
.signUpWithCredential(
|
||||
emailController.text.trim(),
|
||||
passwordController.text.trim(),
|
||||
updatedMetadata.toJson().toString(), // Pass user metadata as a JSON string
|
||||
);
|
||||
|
||||
// Store email for verification or next steps
|
||||
storage.write('CURRENT_USER_EMAIL', emailController.text.trim());
|
||||
|
||||
// Remove loading
|
||||
Logger().i('Signup process completed');
|
||||
isLoading.value = false;
|
||||
|
||||
// Show success message
|
||||
Logger().i('Showing success message');
|
||||
TLoaders.successSnackBar(
|
||||
title: 'Account Created',
|
||||
message: 'Please check your email to verify your account!',
|
||||
);
|
||||
|
||||
// Navigate to email verification
|
||||
Get.offNamed(
|
||||
AppRoutes.emailVerification,
|
||||
arguments: {'email': authResponse.user?.email},
|
||||
);
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
Logger().e('Error occurred: $e');
|
||||
isLoading.value = false;
|
||||
|
||||
// Show error to the user
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Registration Failed',
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to sign in screen
|
||||
void goToSignIn() {
|
||||
Get.offNamed(AppRoutes.signIn);
|
||||
}
|
||||
}
|
|
@ -1,325 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||
|
||||
class SelfieVerificationController extends GetxController {
|
||||
// Singleton instance
|
||||
static SelfieVerificationController get instance => Get.find();
|
||||
|
||||
// Services - Use FacialVerificationService instead of direct EdgeFunction
|
||||
final FacialVerificationService _faceService =
|
||||
FacialVerificationService.instance;
|
||||
|
||||
// Maximum allowed file size in bytes (4MB)
|
||||
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
|
||||
|
||||
// For this step, we just need to ensure selfie is uploaded and validated
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
// Face verification variables
|
||||
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||
final RxString selfieError = RxString('');
|
||||
final RxBool isVerifyingFace = RxBool(false);
|
||||
final RxBool isSelfieValid = RxBool(false);
|
||||
final RxString selfieValidationMessage = RxString('');
|
||||
final RxBool isLivenessCheckPassed = RxBool(false);
|
||||
final RxBool isPerformingLivenessCheck = RxBool(false);
|
||||
|
||||
// Loading states for image uploading
|
||||
final RxBool isUploadingSelfie = RxBool(false);
|
||||
|
||||
// Confirmation status
|
||||
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||
|
||||
// Face comparison with ID card photo
|
||||
final RxBool isComparingWithIDCard = RxBool(false);
|
||||
|
||||
// Use FaceModel to store selfie face details
|
||||
final Rx<FaceModel> selfieFace = Rx<FaceModel>(FaceModel.empty());
|
||||
|
||||
// Use FaceComparisonResult to store comparison results
|
||||
final Rx<FaceComparisonResult?> faceComparisonResult =
|
||||
Rx<FaceComparisonResult?>(null);
|
||||
|
||||
// For backward compatibility
|
||||
final RxBool isMatchWithIDCard = RxBool(false);
|
||||
final RxDouble matchConfidence = RxDouble(0.0);
|
||||
final RxString selfieImageFaceId = RxString('');
|
||||
|
||||
bool validate() {
|
||||
clearErrors();
|
||||
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie for verification';
|
||||
isFormValid.value = false;
|
||||
} else if (!isSelfieValid.value) {
|
||||
selfieError.value = 'Your selfie image is not valid';
|
||||
isFormValid.value = false;
|
||||
} else if (!hasConfirmedSelfie.value) {
|
||||
selfieError.value = 'Please confirm your selfie image';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
selfieError.value = '';
|
||||
selfieValidationMessage.value = '';
|
||||
}
|
||||
|
||||
void clearSelfieValidationMessage() {
|
||||
selfieValidationMessage.value = '';
|
||||
}
|
||||
|
||||
// Take or pick selfie image with file size validation
|
||||
Future<void> pickSelfieImage(ImageSource source) async {
|
||||
try {
|
||||
isUploadingSelfie.value = true;
|
||||
hasConfirmedSelfie.value = false; // Reset confirmation when image changes
|
||||
|
||||
// Reset face data
|
||||
selfieFace.value = FaceModel.empty();
|
||||
faceComparisonResult.value = null;
|
||||
isMatchWithIDCard.value = false;
|
||||
matchConfidence.value = 0.0;
|
||||
selfieImageFaceId.value = '';
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: source,
|
||||
preferredCameraDevice: CameraDevice.front,
|
||||
imageQuality: 80, // Reduce quality to help with file size
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
// Check file size
|
||||
final File file = File(image.path);
|
||||
final int fileSize = await file.length();
|
||||
|
||||
if (fileSize > maxFileSizeBytes) {
|
||||
selfieError.value =
|
||||
'Image size exceeds 4MB limit. Please take a lower resolution photo.';
|
||||
isSelfieValid.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add artificial delay to show loading state
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
selfieImage.value = image;
|
||||
selfieError.value = '';
|
||||
|
||||
// Initial validation of the selfie (face detection)
|
||||
await validateSelfieImage();
|
||||
}
|
||||
} catch (e) {
|
||||
selfieError.value = 'Failed to capture selfie: $e';
|
||||
isSelfieValid.value = false;
|
||||
} finally {
|
||||
isUploadingSelfie.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial validation of selfie image using FacialVerificationService
|
||||
Future<void> validateSelfieImage() async {
|
||||
// Clear previous validation messages
|
||||
clearErrors();
|
||||
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie first';
|
||||
isSelfieValid.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick validation for development
|
||||
if (_faceService.skipFaceVerification) {
|
||||
isSelfieValid.value = true;
|
||||
isLivenessCheckPassed.value = true;
|
||||
selfieImageFaceId.value =
|
||||
'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}';
|
||||
selfieValidationMessage.value =
|
||||
'Selfie validation successful (development mode)';
|
||||
|
||||
// Add dummy face data
|
||||
selfieFace.value = FaceModel(
|
||||
imagePath: selfieImage.value!.path,
|
||||
faceId: selfieImageFaceId.value,
|
||||
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 (development mode)',
|
||||
);
|
||||
|
||||
// Also do dummy comparison with ID Card
|
||||
await compareWithIDCardPhoto();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isVerifyingFace.value = true;
|
||||
|
||||
// Use FacialVerificationService for liveness check
|
||||
final FaceModel livenessFace = await _faceService.performLivenessCheck(
|
||||
selfieImage.value!,
|
||||
);
|
||||
|
||||
// Update the face model
|
||||
selfieFace.value = livenessFace;
|
||||
|
||||
// Update liveness status
|
||||
isLivenessCheckPassed.value = livenessFace.isLive;
|
||||
|
||||
// For backward compatibility
|
||||
selfieImageFaceId.value = livenessFace.faceId;
|
||||
|
||||
if (livenessFace.isLive) {
|
||||
isSelfieValid.value = true;
|
||||
selfieValidationMessage.value = livenessFace.message;
|
||||
|
||||
// Compare with ID card photo if available
|
||||
await compareWithIDCardPhoto();
|
||||
} else {
|
||||
isSelfieValid.value = false;
|
||||
selfieValidationMessage.value = livenessFace.message;
|
||||
}
|
||||
} catch (e) {
|
||||
isSelfieValid.value = false;
|
||||
selfieValidationMessage.value = 'Validation failed: ${e.toString()}';
|
||||
} finally {
|
||||
isVerifyingFace.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare selfie with ID card photo using FacialVerificationService
|
||||
Future<void> compareWithIDCardPhoto() async {
|
||||
try {
|
||||
final idCardController = Get.find<IdCardVerificationController>();
|
||||
|
||||
// Check if both images are available
|
||||
if (selfieImage.value == null ||
|
||||
idCardController.idCardImage.value == null) {
|
||||
print('Cannot compare faces: Missing images');
|
||||
return;
|
||||
}
|
||||
|
||||
isComparingWithIDCard.value = true;
|
||||
|
||||
// Quick comparison for development
|
||||
if (_faceService.skipFaceVerification) {
|
||||
// Create dummy successful comparison result
|
||||
final comparisonResult = FaceComparisonResult(
|
||||
sourceFace:
|
||||
idCardController.idCardFace.value.hasValidFace
|
||||
? idCardController.idCardFace.value
|
||||
: FaceModel(
|
||||
imagePath: idCardController.idCardImage.value!.path,
|
||||
faceId:
|
||||
'dummy-id-card-face-${DateTime.now().millisecondsSinceEpoch}',
|
||||
confidence: 0.95,
|
||||
boundingBox: {
|
||||
'x': 0.1,
|
||||
'y': 0.1,
|
||||
'width': 0.8,
|
||||
'height': 0.8,
|
||||
},
|
||||
),
|
||||
targetFace: selfieFace.value,
|
||||
isMatch: true,
|
||||
confidence: 0.91,
|
||||
message: 'Face matching successful (development mode)',
|
||||
);
|
||||
|
||||
// Store the comparison result
|
||||
faceComparisonResult.value = comparisonResult;
|
||||
|
||||
// For backward compatibility
|
||||
isMatchWithIDCard.value = true;
|
||||
matchConfidence.value = 0.91;
|
||||
|
||||
// Update validation message
|
||||
selfieValidationMessage.value =
|
||||
'Face matching successful (development mode)';
|
||||
|
||||
isComparingWithIDCard.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use FacialVerificationService to compare the faces
|
||||
final comparisonResult = await _faceService.compareFaces(
|
||||
idCardController.idCardImage.value!,
|
||||
selfieImage.value!
|
||||
);
|
||||
|
||||
// Store the comparison result
|
||||
faceComparisonResult.value = comparisonResult;
|
||||
|
||||
// For backward compatibility
|
||||
isMatchWithIDCard.value = comparisonResult.isMatch;
|
||||
matchConfidence.value = comparisonResult.confidence;
|
||||
|
||||
// Update validation message to include face comparison result
|
||||
selfieValidationMessage.value = comparisonResult.message;
|
||||
} catch (e) {
|
||||
print('Face comparison error: $e');
|
||||
selfieValidationMessage.value = 'Face comparison error: $e';
|
||||
} finally {
|
||||
isComparingWithIDCard.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Manually trigger face comparison with ID card using AWS Rekognition
|
||||
Future<void> verifyFaceMatchWithIDCard() async {
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie first';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isVerifyingFace.value = true;
|
||||
|
||||
// Get the ID card controller
|
||||
final idCardController = Get.find<IdCardVerificationController>();
|
||||
|
||||
if (idCardController.idCardImage.value == null) {
|
||||
selfieValidationMessage.value =
|
||||
'ID card image is required for comparison';
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare faces directly using AWS Rekognition
|
||||
await compareWithIDCardPhoto();
|
||||
} catch (e) {
|
||||
selfieValidationMessage.value = 'Face verification failed: $e';
|
||||
} finally {
|
||||
isVerifyingFace.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Selfie Image
|
||||
void clearSelfieImage() {
|
||||
selfieImage.value = null;
|
||||
selfieError.value = '';
|
||||
isSelfieValid.value = false;
|
||||
selfieValidationMessage.value = '';
|
||||
isLivenessCheckPassed.value = false;
|
||||
hasConfirmedSelfie.value = false;
|
||||
selfieFace.value = FaceModel.empty();
|
||||
faceComparisonResult.value = null;
|
||||
isMatchWithIDCard.value = false;
|
||||
matchConfidence.value = 0.0;
|
||||
selfieImageFaceId.value = '';
|
||||
}
|
||||
|
||||
// Confirm Selfie Image
|
||||
void confirmSelfieImage() {
|
||||
if (isSelfieValid.value) {
|
||||
hasConfirmedSelfie.value = true;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/email_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/forgot_password_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
|
|
|
@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
@ -23,9 +23,11 @@ class IdentityVerificationStep extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const FormSectionHeader(
|
||||
FormSectionHeader(
|
||||
title: 'Additional Information',
|
||||
subtitle: 'Please provide additional personal details',
|
||||
subtitle: isOfficer
|
||||
? 'Please provide additional personal details'
|
||||
: 'Please verify your KTP information below. NIK field cannot be edited.',
|
||||
),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
|
@ -0,0 +1,218 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/liveness_detection_controller.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
|
||||
class LivenessDetectionPage extends StatelessWidget {
|
||||
const LivenessDetectionPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.find<FaceLivenessController>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Face Liveness Check'),
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
},
|
||||
),
|
||||
),
|
||||
body: Obx(() {
|
||||
// Show loading indicator while camera initializes
|
||||
if (!controller.cameraController!.value.isInitialized) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 20),
|
||||
Text('Initializing camera...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Show captured image when complete
|
||||
if (controller.isCaptured) {
|
||||
return _buildCapturedView(controller, context);
|
||||
}
|
||||
|
||||
// Main liveness detection UI
|
||||
return Column(
|
||||
children: [
|
||||
// Instruction banner
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16),
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.getCurrentDirection(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Camera preview with face overlay
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Camera preview
|
||||
SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: controller.cameraController!.buildPreview(),
|
||||
),
|
||||
),
|
||||
|
||||
// Oval face guide overlay
|
||||
Container(
|
||||
width: 250,
|
||||
height: 350,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
borderRadius: BorderRadius.circular(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Completed steps progress
|
||||
Container(
|
||||
padding: EdgeInsets.all(16),
|
||||
color: Colors.grey.shade100,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Verification Progress:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
|
||||
// Steps list
|
||||
...controller.successfulSteps.map(
|
||||
(step) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
size: 18,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(step),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCapturedView(
|
||||
FaceLivenessController controller,
|
||||
BuildContext context,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Verification Successful!',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
|
||||
// Display captured image
|
||||
if (controller.capturedImage != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(150),
|
||||
child: Image.file(
|
||||
File(controller.capturedImage!.path),
|
||||
width: 300,
|
||||
height: 300,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24),
|
||||
|
||||
// Completed steps list
|
||||
Container(
|
||||
padding: EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'All verification steps completed:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
...controller.successfulSteps.map(
|
||||
(step) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text(step),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 32),
|
||||
|
||||
// Continue button
|
||||
ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TColors.primary,
|
||||
minimumSize: Size(double.infinity, 50),
|
||||
),
|
||||
child: Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
@ -1,13 +1,14 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/unit_info_step.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/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/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/unit_info_step.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/utils/constants/colors.dart';
|
||||
|
@ -19,6 +20,8 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Ensure all dependencies are registered
|
||||
|
||||
final controller = Get.find<FormRegistrationController>();
|
||||
final dark = THelperFunctions.isDarkMode(context);
|
||||
|
||||
|
@ -130,7 +133,6 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
child: AuthButton(
|
||||
text: 'Previous',
|
||||
onPressed: controller.previousStep,
|
||||
isPrimary: false,
|
||||
),
|
||||
),
|
||||
)
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_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/selfie_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
|
@ -55,23 +55,96 @@ class SelfieVerificationStep extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
|
||||
// Selfie Upload Widget
|
||||
Obx(
|
||||
() => ImageUploader(
|
||||
image: controller.selfieImage.value,
|
||||
title: 'Take a Selfie',
|
||||
subtitle: 'Tap to take a selfie (max 4MB)',
|
||||
errorMessage: controller.selfieError.value,
|
||||
isUploading: controller.isUploadingSelfie.value,
|
||||
isVerifying: controller.isVerifyingFace.value,
|
||||
isConfirmed: controller.hasConfirmedSelfie.value,
|
||||
onTapToSelect: () => _captureSelfie(controller),
|
||||
onClear: controller.clearSelfieImage,
|
||||
onValidate: controller.validateSelfieImage,
|
||||
placeholderIcon: Icons.face,
|
||||
// Liveness Detection Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
||||
child: Obx(
|
||||
() => ElevatedButton.icon(
|
||||
onPressed:
|
||||
controller.isPerformingLivenessCheck.value
|
||||
? null
|
||||
: controller.performLivenessDetection,
|
||||
icon:
|
||||
controller.isPerformingLivenessCheck.value
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.security),
|
||||
label: Text(
|
||||
controller.isPerformingLivenessCheck.value
|
||||
? 'Processing...'
|
||||
: 'Perform Liveness Detection',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: Size(double.infinity, 45),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Selfie Upload Widget (alternative manual method)
|
||||
Obx(
|
||||
() =>
|
||||
controller.selfieImage.value == null
|
||||
? Container(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: TSizes.spaceBtwItems,
|
||||
),
|
||||
padding: const EdgeInsets.all(TSizes.md),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
TSizes.borderRadiusMd,
|
||||
),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
"Or take a selfie manually",
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _captureSelfie(controller),
|
||||
icon: Icon(Icons.camera_alt),
|
||||
label: Text('Take Manual Selfie'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: Size(double.infinity, 45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ImageUploader(
|
||||
image: controller.selfieImage.value,
|
||||
title: 'Selfie Verification',
|
||||
subtitle:
|
||||
controller.isLivenessCheckPassed.value
|
||||
? 'Liveness check passed!'
|
||||
: 'Your selfie photo',
|
||||
errorMessage: controller.selfieError.value,
|
||||
isUploading: controller.isUploadingSelfie.value,
|
||||
isVerifying: controller.isVerifyingFace.value,
|
||||
isConfirmed: controller.hasConfirmedSelfie.value,
|
||||
onTapToSelect: () => _captureSelfie(controller),
|
||||
onClear: controller.clearSelfieImage,
|
||||
onValidate: controller.validateSelfieImage,
|
||||
placeholderIcon: Icons.face,
|
||||
isSuccess: controller.isLivenessCheckPassed.value,
|
||||
),
|
||||
),
|
||||
|
||||
// Verification Status for Selfie
|
||||
Obx(
|
||||
() =>
|
||||
|
@ -113,7 +186,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
|||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// Face match with ID card indicator - Updated with TipsContainer style
|
||||
// Face match with ID card indicator
|
||||
Obx(() {
|
||||
if (controller.selfieImage.value != null &&
|
||||
controller.isSelfieValid.value) {
|
||||
|
@ -243,7 +316,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: TSizes.xs),
|
||||
Text(
|
||||
'Make sure your face is well-lit and clearly visible',
|
||||
'We need to verify that it\'s really you by performing a liveness check',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
|
@ -257,13 +330,14 @@ class SelfieVerificationStep extends StatelessWidget {
|
|||
|
||||
Widget _buildSelfieTips() {
|
||||
return TipsContainer(
|
||||
title: 'Tips for a Good Selfie:',
|
||||
title: 'Tips for Liveness Detection:',
|
||||
tips: [
|
||||
'Find a well-lit area with even lighting',
|
||||
'Hold the camera at eye level',
|
||||
'Look directly at the camera',
|
||||
'Ensure your entire face is visible',
|
||||
'Remove glasses and face coverings',
|
||||
'Look directly at the camera',
|
||||
'Follow the on-screen instructions',
|
||||
'Rotate your head slowly when prompted',
|
||||
'Keep your face within the frame'
|
||||
],
|
||||
backgroundColor: TColors.primary.withOpacity(0.1),
|
||||
textColor: TColors.primary,
|
|
@ -1,271 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||
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/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class ImageVerificationStep extends StatelessWidget {
|
||||
const ImageVerificationStep({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Initialize the form key
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final controller = Get.find<ImageVerificationController>();
|
||||
final mainController = Get.find<FormRegistrationController>();
|
||||
mainController.formKey = formKey;
|
||||
|
||||
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const FormSectionHeader(
|
||||
title: 'Identity Document Verification',
|
||||
subtitle: 'Please upload your identity documents for verification',
|
||||
),
|
||||
|
||||
// ID Card Upload Section
|
||||
_buildSectionHeader(
|
||||
title: '$idCardType Upload',
|
||||
subtitle: 'Upload a clear image of your $idCardType',
|
||||
additionalText:
|
||||
'Make sure all text and your photo are clearly visible',
|
||||
),
|
||||
|
||||
// ID Card Upload Widget
|
||||
Obx(
|
||||
() => ImageUploader(
|
||||
image: controller.idCardImage.value,
|
||||
title: 'Upload $idCardType Image',
|
||||
subtitle: 'Tap to select an image',
|
||||
errorMessage: controller.idCardError.value,
|
||||
isUploading: controller.isUploadingIdCard.value,
|
||||
isVerifying: controller.isVerifying.value,
|
||||
isConfirmed: controller.hasConfirmedIdCard.value,
|
||||
onTapToSelect:
|
||||
() => _showImageSourceDialog(controller, true, idCardType),
|
||||
onClear: controller.clearIdCardImage,
|
||||
onValidate: controller.validateIdCardImage,
|
||||
placeholderIcon: Icons.add_a_photo,
|
||||
),
|
||||
),
|
||||
|
||||
// ID Card Verification Status
|
||||
Obx(
|
||||
() => VerificationStatus(
|
||||
isVerifying:
|
||||
controller.isVerifying.value &&
|
||||
!controller.isUploadingIdCard.value,
|
||||
verifyingMessage: 'Validating your ID card...',
|
||||
),
|
||||
),
|
||||
|
||||
// ID Card Verification Message
|
||||
Obx(
|
||||
() =>
|
||||
controller.idCardValidationMessage.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: TSizes.spaceBtwItems,
|
||||
),
|
||||
child: ValidationMessageCard(
|
||||
message: controller.idCardValidationMessage.value,
|
||||
isValid: controller.isIdCardValid.value,
|
||||
hasConfirmed: controller.hasConfirmedIdCard.value,
|
||||
onConfirm: controller.confirmIdCardImage,
|
||||
onTryAnother: controller.clearIdCardImage,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// ID Card Tips
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
_buildIdCardTips(idCardType),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwSections),
|
||||
|
||||
// Selfie Upload Section
|
||||
_buildSectionHeader(
|
||||
title: 'Selfie Upload',
|
||||
subtitle: 'Take a clear selfie for identity verification',
|
||||
additionalText:
|
||||
'Make sure your face is well-lit and clearly visible',
|
||||
),
|
||||
|
||||
// Selfie Upload Widget
|
||||
Obx(
|
||||
() => ImageUploader(
|
||||
image: controller.selfieImage.value,
|
||||
title: 'Take a Selfie',
|
||||
subtitle: 'Tap to open camera',
|
||||
errorMessage: controller.selfieError.value,
|
||||
isUploading: controller.isUploadingSelfie.value,
|
||||
isVerifying: controller.isVerifyingFace.value,
|
||||
isConfirmed: controller.hasConfirmedSelfie.value,
|
||||
onTapToSelect:
|
||||
() => controller.pickSelfieImage(ImageSource.camera),
|
||||
onClear: controller.clearSelfieImage,
|
||||
onValidate: controller.validateSelfieImage,
|
||||
placeholderIcon: Icons.face,
|
||||
),
|
||||
),
|
||||
|
||||
// Selfie Verification Status
|
||||
Obx(
|
||||
() => VerificationStatus(
|
||||
isVerifying:
|
||||
controller.isVerifyingFace.value &&
|
||||
!controller.isUploadingSelfie.value,
|
||||
verifyingMessage: 'Validating your selfie...',
|
||||
),
|
||||
),
|
||||
|
||||
// Selfie Verification Message
|
||||
Obx(
|
||||
() =>
|
||||
controller.selfieValidationMessage.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: TSizes.spaceBtwItems,
|
||||
),
|
||||
child: ValidationMessageCard(
|
||||
message: controller.selfieValidationMessage.value,
|
||||
isValid: controller.isSelfieValid.value,
|
||||
hasConfirmed: controller.hasConfirmedSelfie.value,
|
||||
onConfirm: controller.confirmSelfieImage,
|
||||
onTryAnother: controller.clearSelfieImage,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// Selfie Tips
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
_buildSelfieTips(),
|
||||
|
||||
// Error Messages
|
||||
Obx(() {
|
||||
if (controller.idCardError.value.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||
child: Text(
|
||||
controller.idCardError.value,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (controller.selfieError.value.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||
child: Text(
|
||||
controller.selfieError.value,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required String additionalText,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeMd,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeSm,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.xs),
|
||||
Text(
|
||||
additionalText,
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeXs,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIdCardTips(String idCardType) {
|
||||
return TipsContainer(
|
||||
title: "Tips for a good $idCardType photo:",
|
||||
tips: [
|
||||
"Place the card on a dark, non-reflective surface",
|
||||
"Ensure all four corners are visible",
|
||||
"Make sure there's good lighting to avoid shadows",
|
||||
"Your photo and all text should be clearly visible",
|
||||
"Avoid using flash to prevent glare",
|
||||
],
|
||||
backgroundColor: Colors.blue,
|
||||
textColor: Colors.blue.shade800,
|
||||
iconColor: Colors.blue,
|
||||
borderColor: Colors.blue,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelfieTips() {
|
||||
return TipsContainer(
|
||||
title: "Tips for a good selfie:",
|
||||
tips: [
|
||||
"Find a well-lit area with even lighting",
|
||||
"Hold the camera at eye level",
|
||||
"Look directly at the camera",
|
||||
"Ensure your entire face is visible",
|
||||
"Remove glasses and face coverings",
|
||||
],
|
||||
backgroundColor: TColors.primary,
|
||||
textColor: TColors.primary,
|
||||
iconColor: TColors.primary,
|
||||
borderColor: TColors.primary,
|
||||
);
|
||||
}
|
||||
|
||||
void _showImageSourceDialog(
|
||||
ImageVerificationController controller,
|
||||
bool isIdCard,
|
||||
String idCardType,
|
||||
) {
|
||||
ImageSourceDialog.show(
|
||||
title: 'Select $idCardType Image Source',
|
||||
message:
|
||||
'Please ensure your ID card is clear, well-lit, and all text is readable',
|
||||
onSourceSelected: controller.pickIdCardImage,
|
||||
galleryOption: isIdCard, // Only allow gallery for ID card
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/location_selection_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/location_selection_controller.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/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/selfie-verification/facial_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/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/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/date_picker_field.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
||||
|
@ -21,15 +20,6 @@ class IdInfoForm extends StatelessWidget {
|
|||
required this.isOfficer,
|
||||
});
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// Ensure data is pre-filled if available
|
||||
if (controller.extractedIdCardNumber?.isNotEmpty == true ||
|
||||
controller.extractedName?.isNotEmpty == true) {
|
||||
controller.prefillExtractedData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
|
@ -39,7 +29,7 @@ class IdInfoForm extends StatelessWidget {
|
|||
if (!isOfficer) ...[
|
||||
// ID Confirmation banner if we have extracted data
|
||||
_buildExtractedDataConfirmation(context),
|
||||
|
||||
|
||||
// NIK field for non-officers
|
||||
_buildNikField(),
|
||||
|
||||
|
@ -92,9 +82,6 @@ class IdInfoForm extends StatelessWidget {
|
|||
VerificationStatusMessage(controller: controller),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// Data verification button
|
||||
VerificationActionButton(controller: controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -202,9 +189,14 @@ class IdInfoForm extends StatelessWidget {
|
|||
keyboardType: TextInputType.number,
|
||||
hintText: 'e.g., 1234567890123456',
|
||||
onChanged: (value) {
|
||||
controller.nikController.text = value;
|
||||
controller.nikError.value = '';
|
||||
},
|
||||
// Invert the meaning - we want enabled=false when isNikReadOnly=true
|
||||
enabled: !controller.isNikReadOnly.value,
|
||||
fillColor:
|
||||
controller.isNikReadOnly.value
|
||||
? Colors.grey.shade200
|
||||
: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -220,8 +212,8 @@ class IdInfoForm extends StatelessWidget {
|
|||
errorText: controller.fullNameError.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
hintText: 'Enter your full name as on KTP',
|
||||
// Remove initialValue to fix the assertion error
|
||||
onChanged: (value) {
|
||||
controller.fullNameController.text = value;
|
||||
controller.fullNameError.value = '';
|
||||
},
|
||||
),
|
||||
|
@ -241,7 +233,6 @@ class IdInfoForm extends StatelessWidget {
|
|||
hintText: 'Enter your address as on KTP',
|
||||
maxLines: 3,
|
||||
onChanged: (value) {
|
||||
controller.addressController.text = value;
|
||||
controller.addressError.value = '';
|
||||
},
|
||||
),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/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/pages/registration-form/widgets/city_selection.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/buttons/custom_elevated_button.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class VerificationActionButton extends StatelessWidget {
|
||||
|
@ -10,28 +11,81 @@ class VerificationActionButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(
|
||||
() => ElevatedButton.icon(
|
||||
onPressed:
|
||||
controller.isVerifying.value
|
||||
? null
|
||||
: () => controller.verifyIdCardWithOCR(),
|
||||
icon: const Icon(Icons.verified_user),
|
||||
label: Text(
|
||||
controller.isVerified.value
|
||||
? 'Re-verify Personal Information'
|
||||
: 'Verify Personal Information',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, TSizes.buttonHeight),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
return Column(
|
||||
children: [
|
||||
// Verify data button
|
||||
Obx(() {
|
||||
if (controller.isVerifying.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return CustomElevatedButton(
|
||||
text: 'Verify Information',
|
||||
onPressed: () => controller.verifyIdCardWithOCR(),
|
||||
icon: Icons.check_circle_outline,
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// Save data button - only show when verification is complete
|
||||
Obx(() {
|
||||
if (!controller.isVerified.value) return const SizedBox.shrink();
|
||||
|
||||
if (controller.isSavingData.value) {
|
||||
return Column(
|
||||
children: [
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
controller.dataSaveMessage.value,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (controller.isDataSaved.value)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: TSizes.sm),
|
||||
padding: const EdgeInsets.all(TSizes.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
size: TSizes.iconSm,
|
||||
),
|
||||
const SizedBox(width: TSizes.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.dataSaveMessage.value,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.green),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!controller.isDataSaved.value)
|
||||
CustomElevatedButton(
|
||||
text: 'Save Registration Data',
|
||||
onPressed: () => controller.saveRegistrationData(),
|
||||
icon: Icons.save_outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
|
|
|
@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/signin_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class SignInScreen extends StatelessWidget {
|
||||
|
@ -20,19 +19,24 @@ class SignInScreen extends StatelessWidget {
|
|||
// Init form key
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
// Get the controller
|
||||
final controller = Get.find<SignInController>();
|
||||
// Get the controller - use Get.put to ensure it's initialized
|
||||
final controller = Get.put(SignInController());
|
||||
|
||||
// Check if dark mode is enabled
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Set system overlay style
|
||||
// Set system overlay style based on theme
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarIconBrightness:
|
||||
isDarkMode ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: TColors.light,
|
||||
// Use dynamic background color from theme
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
|
@ -44,7 +48,7 @@ class SignInScreen extends StatelessWidget {
|
|||
children: [
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Header
|
||||
// Header - pass isDarkMode to AuthHeader if needed
|
||||
const AuthHeader(
|
||||
title: 'Welcome Back',
|
||||
subtitle: 'Sign in to your account to continue',
|
||||
|
@ -82,7 +86,7 @@ class SignInScreen extends StatelessWidget {
|
|||
child: Text(
|
||||
'Forgot Password?',
|
||||
style: TextStyle(
|
||||
color: TColors.primary,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
@ -95,7 +99,7 @@ class SignInScreen extends StatelessWidget {
|
|||
Obx(
|
||||
() => AuthButton(
|
||||
text: 'Sign In',
|
||||
onPressed: () => controller.credentialsSignIn(formKey),
|
||||
onPressed: () => controller.signIn(formKey),
|
||||
isLoading: controller.isLoading.value,
|
||||
),
|
||||
),
|
||||
|
@ -112,10 +116,10 @@ class SignInScreen extends StatelessWidget {
|
|||
text: 'Continue with Google',
|
||||
icon: Icon(
|
||||
TablerIcons.brand_google,
|
||||
color: TColors.light,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => controller.googleSignIn(formKey),
|
||||
onPressed: () => controller.googleSignIn(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
@ -126,14 +130,15 @@ class SignInScreen extends StatelessWidget {
|
|||
children: [
|
||||
Text(
|
||||
'Don\'t have an account?',
|
||||
style: TextStyle(color: TColors.textSecondary),
|
||||
// Use theme color for text
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.goToSignUp,
|
||||
child: Text(
|
||||
'Sign Up',
|
||||
style: TextStyle(
|
||||
color: TColors.primary,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,263 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/signup_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
|
||||
class SignUpScreen extends StatelessWidget {
|
||||
const SignUpScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Get the controller
|
||||
final controller = Get.find<SignUpController>();
|
||||
|
||||
// Set system overlay style
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: TColors.light,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'Create Account',
|
||||
style: TextStyle(
|
||||
color: TColors.textPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
|
||||
onPressed: () => Get.back(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: controller.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with profile summary
|
||||
Obx(() => _buildProfileSummary(controller)),
|
||||
|
||||
// Credentials section header
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Set Your Login Credentials',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Create your login information to secure your account',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email field
|
||||
Obx(
|
||||
() => CustomTextField(
|
||||
label: 'Email',
|
||||
controller: controller.emailController,
|
||||
validator: controller.validateEmail,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
errorText: controller.emailError.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
|
||||
// Password field
|
||||
Obx(
|
||||
() => PasswordField(
|
||||
label: 'Password',
|
||||
controller: controller.passwordController,
|
||||
validator: controller.validatePassword,
|
||||
isVisible: controller.isPasswordVisible,
|
||||
errorText: controller.passwordError.value,
|
||||
onToggleVisibility: controller.togglePasswordVisibility,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
|
||||
// Confirm password field
|
||||
Obx(
|
||||
() => PasswordField(
|
||||
label: 'Confirm Password',
|
||||
controller: controller.confirmPasswordController,
|
||||
validator: controller.validateConfirmPassword,
|
||||
isVisible: controller.isConfirmPasswordVisible,
|
||||
errorText: controller.confirmPasswordError.value,
|
||||
onToggleVisibility:
|
||||
controller.toggleConfirmPasswordVisibility,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Privacy policy checkbox
|
||||
Row(
|
||||
children: [
|
||||
Obx(
|
||||
() => Checkbox(
|
||||
value: controller.privacyPolicy.value,
|
||||
onChanged:
|
||||
(value) =>
|
||||
controller.privacyPolicy.value = value!,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'I agree to the Terms of Service and Privacy Policy',
|
||||
style: TextStyle(color: TColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Sign up button
|
||||
Obx(
|
||||
() => AuthButton(
|
||||
text: 'Create Account',
|
||||
onPressed: controller.signUp,
|
||||
isLoading: controller.isLoading.value,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Already have an account
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Already have an account?',
|
||||
style: TextStyle(color: TColors.textSecondary),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.goToSignIn,
|
||||
child: Text(
|
||||
'Sign In',
|
||||
style: TextStyle(
|
||||
color: TColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileSummary(SignUpController controller) {
|
||||
if (controller.userMetadata.value == null) {
|
||||
return Container(); // Return empty container if no metadata
|
||||
}
|
||||
|
||||
final metadata = controller.userMetadata.value!;
|
||||
final isOfficer = metadata.isOfficer;
|
||||
final name = metadata.name ?? 'User';
|
||||
final identifier =
|
||||
isOfficer
|
||||
? 'NRP: ${metadata.officerData?.nrp ?? 'N/A'}'
|
||||
: 'NIK: ${metadata.nik ?? 'N/A'}';
|
||||
final role = isOfficer ? 'Officer' : 'Viewer';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: TColors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Profile Summary',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: TColors.primary,
|
||||
radius: 24,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : 'U',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
identifier,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Role: $role',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/signup_with_role_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class AuthButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onPressed;
|
||||
final bool isLoading;
|
||||
final bool isPrimary;
|
||||
final bool isDisabled;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
|
||||
|
@ -16,47 +13,42 @@ class AuthButton extends StatelessWidget {
|
|||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.isPrimary = true,
|
||||
this.isDisabled = false,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveBackgroundColor =
|
||||
backgroundColor ?? (isPrimary ? TColors.primary : Colors.grey.shade200);
|
||||
final effectiveTextColor =
|
||||
textColor ?? (isPrimary ? Colors.white : TColors.textPrimary);
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: (isLoading || isDisabled) ? null : onPressed,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: effectiveBackgroundColor,
|
||||
foregroundColor: effectiveTextColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: TSizes.md),
|
||||
backgroundColor: backgroundColor ?? Theme.of(context).primaryColor,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||
),
|
||||
elevation: 1,
|
||||
disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.5),
|
||||
),
|
||||
child:
|
||||
isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
effectiveTextColor,
|
||||
),
|
||||
strokeWidth: 2.5,
|
||||
color: textColor ?? Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(text,
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class PanicButtonController extends GetxController {
|
||||
static PanicButtonController get instance => Get.find();
|
||||
|
||||
// Observable variables
|
||||
final RxBool isPanicActive = false.obs;
|
||||
final RxString locationString = "Indonesia · 125.161.172.145".obs;
|
||||
final RxInt elapsedSeconds = 0.obs;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// Simulate fetching location
|
||||
_fetchLocation();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_timer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// Toggle panic mode on/off
|
||||
void togglePanicMode() {
|
||||
if (isPanicActive.value) {
|
||||
// If currently active, show confirmation dialog
|
||||
Get.dialog(
|
||||
AlertDialog(
|
||||
title: const Text('Cancel Emergency Alert?'),
|
||||
content: const Text(
|
||||
'Are you sure you want to cancel the emergency alert?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Get.back(), child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
_deactivatePanicMode();
|
||||
},
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// If not active, show confirmation to activate
|
||||
Get.dialog(
|
||||
AlertDialog(
|
||||
title: const Text('Confirm Emergency Alert'),
|
||||
content: const Text(
|
||||
'Are you sure you want to send an emergency alert?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Get.back(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
_activatePanicMode();
|
||||
},
|
||||
child: const Text(
|
||||
'Send Alert',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _activatePanicMode() {
|
||||
isPanicActive.value = true;
|
||||
elapsedSeconds.value = 0;
|
||||
_startTimer();
|
||||
|
||||
// Show toast notification
|
||||
Get.snackbar(
|
||||
'Emergency Alert Active',
|
||||
'Help has been notified and is on the way',
|
||||
backgroundColor: Colors.red,
|
||||
colorText: Colors.white,
|
||||
icon: const Icon(Icons.warning_amber_rounded, color: Colors.white),
|
||||
snackPosition: SnackPosition.TOP,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
// TODO: Implement actual emergency services notification
|
||||
}
|
||||
|
||||
void _deactivatePanicMode() {
|
||||
isPanicActive.value = false;
|
||||
_timer?.cancel();
|
||||
|
||||
// Show toast notification
|
||||
Get.snackbar(
|
||||
'Emergency Alert Cancelled',
|
||||
'Your emergency alert has been cancelled',
|
||||
backgroundColor: Colors.green,
|
||||
colorText: Colors.white,
|
||||
icon: const Icon(Icons.check_circle, color: Colors.white),
|
||||
snackPosition: SnackPosition.TOP,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
// TODO: Implement actual emergency services cancellation
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
elapsedSeconds.value++;
|
||||
});
|
||||
}
|
||||
|
||||
void _fetchLocation() {
|
||||
// TODO: Implement actual location fetching
|
||||
// This is just a placeholder that simulates location fetch
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
locationString.value = "Jakarta, Indonesia · 125.161.172.145";
|
||||
});
|
||||
}
|
||||
|
||||
// Format elapsed time as string
|
||||
String get elapsedTimeString {
|
||||
final seconds = elapsedSeconds.value;
|
||||
if (seconds < 60) {
|
||||
return '$seconds seconds ago';
|
||||
}
|
||||
final minutes = seconds ~/ 60;
|
||||
if (minutes < 60) {
|
||||
return '$minutes minutes ago';
|
||||
}
|
||||
final hours = minutes ~/ 60;
|
||||
return '$hours hours ago';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,268 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/panic/controllers/panic_button_controller.dart';
|
||||
|
||||
class PanicButtonPage extends StatefulWidget {
|
||||
const PanicButtonPage({super.key});
|
||||
|
||||
@override
|
||||
State<PanicButtonPage> createState() => _PanicButtonPageState();
|
||||
}
|
||||
|
||||
class _PanicButtonPageState extends State<PanicButtonPage> {
|
||||
final controller = Get.put(PanicButtonController());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF0C1323), Color(0xFF223142)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAppBar(),
|
||||
const Spacer(),
|
||||
_buildStatusIndicator(),
|
||||
const SizedBox(height: 100),
|
||||
_buildPanicButton(),
|
||||
const Spacer(),
|
||||
_buildLocationInfo(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Obx(
|
||||
() => Text(
|
||||
controller.isPanicActive.value ? "SOS ACTIVE" : "Emergency Alert",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(Icons.more_vert, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIndicator() {
|
||||
return Obx(() {
|
||||
if (controller.isPanicActive.value) {
|
||||
// Active state - show pulsating effect
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 180,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red.withOpacity(0.4),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.white,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Help is on the way",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Signal sent ${controller.elapsedTimeString}",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Inactive state - ready to activate
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.teal.shade300, Colors.teal.shade600],
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.shield_outlined,
|
||||
color: Colors.white,
|
||||
size: 60,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"You are unprotected",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Tap the button below in case of emergency",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildPanicButton() {
|
||||
return Obx(() {
|
||||
final isPanicActive = controller.isPanicActive.value;
|
||||
return GestureDetector(
|
||||
onTap: () => controller.togglePanicMode(),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: isPanicActive ? Colors.white : Colors.red,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (isPanicActive ? Colors.white : Colors.red).withOpacity(
|
||||
0.5,
|
||||
),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
isPanicActive ? "CANCEL SOS" : "SOS",
|
||||
style: TextStyle(
|
||||
color: isPanicActive ? Colors.red : Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLocationInfo() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.location_on, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Your current location",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Obx(
|
||||
() => Text(
|
||||
controller.locationString.value,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: Colors.white.withOpacity(0.7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class CustomElevatedButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onPressed;
|
||||
final bool isLoading;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final IconData? icon;
|
||||
final double? width;
|
||||
final double height;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const CustomElevatedButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.icon,
|
||||
this.width,
|
||||
this.height = 50,
|
||||
this.padding = const EdgeInsets.symmetric(
|
||||
vertical: TSizes.buttonHeight / 2.5,
|
||||
),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? theme.primaryColor,
|
||||
foregroundColor: foregroundColor ?? Colors.white,
|
||||
padding: padding,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||
),
|
||||
),
|
||||
child:
|
||||
isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: TSizes.xs),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: foregroundColor ?? Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ class ImageUploader extends StatelessWidget {
|
|||
final bool isUploading;
|
||||
final bool isVerifying;
|
||||
final bool isConfirmed;
|
||||
final bool isSuccess;
|
||||
final VoidCallback onTapToSelect;
|
||||
final VoidCallback? onClear;
|
||||
final VoidCallback? onValidate;
|
||||
|
@ -30,6 +31,7 @@ class ImageUploader extends StatelessWidget {
|
|||
required this.isUploading,
|
||||
required this.isVerifying,
|
||||
required this.isConfirmed,
|
||||
this.isSuccess = false,
|
||||
required this.onTapToSelect,
|
||||
this.onClear,
|
||||
this.onValidate,
|
||||
|
@ -41,18 +43,22 @@ class ImageUploader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Background color based on error state or confirmation
|
||||
// Background color based on error state, success or confirmation
|
||||
final backgroundColor =
|
||||
errorMessage != null && errorMessage!.isNotEmpty
|
||||
? TColors.error.withOpacity(0.1)
|
||||
: isSuccess
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: isConfirmed
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.1);
|
||||
|
||||
// Determine border color based on error state or confirmation
|
||||
// Determine border color based on error state, success or confirmation
|
||||
final borderColor =
|
||||
errorMessage != null && errorMessage!.isNotEmpty
|
||||
? TColors.error
|
||||
: isSuccess
|
||||
? Colors.green
|
||||
: isConfirmed
|
||||
? Colors.green
|
||||
: Colors.grey.withOpacity(0.5);
|
||||
|
@ -63,7 +69,7 @@ class ImageUploader extends StatelessWidget {
|
|||
if (image == null)
|
||||
_buildEmptyUploader(backgroundColor, borderColor)
|
||||
else
|
||||
_buildImagePreview(borderColor),
|
||||
_buildImagePreview(borderColor, context),
|
||||
|
||||
// Show file size information if image is uploaded
|
||||
if (image != null)
|
||||
|
@ -167,7 +173,7 @@ class ImageUploader extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePreview(Color borderColor) {
|
||||
Widget _buildImagePreview(Color borderColor, BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -177,22 +183,49 @@ class ImageUploader extends StatelessWidget {
|
|||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||
border: Border.all(color: borderColor, width: 2),
|
||||
border: Border.all(
|
||||
color: isSuccess ? Colors.green : borderColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
TSizes.borderRadiusMd - 2,
|
||||
),
|
||||
child: Image.file(
|
||||
File(image!.path),
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
// Make the image tappable for preview
|
||||
GestureDetector(
|
||||
onTap: () => _showImagePreview(context),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
TSizes.borderRadiusMd - 2,
|
||||
),
|
||||
child: Image.file(
|
||||
File(image!.path),
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Success indicator (checkmark)
|
||||
if (isSuccess && !isUploading && !isVerifying)
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: TSizes.iconSm,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Error overlay
|
||||
if (errorMessage != null &&
|
||||
errorMessage!.isNotEmpty &&
|
||||
|
@ -253,6 +286,86 @@ class ImageUploader extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
// Method to show full-screen image preview
|
||||
void _showImagePreview(BuildContext context) {
|
||||
if (image == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Dialog(
|
||||
insetPadding: EdgeInsets.zero,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Image with interactive viewer for zooming
|
||||
InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 3.0,
|
||||
child: Image.file(
|
||||
File(image!.path),
|
||||
fit: BoxFit.contain,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: MediaQuery.of(context).size.height,
|
||||
),
|
||||
),
|
||||
|
||||
// Close button at the top
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Image info at the bottom
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isSuccess ? Icons.check_circle : Icons.image,
|
||||
color: isSuccess ? Colors.green : Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultErrorOverlay() {
|
||||
return Container(
|
||||
height: 200,
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/navigation_menu.dart';
|
||||
|
||||
class CustomBottomNavigationBar extends StatelessWidget {
|
||||
const CustomBottomNavigationBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = NavigationController.instance;
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Obx(
|
||||
() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildNavItem(
|
||||
context,
|
||||
"Home",
|
||||
Icons.home,
|
||||
0,
|
||||
controller.selectedIndex.value == 0,
|
||||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
"Search",
|
||||
Icons.search,
|
||||
1,
|
||||
controller.selectedIndex.value == 1,
|
||||
),
|
||||
_buildPanicButton(context),
|
||||
_buildNavItem(
|
||||
context,
|
||||
"History",
|
||||
Icons.history,
|
||||
3,
|
||||
controller.selectedIndex.value == 3,
|
||||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
"Profile",
|
||||
Icons.person_outline,
|
||||
4,
|
||||
controller.selectedIndex.value == 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
BuildContext context,
|
||||
String label,
|
||||
IconData icon,
|
||||
int index,
|
||||
bool isSelected,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () => NavigationController.instance.changeIndex(index),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected ? const Color(0xFF5E39F1) : Colors.grey,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? const Color(0xFF5E39F1) : Colors.grey,
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanicButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => NavigationController.instance.changeIndex(2),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF5E39F1),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF5E39F1).withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.qr_code_scanner, color: Colors.white, size: 30),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,8 @@ import 'package:sigap/src/utils/constants/sizes.dart';
|
|||
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final TextEditingController? controller;
|
||||
final String? initialValue;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
|
@ -13,14 +14,17 @@ class CustomTextField extends StatelessWidget {
|
|||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool? enabled;
|
||||
final bool readOnly;
|
||||
final bool obscureText;
|
||||
final void Function(String)? onChanged;
|
||||
final Color? accentColor;
|
||||
final Color? fillColor;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.controller,
|
||||
this.initialValue,
|
||||
this.validator,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
|
@ -30,10 +34,16 @@ class CustomTextField extends StatelessWidget {
|
|||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.obscureText = false,
|
||||
this.onChanged,
|
||||
this.accentColor,
|
||||
});
|
||||
this.fillColor,
|
||||
}) : assert(
|
||||
// Fix the assertion to avoid duplicate conditions
|
||||
controller == null || initialValue == null,
|
||||
'Either provide a controller or an initialValue, not both',
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -42,6 +52,22 @@ class CustomTextField extends StatelessWidget {
|
|||
accentColor ?? Theme.of(context).primaryColor;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Determine the effective fill color
|
||||
final Color effectiveFillColor =
|
||||
fillColor ??
|
||||
(isDark
|
||||
? Theme.of(context).cardColor
|
||||
: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
Colors.grey[100]!);
|
||||
|
||||
// Get the common input decoration for both cases
|
||||
final inputDecoration = _getInputDecoration(
|
||||
context,
|
||||
effectiveAccentColor,
|
||||
isDark,
|
||||
effectiveFillColor,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -55,77 +81,88 @@ class CustomTextField extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
|
||||
// TextFormField with theme-aware styling
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
maxLines: maxLines,
|
||||
enabled: enabled,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
// Use the theme's text style
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
errorText:
|
||||
errorText != null && errorText!.isNotEmpty ? errorText : null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
// Use theme colors for filling based on brightness
|
||||
fillColor:
|
||||
isDark
|
||||
? Theme.of(context).cardColor
|
||||
: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
Colors.grey[100],
|
||||
|
||||
// Use theme-aware border styling
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(color: effectiveAccentColor, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
// Conditional rendering based on whether controller or initialValue is provided
|
||||
if (controller != null)
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
maxLines: maxLines,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
decoration: inputDecoration,
|
||||
)
|
||||
else
|
||||
TextFormField(
|
||||
initialValue: initialValue,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
maxLines: maxLines,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
decoration: inputDecoration,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwInputFields),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _getInputDecoration(
|
||||
BuildContext context,
|
||||
Color effectiveAccentColor,
|
||||
bool isDark,
|
||||
Color effectiveFillColor,
|
||||
) {
|
||||
return InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
errorText: errorText != null && errorText!.isNotEmpty ? errorText : null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: effectiveFillColor,
|
||||
// Use theme-aware border styling
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(color: effectiveAccentColor, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,10 +40,4 @@ class Endpoints {
|
|||
static String awsSecretKey = dotenv.env['AWS_SECRET_KEY'] ?? '';
|
||||
static String awsRekognitionEndpoint =
|
||||
'https://rekognition.$awsRegion.amazonaws.com';
|
||||
|
||||
// Supabase Edge Functions
|
||||
static String get detectFace =>
|
||||
'https://bhfzrlgxqkbkjepvqeva.supabase.co/functions/v1/detect-face';
|
||||
static String get verifyFace =>
|
||||
'https://bhfzrlgxqkbkjepvqeva.supabase.co/functions/v1/verify-face';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,4 +18,9 @@ class AppRoutes {
|
|||
static const String locationWarning = '/location-warning';
|
||||
static const String registrationForm = '/registration-form';
|
||||
static const String signupWithRole = '/signup-with-role';
|
||||
static const String navigationMenu = '/navigation-menu';
|
||||
static const String idCardVerification = '/id-card-verification';
|
||||
static const String selfieVerification = '/selfie-verification';
|
||||
static const String livenessDetection = '/liveness-detection';
|
||||
|
||||
}
|
||||
|
|
|
@ -105,6 +105,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
camera:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: camera
|
||||
sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
camera_android_camerax:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_android_camerax
|
||||
sha256: "9fb44e73e0fea3647a904dc26d38db24055e5b74fc68fd2b6d3abfa1bd20f536"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.17"
|
||||
camera_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_avfoundation
|
||||
sha256: ca36181194f429eef3b09de3c96280f2400693f9735025f90d1f4a27465fdd72
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.19"
|
||||
camera_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_platform_interface
|
||||
sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.10.0"
|
||||
camera_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_web
|
||||
sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5"
|
||||
carousel_slider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -653,6 +693,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_mlkit_commons:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_mlkit_commons
|
||||
sha256: "8f40fbac10685cad4715d11e6a0d86837d9ad7168684dfcad29610282a88e67a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
google_mlkit_face_detection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_face_detection
|
||||
sha256: f336737d5b8a86797fd4368f42a5c26aeaa9c6dcc5243f0a16b5f6f663cfb70a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
google_mlkit_face_mesh_detection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_face_mesh_detection
|
||||
sha256: "3683daed2463d9631c7f01b31bfc40d22a1fd4c0392d82a24ce275af06bc811f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
google_sign_in:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -1354,6 +1418,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -32,6 +32,7 @@ dependencies:
|
|||
calendar_date_picker2:
|
||||
easy_date_timeline:
|
||||
equatable: ^2.0.7
|
||||
camera:
|
||||
|
||||
# --- Logging & Debugging ---
|
||||
logger:
|
||||
|
@ -113,6 +114,10 @@ dependencies:
|
|||
# --- Fonts ---
|
||||
google_fonts:
|
||||
|
||||
# --- Machine Learning ---
|
||||
google_mlkit_face_detection: ^0.13.1
|
||||
google_mlkit_face_mesh_detection: ^0.4.1
|
||||
|
||||
# --- Localization ---
|
||||
# (add localization dependencies here if needed)
|
||||
|
||||
|
|
Loading…
Reference in New Issue