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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:get_storage/get_storage.dart';
|
import 'package:get_storage/get_storage.dart';
|
||||||
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||||
import 'package:sigap/app.dart';
|
import 'package:sigap/app.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||||
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
import 'navigation_menu.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
|
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
@ -43,3 +48,4 @@ Future<void> main() async {
|
||||||
|
|
||||||
runApp(const App());
|
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,14 +1,13 @@
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/cores/services/background_service.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/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/location_service.dart';
|
||||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||||
|
|
||||||
|
|
||||||
class ServiceBindings extends Bindings {
|
class ServiceBindings extends Bindings {
|
||||||
@override
|
@override
|
||||||
Future<void> dependencies() async {
|
Future<void> dependencies() async {
|
||||||
|
|
||||||
// Initialize background service
|
// Initialize background service
|
||||||
|
|
||||||
final locationService = await BackgroundService.instance
|
final locationService = await BackgroundService.instance
|
||||||
|
@ -20,8 +19,5 @@ class ServiceBindings extends Bindings {
|
||||||
await Get.putAsync(() => SupabaseService().init(), permanent: true);
|
await Get.putAsync(() => SupabaseService().init(), permanent: true);
|
||||||
await Get.putAsync(() => biometricService.init(), permanent: true);
|
await Get.putAsync(() => biometricService.init(), permanent: true);
|
||||||
await Get.putAsync(() => locationService.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: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/email-verification/email_verification_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/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/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/signup/signup_with_role_screen.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
|
||||||
|
@ -33,8 +36,6 @@ class AppPages {
|
||||||
|
|
||||||
GetPage(name: AppRoutes.signIn, page: () => const SignInScreen()),
|
GetPage(name: AppRoutes.signIn, page: () => const SignInScreen()),
|
||||||
|
|
||||||
GetPage(name: AppRoutes.signUp, page: () => const SignUpScreen()),
|
|
||||||
|
|
||||||
GetPage(
|
GetPage(
|
||||||
name: AppRoutes.signupWithRole,
|
name: AppRoutes.signupWithRole,
|
||||||
page: () => const SignupWithRoleScreen(),
|
page: () => const SignupWithRoleScreen(),
|
||||||
|
@ -50,11 +51,16 @@ class AppPages {
|
||||||
page: () => const ForgotPasswordScreen(),
|
page: () => const ForgotPasswordScreen(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
GetPage(
|
GetPage(
|
||||||
name: AppRoutes.locationWarning,
|
name: AppRoutes.locationWarning,
|
||||||
page: () => const LocationWarningScreen(),
|
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');
|
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
|
/// Compares two face images and returns a comparison result with retries
|
||||||
Future<FaceComparisonResult> compareFaces(
|
Future<FaceComparisonResult> compareFaces(
|
||||||
XFile sourceImage,
|
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
|
//when going to the background
|
||||||
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
||||||
notificationText:
|
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",
|
notificationTitle: "Running in Background",
|
||||||
enableWakeLock: true,
|
enableWakeLock: true,
|
||||||
),
|
),
|
||||||
|
|
|
@ -22,6 +22,33 @@ class FaceModel {
|
||||||
final String? gender;
|
final String? gender;
|
||||||
final double? genderConfidence;
|
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
|
/// Liveness detection data
|
||||||
final bool isLive;
|
final bool isLive;
|
||||||
final double livenessConfidence;
|
final double livenessConfidence;
|
||||||
|
@ -41,71 +68,226 @@ class FaceModel {
|
||||||
this.maxAge,
|
this.maxAge,
|
||||||
this.gender,
|
this.gender,
|
||||||
this.genderConfidence,
|
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.isLive = false,
|
||||||
this.livenessConfidence = 0.0,
|
this.livenessConfidence = 0.0,
|
||||||
this.attributes,
|
this.attributes,
|
||||||
this.message = '',
|
this.message = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Constructor from edge function response
|
/// Constructor from edge function response based on Amazon Rekognition format
|
||||||
factory FaceModel.fromEdgeFunction(
|
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,
|
XFile image,
|
||||||
Map<String, dynamic> faceData,
|
Map<String, dynamic> faceData,
|
||||||
) {
|
) {
|
||||||
// Extract faceId if available
|
// Extract bounding box
|
||||||
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
|
|
||||||
Map<String, double> boundingBox = {
|
Map<String, double> boundingBox = {
|
||||||
'x': 0.0,
|
'x': 0.0,
|
||||||
'y': 0.0,
|
'y': 0.0,
|
||||||
'width': 0.0,
|
'width': 0.0,
|
||||||
'height': 0.0,
|
'height': 0.0,
|
||||||
};
|
};
|
||||||
if (faceData['boundingBox'] != null || faceData['bounding_box'] != null) {
|
if (faceData['BoundingBox'] != null) {
|
||||||
final box = faceData['boundingBox'] ?? faceData['bounding_box'];
|
final box = faceData['BoundingBox'];
|
||||||
boundingBox = {
|
boundingBox = {
|
||||||
'x': (box['left'] ?? box['x'] ?? 0.0).toDouble(),
|
'x': (box['Left'] ?? 0.0).toDouble(),
|
||||||
'y': (box['top'] ?? box['y'] ?? 0.0).toDouble(),
|
'y': (box['Top'] ?? 0.0).toDouble(),
|
||||||
'width': (box['width'] ?? 0.0).toDouble(),
|
'width': (box['Width'] ?? 0.0).toDouble(),
|
||||||
'height': (box['height'] ?? 0.0).toDouble(),
|
'height': (box['Height'] ?? 0.0).toDouble(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract age information if available
|
// Extract age range
|
||||||
int? minAge;
|
int? minAge;
|
||||||
int? maxAge;
|
int? maxAge;
|
||||||
if (faceData['age'] != null) {
|
if (faceData['AgeRange'] != null) {
|
||||||
if (faceData['age'] is Map && faceData['age']['range'] != null) {
|
minAge =
|
||||||
minAge = faceData['age']['range']['low'];
|
faceData['AgeRange']['Low'] is num
|
||||||
maxAge = faceData['age']['range']['high'];
|
? (faceData['AgeRange']['Low'] as num).toInt()
|
||||||
} else if (faceData['age'] is num) {
|
: null;
|
||||||
// Single age value
|
maxAge =
|
||||||
final age = (faceData['age'] as num).toInt();
|
faceData['AgeRange']['High'] is num
|
||||||
minAge = age - 5;
|
? (faceData['AgeRange']['High'] as num).toInt()
|
||||||
maxAge = age + 5;
|
: null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract gender if available
|
// Extract gender
|
||||||
String? gender;
|
String? gender;
|
||||||
double? genderConfidence;
|
double? genderConfidence;
|
||||||
if (faceData['gender'] != null) {
|
if (faceData['Gender'] != null) {
|
||||||
if (faceData['gender'] is Map) {
|
gender = faceData['Gender']['Value'];
|
||||||
gender = faceData['gender']['value'];
|
genderConfidence =
|
||||||
genderConfidence = faceData['gender']['confidence'] / 100.0;
|
faceData['Gender']['Confidence'] is num
|
||||||
} else if (faceData['gender'] is String) {
|
? (faceData['Gender']['Confidence'] as num).toDouble() / 100.0
|
||||||
gender = faceData['gender'];
|
: null;
|
||||||
genderConfidence = 0.9; // Default confidence
|
}
|
||||||
}
|
|
||||||
|
// 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the face model
|
|
||||||
return FaceModel(
|
return FaceModel(
|
||||||
imagePath: image.path,
|
imagePath: image.path,
|
||||||
faceId: faceId,
|
faceId: faceId,
|
||||||
|
@ -115,7 +297,25 @@ class FaceModel {
|
||||||
maxAge: maxAge,
|
maxAge: maxAge,
|
||||||
gender: gender,
|
gender: gender,
|
||||||
genderConfidence: genderConfidence,
|
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,
|
attributes: faceData,
|
||||||
|
message: message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,6 +382,23 @@ class FaceModel {
|
||||||
'maxAge': maxAge,
|
'maxAge': maxAge,
|
||||||
'gender': gender,
|
'gender': gender,
|
||||||
'genderConfidence': genderConfidence,
|
'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,
|
'isLive': isLive,
|
||||||
'livenessConfidence': livenessConfidence,
|
'livenessConfidence': livenessConfidence,
|
||||||
'message': message,
|
'message': message,
|
||||||
|
@ -204,6 +421,15 @@ class FaceComparisonResult {
|
||||||
/// Confidence level of the match (0.0-1.0)
|
/// Confidence level of the match (0.0-1.0)
|
||||||
final double confidence;
|
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
|
/// Message describing the comparison result
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
|
@ -213,6 +439,9 @@ class FaceComparisonResult {
|
||||||
required this.targetFace,
|
required this.targetFace,
|
||||||
required this.isMatch,
|
required this.isMatch,
|
||||||
required this.confidence,
|
required this.confidence,
|
||||||
|
this.similarity = 0.0,
|
||||||
|
this.similarityThreshold = 0.0,
|
||||||
|
this.confidenceLevel,
|
||||||
required this.message,
|
required this.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -222,25 +451,52 @@ class FaceComparisonResult {
|
||||||
FaceModel targetFace,
|
FaceModel targetFace,
|
||||||
Map<String, dynamic> response,
|
Map<String, dynamic> response,
|
||||||
) {
|
) {
|
||||||
bool isMatch = response['isMatch'] ?? false;
|
// Check if the response is valid
|
||||||
double confidence = 0.0;
|
if (!response.containsKey('success') || response['success'] != true) {
|
||||||
|
return FaceComparisonResult.error(
|
||||||
if (response['confidence'] != null || response['similarity'] != null) {
|
sourceFace,
|
||||||
confidence =
|
targetFace,
|
||||||
((response['confidence'] ?? response['similarity']) ?? 0.0) / 100.0;
|
'Invalid or failed response',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String message =
|
// Extract match result
|
||||||
response['message'] ??
|
final bool isMatch = response['matched'] ?? false;
|
||||||
(isMatch
|
|
||||||
? 'Faces match with ${(confidence * 100).toStringAsFixed(1)}% confidence'
|
// Extract similarity and threshold
|
||||||
: 'Faces do not match');
|
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'];
|
||||||
|
|
||||||
|
// 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(
|
return FaceComparisonResult(
|
||||||
sourceFace: sourceFace,
|
sourceFace: sourceFace,
|
||||||
targetFace: targetFace,
|
targetFace: targetFace,
|
||||||
isMatch: isMatch,
|
isMatch: isMatch,
|
||||||
confidence: confidence,
|
confidence: confidence,
|
||||||
|
similarity: similarity,
|
||||||
|
similarityThreshold: similarityThreshold,
|
||||||
|
confidenceLevel: confidenceLevel,
|
||||||
message: message,
|
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/biometric_service.dart';
|
||||||
import 'package:sigap/src/cores/services/location_service.dart';
|
import 'package:sigap/src/cores/services/location_service.dart';
|
||||||
import 'package:sigap/src/cores/services/supabase_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/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/constants/app_routes.dart';
|
||||||
import 'package:sigap/src/utils/exceptions/exceptions.dart';
|
import 'package:sigap/src/utils/exceptions/exceptions.dart';
|
||||||
import 'package:sigap/src/utils/exceptions/format_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
|
// SESSION MANAGEMENT
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
import 'package:get/get.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/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/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/signin_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/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/signup_with_role_controller.dart';
|
||||||
|
|
||||||
class AuthControllerBindings extends Bindings {
|
class AuthControllerBindings extends Bindings {
|
||||||
@override
|
@override
|
||||||
void dependencies() {
|
void dependencies() {
|
||||||
// Register all feature auth controllers
|
// Register all feature auth controllers
|
||||||
Get.lazyPut(() => SignInController(), fenix: true);
|
Get.lazyPut(() => SignInController(), fenix: true);
|
||||||
Get.lazyPut(() => SignUpController(), fenix: true);
|
|
||||||
Get.lazyPut(() => SignupWithRoleController(), fenix: true);
|
Get.lazyPut(() => SignupWithRoleController(), fenix: true);
|
||||||
Get.lazyPut(() => FormRegistrationController(), fenix: true);
|
Get.lazyPut(() => FormRegistrationController(), fenix: true);
|
||||||
Get.lazyPut(() => EmailVerificationController(), 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:logger/logger.dart';
|
||||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||||
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.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/id-card-verification/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/identity-verification/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/officer-information/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/officer-information/unit_info_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
|
||||||
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
||||||
import 'package:sigap/src/features/personalization/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/models/models/user_metadata_model.dart';
|
||||||
import 'package:sigap/src/features/personalization/data/repositories/roles_repository.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/constants/num_int.dart';
|
||||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||||
|
|
||||||
|
@ -55,6 +54,11 @@ class FormRegistrationController extends GetxController {
|
||||||
// Loading state
|
// Loading state
|
||||||
final RxBool isLoading = false.obs;
|
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
|
// Data to be passed between steps
|
||||||
final Rx<dynamic> idCardData = Rx<dynamic>(null);
|
final Rx<dynamic> idCardData = Rx<dynamic>(null);
|
||||||
|
|
||||||
|
@ -460,63 +464,12 @@ class FormRegistrationController extends GetxController {
|
||||||
idCardVerificationController.hasConfirmedIdCard.value) {
|
idCardVerificationController.hasConfirmedIdCard.value) {
|
||||||
// Get the model from the controller
|
// Get the model from the controller
|
||||||
idCardData.value = idCardVerificationController.verifiedIdCardModel;
|
idCardData.value = idCardVerificationController.verifiedIdCardModel;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error passing ID card data: $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
|
// Go to next step - fixed implementation
|
||||||
void nextStep() {
|
void nextStep() {
|
||||||
// Special case for step 1 (ID Card step)
|
// Special case for step 1 (ID Card step)
|
||||||
|
@ -568,6 +521,7 @@ class FormRegistrationController extends GetxController {
|
||||||
|
|
||||||
// Proceed to next step
|
// Proceed to next step
|
||||||
if (currentStep.value < totalSteps - 1) {
|
if (currentStep.value < totalSteps - 1) {
|
||||||
|
// Fixed missing parenthesis
|
||||||
currentStep.value++;
|
currentStep.value++;
|
||||||
} else {
|
} else {
|
||||||
submitForm();
|
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
|
// Add this method to collect all form data
|
||||||
void collectAllFormData() {
|
void collectAllFormData() {
|
||||||
final isOfficerRole = selectedRole.value?.isOfficer ?? false;
|
final isOfficerRole = selectedRole.value?.isOfficer ?? false;
|
||||||
|
@ -771,4 +671,45 @@ class FormRegistrationController extends GetxController {
|
||||||
extractedName = idCardController.ktaModel.value?.name ?? '';
|
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,9 +1,10 @@
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.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/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/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/kta_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/data/models/ktp_model.dart';
|
||||||
|
@ -14,9 +15,6 @@ class IdCardVerificationController extends GetxController {
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
// Using FacialVerificationService instead of direct EdgeFunction
|
|
||||||
final FacialVerificationService _faceService =
|
|
||||||
FacialVerificationService.instance;
|
|
||||||
|
|
||||||
final bool isOfficer;
|
final bool isOfficer;
|
||||||
|
|
||||||
|
@ -25,6 +23,11 @@ class IdCardVerificationController extends GetxController {
|
||||||
|
|
||||||
IdCardVerificationController({required this.isOfficer});
|
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
|
// ID Card variables
|
||||||
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
|
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
|
||||||
final RxString idCardError = RxString('');
|
final RxString idCardError = RxString('');
|
||||||
|
@ -54,6 +57,51 @@ class IdCardVerificationController extends GetxController {
|
||||||
final RxString idCardFaceId = RxString('');
|
final RxString idCardFaceId = RxString('');
|
||||||
final RxBool hasFaceDetected = RxBool(false);
|
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() {
|
bool validate() {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
|
@ -131,7 +179,7 @@ class IdCardVerificationController extends GetxController {
|
||||||
ktpModel.value = null;
|
ktpModel.value = null;
|
||||||
ktaModel.value = null;
|
ktaModel.value = null;
|
||||||
|
|
||||||
// Reset face detection data
|
// Initialize face data with empty model (just to maintain compatibility)
|
||||||
idCardFace.value = FaceModel.empty();
|
idCardFace.value = FaceModel.empty();
|
||||||
idCardFaceId.value = '';
|
idCardFaceId.value = '';
|
||||||
hasFaceDetected.value = false;
|
hasFaceDetected.value = false;
|
||||||
|
@ -160,6 +208,12 @@ class IdCardVerificationController extends GetxController {
|
||||||
extractedInfo.assignAll(result);
|
extractedInfo.assignAll(result);
|
||||||
hasExtractedInfo.value = result.isNotEmpty;
|
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
|
// Check if the extracted information is valid using our validation methods
|
||||||
if (isOfficer) {
|
if (isOfficer) {
|
||||||
isImageValid = _ocrService.isKtaValid(result);
|
isImageValid = _ocrService.isKtaValid(result);
|
||||||
|
@ -174,44 +228,8 @@ class IdCardVerificationController extends GetxController {
|
||||||
ktpModel.value = _ocrService.createKtpModel(result);
|
ktpModel.value = _ocrService.createKtpModel(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect faces in the ID card image using FacialVerificationService
|
|
||||||
if (isImageValid) {
|
if (isImageValid) {
|
||||||
try {
|
// Instead of detecting faces here, we just save the image reference for later comparison
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
isIdCardValid.value = true;
|
isIdCardValid.value = true;
|
||||||
idCardValidationMessage.value =
|
idCardValidationMessage.value =
|
||||||
'$idCardType image looks valid. Please confirm this is your $idCardType.';
|
'$idCardType image looks valid. Please confirm this is your $idCardType.';
|
||||||
|
@ -265,7 +283,7 @@ class IdCardVerificationController extends GetxController {
|
||||||
bool get hasDetectedFace => idCardFace.value.hasValidFace;
|
bool get hasDetectedFace => idCardFace.value.hasValidFace;
|
||||||
|
|
||||||
// Clear ID Card Image
|
// Clear ID Card Image
|
||||||
void clearIdCardImage() {
|
void clearIdCardImage() async {
|
||||||
idCardImage.value = null;
|
idCardImage.value = null;
|
||||||
idCardError.value = '';
|
idCardError.value = '';
|
||||||
isIdCardValid.value = false;
|
isIdCardValid.value = false;
|
||||||
|
@ -278,12 +296,34 @@ class IdCardVerificationController extends GetxController {
|
||||||
idCardFace.value = FaceModel.empty();
|
idCardFace.value = FaceModel.empty();
|
||||||
idCardFaceId.value = '';
|
idCardFaceId.value = '';
|
||||||
hasFaceDetected.value = false;
|
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
|
// Confirm ID Card Image
|
||||||
void confirmIdCardImage() {
|
void confirmIdCardImage() {
|
||||||
if (isIdCardValid.value) {
|
if (isIdCardValid.value) {
|
||||||
hasConfirmedIdCard.value = true;
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.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/azure_ocr_service.dart';
|
||||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
|
||||||
// Remove AWS rekognition import completely
|
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/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/kta_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/data/models/ktp_model.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/data/services/registration_service.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/steps/selfie_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
|
||||||
|
|
||||||
class IdentityVerificationController extends GetxController {
|
class IdentityVerificationController extends GetxController {
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
@ -21,6 +25,11 @@ class IdentityVerificationController extends GetxController {
|
||||||
final FacialVerificationService _faceService =
|
final FacialVerificationService _faceService =
|
||||||
FacialVerificationService.instance;
|
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
|
// Controllers
|
||||||
final TextEditingController nikController = TextEditingController();
|
final TextEditingController nikController = TextEditingController();
|
||||||
final TextEditingController fullNameController = TextEditingController();
|
final TextEditingController fullNameController = TextEditingController();
|
||||||
|
@ -59,11 +68,22 @@ class IdentityVerificationController extends GetxController {
|
||||||
// Flag to prevent infinite loop
|
// Flag to prevent infinite loop
|
||||||
bool _isApplyingData = false;
|
bool _isApplyingData = false;
|
||||||
|
|
||||||
|
// NIK field readonly status
|
||||||
|
final RxBool isNikReadOnly = RxBool(false);
|
||||||
|
|
||||||
// Properties to store extracted ID card data
|
// Properties to store extracted ID card data
|
||||||
final String? extractedIdCardNumber;
|
final String? extractedIdCardNumber;
|
||||||
final String? extractedName;
|
final String? extractedName;
|
||||||
final RxBool isPreFilledNik = false.obs;
|
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({
|
IdentityVerificationController({
|
||||||
this.extractedIdCardNumber = '',
|
this.extractedIdCardNumber = '',
|
||||||
this.extractedName = '',
|
this.extractedName = '',
|
||||||
|
@ -76,11 +96,131 @@ class IdentityVerificationController extends GetxController {
|
||||||
// Make sure selectedGender has a default value
|
// Make sure selectedGender has a default value
|
||||||
selectedGender.value = selectedGender.value ?? 'Male';
|
selectedGender.value = selectedGender.value ?? 'Male';
|
||||||
|
|
||||||
// Try to apply ID card data after initialization
|
// Load OCR data from local storage with debug info
|
||||||
Future.microtask(() => _safeApplyIdCardData());
|
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() {
|
void _safeApplyIdCardData() {
|
||||||
if (_isApplyingData) return; // Guard against recursive calls
|
if (_isApplyingData) return; // Guard against recursive calls
|
||||||
|
|
||||||
|
@ -102,59 +242,10 @@ class IdentityVerificationController extends GetxController {
|
||||||
if (idCardData != null) {
|
if (idCardData != null) {
|
||||||
// Fill the form with the extracted data
|
// Fill the form with the extracted data
|
||||||
if (!isOfficer && idCardData is KtpModel) {
|
if (!isOfficer && idCardData is KtpModel) {
|
||||||
KtpModel ktpModel = idCardData;
|
applyKtpDataToForm(idCardData);
|
||||||
|
isNikReadOnly.value = true;
|
||||||
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';
|
|
||||||
} else if (isOfficer && idCardData is KtaModel) {
|
} else if (isOfficer && idCardData is KtaModel) {
|
||||||
KtaModel ktaModel = idCardData;
|
applyKtaDataToForm(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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -195,10 +286,10 @@ class IdentityVerificationController extends GetxController {
|
||||||
isFormValid.value = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addressController.text.isEmpty) {
|
// if (addressController.text.isEmpty) {
|
||||||
addressError.value = 'Address is required';
|
// addressError.value = 'Address is required';
|
||||||
isFormValid.value = false;
|
// isFormValid.value = false;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return isFormValid.value;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
@ -416,4 +507,38 @@ class IdentityVerificationController extends GetxController {
|
||||||
|
|
||||||
isPreFilledNik.value = true;
|
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/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_button.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/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_button.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.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:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/kta_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/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/basic/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/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_source_dialog.dart';
|
||||||
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.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/info/tips_container.dart';
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/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/id_info_form.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/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
@ -23,9 +23,11 @@ class IdentityVerificationStep extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const FormSectionHeader(
|
FormSectionHeader(
|
||||||
title: 'Additional Information',
|
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),
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.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/basic/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/viewer-information/personal_info_controller.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.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/shared/widgets/text/custom_text_field.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
@ -1,13 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/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/controllers/basic/registration_form_controller.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/basic/id_card_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/basic/identity_verification_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/officer/officer_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/basic/personal_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/unit_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/features/auth/presentasion/widgets/auth_button.dart';
|
||||||
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
@ -19,6 +20,8 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Ensure all dependencies are registered
|
||||||
|
|
||||||
final controller = Get.find<FormRegistrationController>();
|
final controller = Get.find<FormRegistrationController>();
|
||||||
final dark = THelperFunctions.isDarkMode(context);
|
final dark = THelperFunctions.isDarkMode(context);
|
||||||
|
|
||||||
|
@ -130,7 +133,6 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
child: AuthButton(
|
child: AuthButton(
|
||||||
text: 'Previous',
|
text: 'Previous',
|
||||||
onPressed: controller.previousStep,
|
onPressed: controller.previousStep,
|
||||||
isPrimary: false,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/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/steps/selfie_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/image_upload/image_uploader.dart';
|
||||||
import 'package:sigap/src/shared/widgets/info/tips_container.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/shared/widgets/verification/validation_message_card.dart';
|
||||||
|
@ -55,23 +55,96 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Selfie Upload Widget
|
// Liveness Detection Button
|
||||||
Obx(
|
Padding(
|
||||||
() => ImageUploader(
|
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
||||||
image: controller.selfieImage.value,
|
child: Obx(
|
||||||
title: 'Take a Selfie',
|
() => ElevatedButton.icon(
|
||||||
subtitle: 'Tap to take a selfie (max 4MB)',
|
onPressed:
|
||||||
errorMessage: controller.selfieError.value,
|
controller.isPerformingLivenessCheck.value
|
||||||
isUploading: controller.isUploadingSelfie.value,
|
? null
|
||||||
isVerifying: controller.isVerifyingFace.value,
|
: controller.performLivenessDetection,
|
||||||
isConfirmed: controller.hasConfirmedSelfie.value,
|
icon:
|
||||||
onTapToSelect: () => _captureSelfie(controller),
|
controller.isPerformingLivenessCheck.value
|
||||||
onClear: controller.clearSelfieImage,
|
? SizedBox(
|
||||||
onValidate: controller.validateSelfieImage,
|
width: 20,
|
||||||
placeholderIcon: Icons.face,
|
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
|
// Verification Status for Selfie
|
||||||
Obx(
|
Obx(
|
||||||
() =>
|
() =>
|
||||||
|
@ -113,7 +186,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Face match with ID card indicator - Updated with TipsContainer style
|
// Face match with ID card indicator
|
||||||
Obx(() {
|
Obx(() {
|
||||||
if (controller.selfieImage.value != null &&
|
if (controller.selfieImage.value != null &&
|
||||||
controller.isSelfieValid.value) {
|
controller.isSelfieValid.value) {
|
||||||
|
@ -243,7 +316,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.xs),
|
const SizedBox(height: TSizes.xs),
|
||||||
Text(
|
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(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.copyWith(
|
).textTheme.bodySmall?.copyWith(
|
||||||
|
@ -257,13 +330,14 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildSelfieTips() {
|
Widget _buildSelfieTips() {
|
||||||
return TipsContainer(
|
return TipsContainer(
|
||||||
title: 'Tips for a Good Selfie:',
|
title: 'Tips for Liveness Detection:',
|
||||||
tips: [
|
tips: [
|
||||||
'Find a well-lit area with even lighting',
|
'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',
|
'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),
|
backgroundColor: TColors.primary.withOpacity(0.1),
|
||||||
textColor: TColors.primary,
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.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/basic/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/officer-information/officer_info_controller.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.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/shared/widgets/text/custom_text_field.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_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/dropdown/custom_dropdown.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.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/shared/widgets/text/custom_text_field.dart';
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/administrative_division.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/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/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/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/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/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/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/date_picker_field.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
||||||
|
@ -21,15 +20,6 @@ class IdInfoForm extends StatelessWidget {
|
||||||
required this.isOfficer,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
|
@ -92,9 +82,6 @@ class IdInfoForm extends StatelessWidget {
|
||||||
VerificationStatusMessage(controller: controller),
|
VerificationStatusMessage(controller: controller),
|
||||||
|
|
||||||
const SizedBox(height: TSizes.spaceBtwItems),
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// Data verification button
|
|
||||||
VerificationActionButton(controller: controller),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -202,9 +189,14 @@ class IdInfoForm extends StatelessWidget {
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
hintText: 'e.g., 1234567890123456',
|
hintText: 'e.g., 1234567890123456',
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.nikController.text = value;
|
|
||||||
controller.nikError.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,
|
errorText: controller.fullNameError.value,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
hintText: 'Enter your full name as on KTP',
|
hintText: 'Enter your full name as on KTP',
|
||||||
|
// Remove initialValue to fix the assertion error
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.fullNameController.text = value;
|
|
||||||
controller.fullNameError.value = '';
|
controller.fullNameError.value = '';
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -241,7 +233,6 @@ class IdInfoForm extends StatelessWidget {
|
||||||
hintText: 'Enter your address as on KTP',
|
hintText: 'Enter your address as on KTP',
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.addressController.text = value;
|
|
||||||
controller.addressError.value = '';
|
controller.addressError.value = '';
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/administrative_division.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/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/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';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class VerificationActionButton extends StatelessWidget {
|
class VerificationActionButton extends StatelessWidget {
|
||||||
|
@ -10,28 +11,81 @@ class VerificationActionButton extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(
|
return Column(
|
||||||
() => ElevatedButton.icon(
|
children: [
|
||||||
onPressed:
|
// Verify data button
|
||||||
controller.isVerifying.value
|
Obx(() {
|
||||||
? null
|
if (controller.isVerifying.value) {
|
||||||
: () => controller.verifyIdCardWithOCR(),
|
return const Center(child: CircularProgressIndicator());
|
||||||
icon: const Icon(Icons.verified_user),
|
}
|
||||||
label: Text(
|
|
||||||
controller.isVerified.value
|
return CustomElevatedButton(
|
||||||
? 'Re-verify Personal Information'
|
text: 'Verify Information',
|
||||||
: 'Verify Personal Information',
|
onPressed: () => controller.verifyIdCardWithOCR(),
|
||||||
),
|
icon: Icons.check_circle_outline,
|
||||||
style: ElevatedButton.styleFrom(
|
);
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
}),
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size(double.infinity, TSizes.buttonHeight),
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
// Save data button - only show when verification is complete
|
||||||
),
|
Obx(() {
|
||||||
elevation: 0,
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.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/shared/widgets/verification/validation_message_card.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.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/services.dart';
|
||||||
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
|
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
|
||||||
import 'package:get/get.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_button.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.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/auth_header.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.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/features/auth/presentasion/widgets/social_button.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.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';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class SignInScreen extends StatelessWidget {
|
class SignInScreen extends StatelessWidget {
|
||||||
|
@ -20,19 +19,24 @@ class SignInScreen extends StatelessWidget {
|
||||||
// Init form key
|
// Init form key
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
// Get the controller
|
// Get the controller - use Get.put to ensure it's initialized
|
||||||
final controller = Get.find<SignInController>();
|
final controller = Get.put(SignInController());
|
||||||
|
|
||||||
// Set system overlay style
|
// Check if dark mode is enabled
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Set system overlay style based on theme
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: Brightness.dark,
|
statusBarIconBrightness:
|
||||||
|
isDarkMode ? Brightness.light : Brightness.dark,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: TColors.light,
|
// Use dynamic background color from theme
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
@ -44,7 +48,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Header
|
// Header - pass isDarkMode to AuthHeader if needed
|
||||||
const AuthHeader(
|
const AuthHeader(
|
||||||
title: 'Welcome Back',
|
title: 'Welcome Back',
|
||||||
subtitle: 'Sign in to your account to continue',
|
subtitle: 'Sign in to your account to continue',
|
||||||
|
@ -82,7 +86,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
'Forgot Password?',
|
'Forgot Password?',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TColors.primary,
|
color: Theme.of(context).primaryColor,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -95,7 +99,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
Obx(
|
Obx(
|
||||||
() => AuthButton(
|
() => AuthButton(
|
||||||
text: 'Sign In',
|
text: 'Sign In',
|
||||||
onPressed: () => controller.credentialsSignIn(formKey),
|
onPressed: () => controller.signIn(formKey),
|
||||||
isLoading: controller.isLoading.value,
|
isLoading: controller.isLoading.value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -112,10 +116,10 @@ class SignInScreen extends StatelessWidget {
|
||||||
text: 'Continue with Google',
|
text: 'Continue with Google',
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
TablerIcons.brand_google,
|
TablerIcons.brand_google,
|
||||||
color: TColors.light,
|
color: Colors.white,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => controller.googleSignIn(formKey),
|
onPressed: () => controller.googleSignIn(),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
@ -126,14 +130,15 @@ class SignInScreen extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Don\'t have an account?',
|
'Don\'t have an account?',
|
||||||
style: TextStyle(color: TColors.textSecondary),
|
// Use theme color for text
|
||||||
|
style: TextStyle(color: Theme.of(context).hintColor),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: controller.goToSignUp,
|
onPressed: controller.goToSignUp,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Sign Up',
|
'Sign Up',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TColors.primary,
|
color: Theme.of(context).primaryColor,
|
||||||
fontWeight: FontWeight.w500,
|
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:flutter_svg/svg.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:get/get.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_button.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
|
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
|
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class AuthButton extends StatelessWidget {
|
class AuthButton extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final bool isPrimary;
|
|
||||||
final bool isDisabled;
|
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
final Color? textColor;
|
final Color? textColor;
|
||||||
|
|
||||||
|
@ -16,47 +13,42 @@ class AuthButton extends StatelessWidget {
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.isPrimary = true,
|
|
||||||
this.isDisabled = false,
|
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.textColor,
|
this.textColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final effectiveBackgroundColor =
|
|
||||||
backgroundColor ?? (isPrimary ? TColors.primary : Colors.grey.shade200);
|
|
||||||
final effectiveTextColor =
|
|
||||||
textColor ?? (isPrimary ? Colors.white : TColors.textPrimary);
|
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (isLoading || isDisabled) ? null : onPressed,
|
onPressed: isLoading ? null : onPressed,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: effectiveBackgroundColor,
|
backgroundColor: backgroundColor ?? Theme.of(context).primaryColor,
|
||||||
foregroundColor: effectiveTextColor,
|
foregroundColor: textColor ?? Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: TSizes.md),
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
),
|
),
|
||||||
elevation: 1,
|
|
||||||
disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.5),
|
|
||||||
),
|
),
|
||||||
child:
|
child:
|
||||||
isLoading
|
isLoading
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
height: 20,
|
width: 24,
|
||||||
width: 20,
|
height: 24,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2.5,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
color: textColor ?? Colors.white,
|
||||||
effectiveTextColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(text,
|
: Text(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
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 isUploading;
|
||||||
final bool isVerifying;
|
final bool isVerifying;
|
||||||
final bool isConfirmed;
|
final bool isConfirmed;
|
||||||
|
final bool isSuccess;
|
||||||
final VoidCallback onTapToSelect;
|
final VoidCallback onTapToSelect;
|
||||||
final VoidCallback? onClear;
|
final VoidCallback? onClear;
|
||||||
final VoidCallback? onValidate;
|
final VoidCallback? onValidate;
|
||||||
|
@ -30,6 +31,7 @@ class ImageUploader extends StatelessWidget {
|
||||||
required this.isUploading,
|
required this.isUploading,
|
||||||
required this.isVerifying,
|
required this.isVerifying,
|
||||||
required this.isConfirmed,
|
required this.isConfirmed,
|
||||||
|
this.isSuccess = false,
|
||||||
required this.onTapToSelect,
|
required this.onTapToSelect,
|
||||||
this.onClear,
|
this.onClear,
|
||||||
this.onValidate,
|
this.onValidate,
|
||||||
|
@ -41,18 +43,22 @@ class ImageUploader extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Background color based on error state or confirmation
|
// Background color based on error state, success or confirmation
|
||||||
final backgroundColor =
|
final backgroundColor =
|
||||||
errorMessage != null && errorMessage!.isNotEmpty
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
? TColors.error.withOpacity(0.1)
|
? TColors.error.withOpacity(0.1)
|
||||||
|
: isSuccess
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
: isConfirmed
|
: isConfirmed
|
||||||
? Colors.green.withOpacity(0.1)
|
? Colors.green.withOpacity(0.1)
|
||||||
: Colors.grey.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 =
|
final borderColor =
|
||||||
errorMessage != null && errorMessage!.isNotEmpty
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
? TColors.error
|
? TColors.error
|
||||||
|
: isSuccess
|
||||||
|
? Colors.green
|
||||||
: isConfirmed
|
: isConfirmed
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: Colors.grey.withOpacity(0.5);
|
: Colors.grey.withOpacity(0.5);
|
||||||
|
@ -63,7 +69,7 @@ class ImageUploader extends StatelessWidget {
|
||||||
if (image == null)
|
if (image == null)
|
||||||
_buildEmptyUploader(backgroundColor, borderColor)
|
_buildEmptyUploader(backgroundColor, borderColor)
|
||||||
else
|
else
|
||||||
_buildImagePreview(borderColor),
|
_buildImagePreview(borderColor, context),
|
||||||
|
|
||||||
// Show file size information if image is uploaded
|
// Show file size information if image is uploaded
|
||||||
if (image != null)
|
if (image != null)
|
||||||
|
@ -167,7 +173,7 @@ class ImageUploader extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImagePreview(Color borderColor) {
|
Widget _buildImagePreview(Color borderColor, BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -177,22 +183,49 @@ class ImageUploader extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
border: Border.all(color: borderColor, width: 2),
|
border: Border.all(
|
||||||
|
color: isSuccess ? Colors.green : borderColor,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
// Make the image tappable for preview
|
||||||
borderRadius: BorderRadius.circular(
|
GestureDetector(
|
||||||
TSizes.borderRadiusMd - 2,
|
onTap: () => _showImagePreview(context),
|
||||||
),
|
child: ClipRRect(
|
||||||
child: Image.file(
|
borderRadius: BorderRadius.circular(
|
||||||
File(image!.path),
|
TSizes.borderRadiusMd - 2,
|
||||||
height: 200,
|
),
|
||||||
width: double.infinity,
|
child: Image.file(
|
||||||
fit: BoxFit.cover,
|
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
|
// Error overlay
|
||||||
if (errorMessage != null &&
|
if (errorMessage != null &&
|
||||||
errorMessage!.isNotEmpty &&
|
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() {
|
Widget _defaultErrorOverlay() {
|
||||||
return Container(
|
return Container(
|
||||||
height: 200,
|
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 {
|
class CustomTextField extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
final String? initialValue;
|
||||||
final String? Function(String?)? validator;
|
final String? Function(String?)? validator;
|
||||||
final TextInputType? keyboardType;
|
final TextInputType? keyboardType;
|
||||||
final TextInputAction? textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
|
@ -13,14 +14,17 @@ class CustomTextField extends StatelessWidget {
|
||||||
final Widget? prefixIcon;
|
final Widget? prefixIcon;
|
||||||
final Widget? suffixIcon;
|
final Widget? suffixIcon;
|
||||||
final bool? enabled;
|
final bool? enabled;
|
||||||
|
final bool readOnly;
|
||||||
final bool obscureText;
|
final bool obscureText;
|
||||||
final void Function(String)? onChanged;
|
final void Function(String)? onChanged;
|
||||||
final Color? accentColor;
|
final Color? accentColor;
|
||||||
|
final Color? fillColor;
|
||||||
|
|
||||||
const CustomTextField({
|
const CustomTextField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.controller,
|
this.controller,
|
||||||
|
this.initialValue,
|
||||||
this.validator,
|
this.validator,
|
||||||
this.keyboardType,
|
this.keyboardType,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
|
@ -30,10 +34,16 @@ class CustomTextField extends StatelessWidget {
|
||||||
this.prefixIcon,
|
this.prefixIcon,
|
||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.readOnly = false,
|
||||||
this.obscureText = false,
|
this.obscureText = false,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.accentColor,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -42,6 +52,22 @@ class CustomTextField extends StatelessWidget {
|
||||||
accentColor ?? Theme.of(context).primaryColor;
|
accentColor ?? Theme.of(context).primaryColor;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -56,76 +82,87 @@ class CustomTextField extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
|
|
||||||
// TextFormField with theme-aware styling
|
// Conditional rendering based on whether controller or initialValue is provided
|
||||||
TextFormField(
|
if (controller != null)
|
||||||
controller: controller,
|
TextFormField(
|
||||||
validator: validator,
|
controller: controller,
|
||||||
keyboardType: keyboardType,
|
validator: validator,
|
||||||
textInputAction: textInputAction,
|
keyboardType: keyboardType,
|
||||||
maxLines: maxLines,
|
textInputAction: textInputAction,
|
||||||
enabled: enabled,
|
maxLines: maxLines,
|
||||||
obscureText: obscureText,
|
enabled: enabled,
|
||||||
onChanged: onChanged,
|
readOnly: readOnly,
|
||||||
// Use the theme's text style
|
obscureText: obscureText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
onChanged: onChanged,
|
||||||
decoration: InputDecoration(
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
hintText: hintText,
|
decoration: inputDecoration,
|
||||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
)
|
||||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
else
|
||||||
),
|
TextFormField(
|
||||||
errorText:
|
initialValue: initialValue,
|
||||||
errorText != null && errorText!.isNotEmpty ? errorText : null,
|
validator: validator,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
keyboardType: keyboardType,
|
||||||
horizontal: TSizes.md,
|
textInputAction: textInputAction,
|
||||||
vertical: TSizes.md,
|
maxLines: maxLines,
|
||||||
),
|
enabled: enabled,
|
||||||
prefixIcon: prefixIcon,
|
readOnly: readOnly,
|
||||||
suffixIcon: suffixIcon,
|
obscureText: obscureText,
|
||||||
filled: true,
|
onChanged: onChanged,
|
||||||
// Use theme colors for filling based on brightness
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
fillColor:
|
decoration: inputDecoration,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: TSizes.spaceBtwInputFields),
|
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 awsSecretKey = dotenv.env['AWS_SECRET_KEY'] ?? '';
|
||||||
static String awsRekognitionEndpoint =
|
static String awsRekognitionEndpoint =
|
||||||
'https://rekognition.$awsRegion.amazonaws.com';
|
'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 locationWarning = '/location-warning';
|
||||||
static const String registrationForm = '/registration-form';
|
static const String registrationForm = '/registration-form';
|
||||||
static const String signupWithRole = '/signup-with-role';
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.9"
|
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:
|
carousel_slider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -653,6 +693,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.3+1"
|
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:
|
google_sign_in:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -1354,6 +1418,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
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:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -32,6 +32,7 @@ dependencies:
|
||||||
calendar_date_picker2:
|
calendar_date_picker2:
|
||||||
easy_date_timeline:
|
easy_date_timeline:
|
||||||
equatable: ^2.0.7
|
equatable: ^2.0.7
|
||||||
|
camera:
|
||||||
|
|
||||||
# --- Logging & Debugging ---
|
# --- Logging & Debugging ---
|
||||||
logger:
|
logger:
|
||||||
|
@ -113,6 +114,10 @@ dependencies:
|
||||||
# --- Fonts ---
|
# --- Fonts ---
|
||||||
google_fonts:
|
google_fonts:
|
||||||
|
|
||||||
|
# --- Machine Learning ---
|
||||||
|
google_mlkit_face_detection: ^0.13.1
|
||||||
|
google_mlkit_face_mesh_detection: ^0.4.1
|
||||||
|
|
||||||
# --- Localization ---
|
# --- Localization ---
|
||||||
# (add localization dependencies here if needed)
|
# (add localization dependencies here if needed)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue