From 5dc7aa3cc75abf283d234557bf4148767bdc20c6 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sat, 24 May 2025 16:27:58 +0700 Subject: [PATCH] feat: Update camera dependencies and implement selfie verification UI - Updated camera package version to 0.11.0+2 and camera_android_camerax to 0.6.7+2 in pubspec.lock and pubspec.yaml. - Removed unused google_ml_kit dependencies from pubspec.lock and pubspec.yaml. - Added CameraPreviewWidget for displaying camera feed in registration form. - Created CapturedSelfieView to show the captured selfie and verification status. - Implemented Debug Panel for liveness detection with real-time status updates. - Added ErrorStateWidget to handle and display error messages during verification. - Introduced InstructionBanner to guide users through the verification process. - Developed VerificationProgressWidget to visually represent the verification progress. --- sigap-mobile/backup.mdx | 248 ++++ sigap-mobile/ios/Runner/Info.plist | 144 +-- .../face_liveness_detection_controller.dart | 45 +- .../basic/liveness_detection_screen.dart | 1077 ++--------------- .../basic/widgets/camera_preview_widget.dart | 60 + .../basic/widgets/captured_selfie_view.dart | 218 ++++ .../basic/widgets/debug_panel.dart | 333 +++++ .../basic/widgets/error_state_widget.dart | 87 ++ .../basic/widgets/instruction_banner.dart | 113 ++ .../widgets/verification_progress_widget.dart | 115 ++ sigap-mobile/pubspec.lock | 106 +- sigap-mobile/pubspec.yaml | 99 +- 12 files changed, 1431 insertions(+), 1214 deletions(-) create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/verification_progress_widget.dart diff --git a/sigap-mobile/backup.mdx b/sigap-mobile/backup.mdx index 797ca6a..5532cba 100644 --- a/sigap-mobile/backup.mdx +++ b/sigap-mobile/backup.mdx @@ -1140,3 +1140,251 @@ class FaceLivenessController extends GetxController return orientation == Orientation.portrait ? "Portrait" : "Landscape"; } } + + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; + +class CameraScreen extends StatefulWidget { + const CameraScreen({super.key}); + + @override + State createState() => _CameraScreenState(); +} + +class _CameraScreenState extends State + with WidgetsBindingObserver, TickerProviderStateMixin { + CameraController? controller; + String error=''; + + FaceDetector faceDetector = FaceDetector( + options: FaceDetectorOptions( + enableClassification: true, + enableContours: true, + enableTracking: true, + enableLandmarks: true, + performanceMode: Platform.isAndroid + ? FaceDetectorMode.fast + : FaceDetectorMode.accurate, + ), + ); + + final orientations = { + DeviceOrientation.portraitUp: 0, + DeviceOrientation.landscapeLeft: 90, + DeviceOrientation.portraitDown: 180, + DeviceOrientation.landscapeRight: 270, + }; + + initialize() async { + controller = CameraController( + cameras[1], + ResolutionPreset.medium, + imageFormatGroup: Platform.isAndroid + ? ImageFormatGroup.nv21 + : ImageFormatGroup.bgra8888, + ); + if (mounted) setState(() {}); + initializeCameraController(controller!.description); + if (mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + initialize(); + } + + @override + void dispose() { + faceDetector.close(); + if (controller != null) controller!.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final CameraController? cameraController = controller; + if (cameraController == null || !cameraController.value.isInitialized) { + return; + } + + if (state == AppLifecycleState.inactive) { + cameraController.dispose(); + } else if (state == AppLifecycleState.resumed) { + initialize(); + } + } + + Future initializeCameraController() async { + try { + await controller!.initialize(); + controller!.setFlashMode(FlashMode.off); + // If the controller is updated then update the UI. + controller!.addListener(() { + if (mounted) setState(() {}); + controller!.startImageStream(_processCameraImage).then((value) {}); + }); + if (mounted) setState(() {}); + } on CameraException catch (e) { + switch (e.code) { + case 'CameraAccessDenied': + Get.back(); + cameraDialog(); + break; + case 'CameraAccessDeniedWithoutPrompt': + Get.back(); + cameraDialog(); + break; + case 'CameraAccessRestricted': + Get.back(); + cameraDialog(); + break; + default: + Get.back(); + EasyLoading.showError(e.description!); + break; + } + } + if (mounted) setState(() {}); + } + + cameraDialog() { + Get.defaultDialog( + title: 'Camera Access', + middleText: + 'Looks like you have not provided permission to camera completely. Enable it in the settings.', + onConfirm: () async { + Get.back(); + await openAppSettings(); + }, + ); + } + + clearCaptured(String message) { + debugPrint(message); + imageFile = null; + error = message; + if (mounted) setState(() {}); + return; + } + + void _processCameraImage(CameraImage image) { + final inputImage = inputImageFromCameraImage(image); + if (inputImage == null) return; + checkImage(inputImage); + } + + checkImage(InputImage inputImage) async { + List faces = await faceDetector.processImage(inputImage); + debugPrint('faces: $faces'); + if (faces.isEmpty) { + clearCaptured('No faces detected in the image.'); + } + if (faces.length > 1) { + clearCaptured( + 'Multiple faces detected. Please try capturing a single face.', + ); + } + // Handle the detected faces + for (Face face in faces) { + if (face.leftEyeOpenProbability != null && + face.rightEyeOpenProbability != null) { + final leftEyeOpen = face.leftEyeOpenProbability! > 0.5; + final rightEyeOpen = face.rightEyeOpenProbability! > 0.5; + if (leftEyeOpen && rightEyeOpen) { + error = 'Click capture to save this image'; + if (mounted) setState(() {}); + return; + } else if (!leftEyeOpen && !rightEyeOpen) { + clearCaptured('Both eyes are closed!'); + } else if (leftEyeOpen && !rightEyeOpen) { + clearCaptured('Left eye is open, right eye is closed.'); + } else if (!leftEyeOpen && rightEyeOpen) { + clearCaptured('Right eye is open, left eye is closed.'); + } + } else { + clearCaptured( + 'Looks like either one or both eyes have not been captured properly. Please try again.', + ); + } + } + } + + + InputImage? inputImageFromCameraImage(CameraImage image) { + final camera = cameras[1]; + final sensorOrientation = camera.sensorOrientation; + InputImageRotation? rotation; + if (Platform.isIOS) { + rotation = InputImageRotationValue.fromRawValue(sensorOrientation); + } else if (Platform.isAndroid) { + var rotationCompensation = + orientations[controller!.value.deviceOrientation]; + if (rotationCompensation == null) return null; + if (camera.lensDirection == CameraLensDirection.front) { + // front-facing + rotationCompensation = (sensorOrientation + rotationCompensation) % 360; + } else { + // back-facing + rotationCompensation = + (sensorOrientation - rotationCompensation + 360) % 360; + } + rotation = InputImageRotationValue.fromRawValue(rotationCompensation); + } + if (rotation == null) return null; + + // get image format + final format = InputImageFormatValue.fromRawValue(image.format.raw); + + if (format == null || + (Platform.isAndroid && format != InputImageFormat.nv21) || + (Platform.isIOS && format != InputImageFormat.bgra8888)) return null; + + // since format is constraint to nv21 or bgra8888, both only have one plane + if (image.planes.length != 1) return null; + final plane = image.planes.first; + + // compose InputImage using bytes + return InputImage.fromBytes( + bytes: plane.bytes, + metadata: InputImageMetadata( + size: Size(image.width.toDouble(), image.height.toDouble()), + rotation: rotation, // used only in Android + format: format, // used only in iOS + bytesPerRow: plane.bytesPerRow, // used only in iOS + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text('Capture Your Selfie'), + ), + bottomNavigationBar: controller == null + ? null + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(error), + SizedBox(height:30), + ElevatedButton( + child: Text('Capture'), + onPressed: () async { + await controller!.takePicture().then((value) {}); + }, + ), + SizedBox(height:20), + ], + ), + body: controller == null + ? Center(child: Text('Need to access camera to capture selfie')) + : CameraPreview(controller!), + ); + } +} \ No newline at end of file diff --git a/sigap-mobile/ios/Runner/Info.plist b/sigap-mobile/ios/Runner/Info.plist index 117733b..df4391d 100644 --- a/sigap-mobile/ios/Runner/Info.plist +++ b/sigap-mobile/ios/Runner/Info.plist @@ -1,75 +1,77 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Sigap - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - sigap - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - io.supabase.flutterquickstart - - - - - + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + io.supabase.flutterquickstart + + + + + - - NSLocationWhenInUseUsageDescription - Aplikasi ini memerlukan akses lokasi untuk menunjukkan posisi Anda pada peta - NSLocationAlwaysUsageDescription - Aplikasi ini memerlukan akses lokasi untuk navigasi latar belakang - io.flutter.embedded_views_preview - - MGLMapboxAccessToken - $(MAPBOX_ACCESS_TOKEN) - UIStatusBarHidden - - - + + NSLocationWhenInUseUsageDescription + Aplikasi ini memerlukan akses lokasi untuk menunjukkan posisi Anda pada peta + NSLocationAlwaysUsageDescription + Aplikasi ini memerlukan akses lokasi untuk navigasi latar belakang + io.flutter.embedded_views_preview + + MGLMapboxAccessToken + $(MAPBOX_ACCESS_TOKEN) + UIStatusBarHidden + NSCameraUsageDescription + We need access to your camera to detect faces. + + + \ No newline at end of file diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart index 2511235..e4637e0 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart @@ -3,7 +3,7 @@ import 'dart:developer' as dev; import 'dart:io'; 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'; @@ -23,6 +23,13 @@ enum LivenessStatus { failed, } +final orientations = { + DeviceOrientation.portraitUp: 0, + DeviceOrientation.landscapeLeft: 90, + DeviceOrientation.portraitDown: 180, + DeviceOrientation.landscapeRight: 270, +}; + class FaceLivenessController extends GetxController { // Camera CameraController? cameraController; @@ -201,9 +208,10 @@ class FaceLivenessController extends GetxController { // Detect faces final faces = await faceDetector.processImage(inputImage); - // Process face detection results await _processFaceDetection(faces); + + dev.log('Detected ${faces.length} faces', name: 'LIVENESS_CONTROLLER'); } catch (e) { dev.log('Error processing image: $e', name: 'LIVENESS_CONTROLLER'); } finally { @@ -233,7 +241,18 @@ class FaceLivenessController extends GetxController { if (Platform.isIOS) { rotation = InputImageRotationValue.fromRawValue(sensorOrientation); } else if (Platform.isAndroid) { - var rotationCompensation = sensorOrientation; + var rotationCompensation = + orientations[cameraController!.value.deviceOrientation]; + if (rotationCompensation == null) return null; + if (camera.lensDirection == CameraLensDirection.front) { + // front-facing + rotationCompensation = + (sensorOrientation + rotationCompensation) % 360; + } else { + // back-facing + rotationCompensation = + (sensorOrientation - rotationCompensation + 360) % 360; + } rotation = InputImageRotationValue.fromRawValue(rotationCompensation); } @@ -241,15 +260,11 @@ class FaceLivenessController extends GetxController { dev.log('Could not determine rotation', name: 'LIVENESS_CONTROLLER'); return null; } - - final format = InputImageFormatValue.fromRawValue(image.format.raw); - if (format == null) { - dev.log( - 'Unsupported image format: ${image.format.raw}', - name: 'LIVENESS_CONTROLLER', - ); - return null; - } + + final format = + Platform.isAndroid + ? InputImageFormat.nv21 + : InputImageFormat.bgra8888; // Handle different plane configurations if (image.planes.isEmpty) { @@ -469,7 +484,7 @@ class FaceLivenessController extends GetxController { // Capture image with retry logic int retryCount = 0; const maxRetries = 3; - + while (retryCount < maxRetries) { try { capturedImage = await cameraController!.takePicture(); @@ -480,11 +495,11 @@ class FaceLivenessController extends GetxController { 'Capture attempt $retryCount failed: $e', name: 'LIVENESS_CONTROLLER', ); - + if (retryCount >= maxRetries) { rethrow; } - + // Wait before retry await Future.delayed(Duration(milliseconds: 500)); } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart index 18e2f13..ea69e9e 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart @@ -1,10 +1,15 @@ import 'dart:developer' as dev; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/verification_progress_widget.dart'; import 'package:sigap/src/utils/constants/colors.dart'; class LivenessDetectionPage extends StatelessWidget { @@ -13,7 +18,7 @@ class LivenessDetectionPage extends StatelessWidget { @override Widget build(BuildContext context) { dev.log('Building LivenessDetectionPage', name: 'LIVENESS_DEBUG'); - + // Ensure controllers are registered final bool hasController = Get.isRegistered(); final bool hasSelfieController = @@ -36,33 +41,7 @@ class LivenessDetectionPage extends StatelessWidget { 'Error registering FaceLivenessController: $e', name: 'LIVENESS_DEBUG', ); - // Show error widget if controller initialization fails - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar(title: Text('Error')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.red, size: 48), - SizedBox(height: 16), - Text( - 'Failed to initialize face detection', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text( - 'Error: Controller not found', - style: TextStyle(color: Colors.grey), - ), - SizedBox(height: 24), - ElevatedButton( - onPressed: () => Get.back(), - child: Text('Go Back'), - ), - ], - ), - ), + return ErrorStateWidget(message: 'Failed to initialize face detection', ); } } @@ -87,8 +66,6 @@ class LivenessDetectionPage extends StatelessWidget { ); } - final screenSize = MediaQuery.of(context).size; - return PopScope( onPopInvokedWithResult: (didPop, result) { dev.log( @@ -106,37 +83,7 @@ class LivenessDetectionPage extends StatelessWidget { }, child: Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - elevation: 0, - backgroundColor: Colors.white, - title: const Text( - 'Face Verification', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w600, - fontSize: 18, - ), - ), - // Add debug button - actions: [ - IconButton( - icon: Icon(Icons.bug_report, color: TColors.warning), - onPressed: - () => _showDebugPanel(context, controller, selfieController), - ), - ], - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black87), - onPressed: () { - dev.log('Back button pressed', name: 'LIVENESS_DEBUG'); - if (selfieController != null) { - dev.log('Handling cancellation', name: 'LIVENESS_DEBUG'); - controller.handleCancellation(); - } - Get.back(); - }, - ), - ), + appBar: _buildAppBar(context, controller, selfieController), body: Obx(() { dev.log( 'Rebuilding body: ' @@ -145,951 +92,127 @@ class LivenessDetectionPage extends StatelessWidget { 'Steps: ${controller.successfulSteps.length}', name: 'LIVENESS_DEBUG', ); - + // Show loading indicator while camera initializes if (controller.cameraController == null) { dev.log('Camera controller is null', name: 'LIVENESS_DEBUG'); - return _buildErrorState('Camera initialization failed'); + return ErrorStateWidget(message: 'Camera initialization failed'); } - + if (!controller.cameraController!.value.isInitialized) { dev.log('Camera not initialized yet', name: 'LIVENESS_DEBUG'); - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator( - color: TColors.primary, - strokeWidth: 3, - ), - const SizedBox(height: 24), - Text( - 'Initializing camera...', - style: TextStyle( - fontSize: 16, - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); + return _buildCameraInitializingState(); } // Show captured image when complete if (controller.isCaptured.value) { dev.log('Showing captured view', name: 'LIVENESS_DEBUG'); - return _buildCapturedView(controller, context); + return CapturedSelfieView( + controller: controller, + selfieController: selfieController, + ); } // Main liveness detection UI - return Stack( - children: [ - Column( - children: [ - // Instruction banner - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - decoration: BoxDecoration( - color: TColors.primary.withOpacity(0.08), - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(16), - bottomRight: Radius.circular(16), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: TColors.primary.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.face_retouching_natural, - color: TColors.primary, - size: 20, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - controller.getCurrentDirection(), - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: TColors.primary, - height: 1.4, - ), - ), - ), - // Status indicator - Container( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: _getStatusColor(controller.status.value), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _getStatusText(controller.status.value), - style: TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Camera preview with face overlay - Expanded( - child: Stack( - alignment: Alignment.center, - children: [ - // Camera background - Container( - width: screenSize.width * 0.85, - height: screenSize.width * 0.85, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.05), - borderRadius: BorderRadius.circular(24), - ), - ), - - // Camera preview - ClipRRect( - borderRadius: BorderRadius.circular(24), - child: SizedBox( - width: screenSize.width * 0.85, - height: screenSize.width * 0.85, - child: controller.cameraController!.buildPreview(), - ), - ), - - // Scanning animation - Positioned( - top: 0, - child: Container( - width: screenSize.width * 0.65, - height: 2, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.transparent, - TColors.primary.withOpacity(0.8), - Colors.transparent, - ], - ), - ), - ), - ), - ], - ), - ), - - // Completed steps progress - Container( - margin: const EdgeInsets.all(20), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - spreadRadius: 0, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.verified_outlined, - color: TColors.primary, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'Verification Progress', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Progress indicator - LinearProgressIndicator( - value: controller.successfulSteps.length / 4, - backgroundColor: Colors.grey.shade200, - color: TColors.primary, - minHeight: 6, - borderRadius: BorderRadius.circular(3), - ), - - const SizedBox(height: 16), - - // Steps list - ...controller.successfulSteps.map( - (step) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green.shade50, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: Colors.green.shade600, - size: 14, - ), - ), - const SizedBox(width: 12), - Text( - step, - style: const TextStyle( - fontSize: 14, - color: Colors.black87, - ), - ), - ], - ), - ), - ), - - // Placeholder for incomplete steps - if (controller.successfulSteps.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text( - 'Follow the instructions to complete verification', - style: TextStyle( - fontSize: 14, - color: Colors.black54, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ), - ], - ), - - // Debug overlay (small corner indicator) - Positioned( - top: 10, - left: 10, - child: Container( - padding: EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'Face: ${controller.isFaceInFrame.value ? '✓' : '✗'} | ' - 'Steps: ${controller.successfulSteps.length}', - style: TextStyle(color: Colors.white, fontSize: 10), - ), - ), - ), - ], - ); + return _buildMainDetectionView(context, controller); }), ), ); } - // Function to color the face guide based on detection state - Color _getFaceGuideColor(FaceLivenessController controller) { - if (controller.isFaceInFrame.value) { - return controller.isFaceReadyForPhoto.value - ? Colors.green - : TColors.primary; - } else { - return Colors.white.withOpacity(0.7); - } - } - - // Function to get status color - Color _getStatusColor(LivenessStatus status) { - switch (status) { - case LivenessStatus.preparing: - case LivenessStatus.detectingFace: - return Colors.orange; - case LivenessStatus.failed: - return Colors.red; - case LivenessStatus.completed: - case LivenessStatus.photoTaken: - return Colors.green; - default: - return TColors.primary; - } - } - - // Function to get status text - String _getStatusText(LivenessStatus status) { - switch (status) { - case LivenessStatus.preparing: - return 'Preparing'; - case LivenessStatus.detectingFace: - return 'Detecting'; - case LivenessStatus.checkLeftRotation: - return 'Look Left'; - case LivenessStatus.checkRightRotation: - return 'Look Right'; - case LivenessStatus.checkSmile: - return 'Smile'; - case LivenessStatus.checkEyesOpen: - return 'Open Eyes'; - case LivenessStatus.readyForPhoto: - return 'Ready'; - case LivenessStatus.photoTaken: - return 'Processing'; - case LivenessStatus.completed: - return 'Success'; - case LivenessStatus.failed: - return 'Failed'; - default: - return 'Unknown'; - } - } - - // Error state widget - Widget _buildErrorState(String message) { - String userFriendlyMessage = message; - - // Convert technical errors to user-friendly messages - if (message.contains('server_config_error') || - message.contains('environment variables')) { - userFriendlyMessage = - 'The face verification service is temporarily unavailable. Please try again later.'; - } else if (message.contains('network') || message.contains('connection')) { - userFriendlyMessage = - 'Network error. Please check your internet connection and try again.'; - } else if (message.contains('timeout')) { - userFriendlyMessage = - 'The request timed out. Please try again when you have a stronger connection.'; - } else if (message.contains('Camera initialization failed')) { - userFriendlyMessage = - 'Unable to access camera. Please check your camera permissions and try again.'; - } else if (message.contains('decode') || - message.contains('Body can not be decoded')) { - userFriendlyMessage = - 'There was a problem processing your image. Please try again.'; - } else if (message.contains('invalid_request_format')) { - userFriendlyMessage = - 'There was a problem with the image format. Please try again with a different image.'; - } - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.red, size: 48), - SizedBox(height: 16), - Text( - 'Verification Error', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - userFriendlyMessage, - style: TextStyle(color: Colors.grey), - textAlign: TextAlign.center, - ), - ), - SizedBox(height: 24), - ElevatedButton( - onPressed: () => Get.back(), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - ), - child: Text('Go Back'), - ), - SizedBox(height: 8), - TextButton( - onPressed: () { - // Reset and try again - final controller = Get.find(); - controller.resetProcess(); - }, - child: Text('Try Again'), - ), - ], - ), - ); - } - - Widget _buildCapturedView( - FaceLivenessController controller, - BuildContext context, - ) { - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.white, Colors.green.shade50], - ), - ), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Success icon - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.green.shade50, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check_circle_outline, - color: Colors.green.shade600, - size: 48, - ), - ), - - const SizedBox(height: 20), - - Text( - 'Verification Successful!', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.green.shade700, - ), - ), - - const SizedBox(height: 8), - - Text( - 'Your identity has been verified', - style: TextStyle(fontSize: 16, color: Colors.black54), - ), - - const SizedBox(height: 32), - - // Display captured image - if (controller.capturedImage != null) - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(150), - border: Border.all(color: Colors.white, width: 4), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 20, - spreadRadius: 2, - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(150), - child: Image.file( - File(controller.capturedImage!.path), - width: 200, - height: 200, - fit: BoxFit.cover, - ), - ), - ), - - const SizedBox(height: 32), - - // Completed steps list - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - spreadRadius: 0, - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.verified, - color: Colors.green.shade600, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'All verification steps completed', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: Colors.black87, - ), - ), - ], - ), - - const SizedBox(height: 16), - - ...controller.successfulSteps.map( - (step) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green.shade50, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: Colors.green.shade600, - size: 14, - ), - ), - const SizedBox(width: 12), - Text( - step, - style: const TextStyle( - fontSize: 14, - color: Colors.black87, - ), - ), - ], - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 32), - - // Continue button - clear loading state properly - ElevatedButton( - onPressed: () { - // Reset loading state in selfie controller before navigating back - try { - final selfieController = - Get.find(); - dev.log( - 'Found SelfieVerificationController, handling success', - name: 'LIVENESS_DEBUG', - ); - // Connect with SelfieVerificationController - if (controller.capturedImage != null) { - dev.log( - 'Setting captured image on SelfieVerificationController', - name: 'LIVENESS_DEBUG', - ); - selfieController.selfieImage.value = - controller.capturedImage; - // selfieController._processCapturedLivenessImage(); - } - } catch (e) { - dev.log( - 'Error connecting with SelfieVerificationController: $e', - name: 'LIVENESS_DEBUG', - ); - // Continue without selfie controller - } - Get.back(result: controller.capturedImage); - }, - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - ), - child: const Text( - 'Continue', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - ), - ], - ), - ), - ); - } - - // Debug panel - void _showDebugPanel( + // Building the app bar with debug button + AppBar _buildAppBar( BuildContext context, FaceLivenessController controller, SelfieVerificationController? selfieController, ) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + return AppBar( + elevation: 0, + backgroundColor: Colors.white, + title: const Text( + 'Face Verification', + style: TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + // Add debug button + actions: [ + IconButton( + icon: Icon(Icons.bug_report, color: TColors.warning), + onPressed: + () => + showLivenessDebugPanel(context, controller, selfieController), + ), + ], + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black87), + onPressed: () { + dev.log('Back button pressed', name: 'LIVENESS_DEBUG'); + if (selfieController != null) { + dev.log('Handling cancellation', name: 'LIVENESS_DEBUG'); + controller.handleCancellation(); + } + Get.back(); + }, ), - builder: - (context) => Container( - padding: EdgeInsets.all(20), - height: MediaQuery.of(context).size.height * 0.8, // Lebih tinggi - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Liveness Detection Debug Panel', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - IconButton( - icon: Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - Divider(), - Expanded( - child: SingleChildScrollView( - // Gunakan SingleChildScrollView sebagai pengganti ListView - child: Obx( - () => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status section - Text( - 'Camera & Detection Status', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - SizedBox(height: 8), - _debugItem( - 'Camera Controller', - '${controller.cameraController?.value.isInitialized}', - ), - _debugItem('Status', '${controller.status.value}'), - _debugItem( - 'Face In Frame', - '${controller.isFaceInFrame.value}', - ), - _debugItem('Face Left', '${controller.isFaceLeft}'), - _debugItem('Face Right', '${controller.isFaceRight}'), - _debugItem('Eyes Open', '${controller.isEyeOpen}'), - _debugItem('Smiled', '${controller.isSmiled}'), - _debugItem( - 'Ready For Photo', - '${controller.isFaceReadyForPhoto.value}', - ), - _debugItem('Captured', '${controller.isCaptured}'), - _debugItem( - 'Steps Completed', - '${controller.successfulSteps.length}', - ), - _debugItem('Steps', '${controller.successfulSteps}'), - - Divider(), - Text( - 'Selfie Controller', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - SizedBox(height: 8), - _debugItem('Found', '${selfieController != null}'), - - if (selfieController != null) ...[ - _debugItem( - 'Is Performing Check', - '${selfieController.isPerformingLivenessCheck.value}', - ), - _debugItem( - 'Is Selfie Valid', - '${selfieController.isSelfieValid.value}', - ), - _debugItem( - 'Has Confirmed Selfie', - '${selfieController.hasConfirmedSelfie.value}', - ), - _debugItem( - 'Liveness Passed', - '${selfieController.isLivenessCheckPassed.value}', - ), - ], - - Divider(), - Container( - width: double.infinity, - padding: EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.amber.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.amber.shade200), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Quick Actions', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Colors.amber.shade800, - ), - ), - SizedBox(height: 12), - Text( - 'Use these actions to bypass steps or debug issues:', - style: TextStyle( - fontSize: 14, - color: Colors.black87, - ), - ), - SizedBox(height: 16), - - // Button group - layout untuk tombol - GridView.count( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - crossAxisCount: 2, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 2.5, - children: [ - // Tombol untuk melewati semua tahapan - ElevatedButton.icon( - icon: Icon( - Icons.skip_next, - color: Colors.white, - size: 18, - ), - label: Text( - 'Skip All Steps', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - onPressed: () { - controller.skipAllVerificationSteps(); - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text('Skipping all steps'), - backgroundColor: Colors.green, - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 8, - ), - ), - ), - ), - - // Force next step button - ElevatedButton.icon( - icon: Icon( - Icons.arrow_forward, - color: Colors.white, - size: 18, - ), - label: Text( - 'Next Step', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - onPressed: () { - controller.forceAdvanceToNextStep(); - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text('Forced next step'), - backgroundColor: Colors.amber, - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.amber, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 8, - ), - ), - ), - ), - - // Force capture button - ElevatedButton.icon( - icon: Icon( - Icons.camera, - color: Colors.white, - size: 18, - ), - label: Text( - 'Force Capture', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - onPressed: () { - if (controller.cameraController != - null && - controller - .cameraController! - .value - .isInitialized) { - controller.forceCaptureImage(); - Navigator.pop(context); - } else { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text('Camera not ready'), - backgroundColor: Colors.red, - ), - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 8, - ), - ), - ), - ), - - // Reset process button - ElevatedButton.icon( - icon: Icon( - Icons.refresh, - color: Colors.white, - size: 18, - ), - label: Text( - 'Reset Process', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - onPressed: () { - controller.resetProcess(); - Navigator.pop(context); - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text('Process reset'), - backgroundColor: Colors.red, - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 8, - ), - ), - ), - ), - ], - ), - ], - ), - ), - - SizedBox(height: 16), - // Additional test connection button - if (selfieController != null && - controller.capturedImage != null) - OutlinedButton.icon( - icon: Icon(Icons.link), - label: Text( - 'Test Connection to Selfie Controller', - ), - onPressed: () { - selfieController.selfieImage.value = - controller.capturedImage; - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Test data sent to SelfieVerificationController', - ), - ), - ); - }, - ), - ], - ), - ), - ), - ), - ], - ), - ), ); } - // Debug list item - Widget _debugItem(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( + // Camera initializing state UI + Widget _buildCameraInitializingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('$label: ', style: TextStyle(fontWeight: FontWeight.w500)), - Expanded(child: Text(value, style: TextStyle(color: Colors.blue))), + const CircularProgressIndicator( + color: TColors.primary, + strokeWidth: 3, + ), + const SizedBox(height: 24), + Text( + 'Initializing camera...', + style: TextStyle( + fontSize: 16, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), ], ), ); } + + // Main detection view UI + Widget _buildMainDetectionView( + BuildContext context, + FaceLivenessController controller, + ) { + final screenSize = MediaQuery.of(context).size; + + return Stack( + children: [ + Column( + children: [ + // Instruction banner + InstructionBanner(controller: controller), + + const SizedBox(height: 24), + + // Camera preview with face overlay + Expanded( + child: CameraPreviewWidget( + controller: controller, + screenWidth: screenSize.width, + ), + ), + + // Completed steps progress + VerificationProgressWidget(controller: controller), + ], + ), + ], + ); + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart new file mode 100644 index 0000000..8f3468f --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; + +class CameraPreviewWidget extends StatelessWidget { + final FaceLivenessController controller; + final double screenWidth; + + const CameraPreviewWidget({ + super.key, + required this.controller, + required this.screenWidth, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + // Camera background + Container( + width: screenWidth * 0.85, + height: screenWidth * 0.85, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(24), + ), + ), + + // Camera preview + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: SizedBox( + width: screenWidth * 0.85, + height: screenWidth * 0.85, + child: controller.cameraController!.buildPreview(), + ), + ), + + // Scanning animation + Positioned( + top: 0, + child: Container( + width: screenWidth * 0.65, + height: 2, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + TColors.primary.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart new file mode 100644 index 0000000..512eaa9 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart @@ -0,0 +1,218 @@ +import 'dart:developer' as dev; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; + +class CapturedSelfieView extends StatelessWidget { + final FaceLivenessController controller; + final SelfieVerificationController? selfieController; + + const CapturedSelfieView({ + super.key, + required this.controller, + this.selfieController, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Colors.green.shade50], + ), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Success icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_outline, + color: Colors.green.shade600, + size: 48, + ), + ), + + const SizedBox(height: 20), + + Text( + 'Verification Successful!', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.green.shade700, + ), + ), + + const SizedBox(height: 8), + + Text( + 'Your identity has been verified', + style: TextStyle(fontSize: 16, color: Colors.black54), + ), + + const SizedBox(height: 32), + + // Display captured image + if (controller.capturedImage != null) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(150), + border: Border.all(color: Colors.white, width: 4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(150), + child: Image.file( + File(controller.capturedImage!.path), + width: 200, + height: 200, + fit: BoxFit.cover, + ), + ), + ), + + const SizedBox(height: 32), + + // Completed steps list + _buildCompletedStepsList(), + + const SizedBox(height: 32), + + // Continue button - clear loading state properly + ElevatedButton( + onPressed: () => _handleContinueButton(), + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: const Text( + 'Continue', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + + // Build the completed steps list + Widget _buildCompletedStepsList() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + spreadRadius: 0, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.verified, color: Colors.green.shade600, size: 20), + const SizedBox(width: 8), + Text( + 'All verification steps completed', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Colors.black87, + ), + ), + ], + ), + + const SizedBox(height: 16), + + ...controller.successfulSteps.map( + (step) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: Colors.green.shade600, + size: 14, + ), + ), + const SizedBox(width: 12), + Text( + step, + style: const TextStyle(fontSize: 14, color: Colors.black87), + ), + ], + ), + ), + ), + ], + ), + ); + } + + // Handle the continue button + void _handleContinueButton() { + // Reset loading state in selfie controller before navigating back + try { + if (selfieController != null) { + dev.log( + 'Found SelfieVerificationController, handling success', + name: 'LIVENESS_DEBUG', + ); + // Connect with SelfieVerificationController + if (controller.capturedImage != null) { + dev.log( + 'Setting captured image on SelfieVerificationController', + name: 'LIVENESS_DEBUG', + ); + selfieController?.selfieImage.value = controller.capturedImage; + // selfieController._processCapturedLivenessImage(); + } + } + } catch (e) { + dev.log( + 'Error connecting with SelfieVerificationController: $e', + name: 'LIVENESS_DEBUG', + ); + // Continue without selfie controller + } + Get.back(result: controller.capturedImage); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart new file mode 100644 index 0000000..4d1e2f6 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; + +/// Shows the debug panel for liveness detection +void showLivenessDebugPanel( + BuildContext context, + FaceLivenessController controller, + SelfieVerificationController? selfieController, +) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: + (context) => LivenessDebugPanel( + controller: controller, + selfieController: selfieController, + ), + ); +} + +class LivenessDebugPanel extends StatelessWidget { + final FaceLivenessController controller; + final SelfieVerificationController? selfieController; + + const LivenessDebugPanel({ + super.key, + required this.controller, + this.selfieController, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(20), + height: MediaQuery.of(context).size.height * 0.8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Liveness Detection Debug Panel', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + Divider(), + Expanded( + child: SingleChildScrollView( + child: Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status section + Text( + 'Camera & Detection Status', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + SizedBox(height: 8), + _debugItem( + 'Camera Controller', + '${controller.cameraController?.value.isInitialized}', + ), + _debugItem('Status', '${controller.status.value}'), + _debugItem( + 'Face In Frame', + '${controller.isFaceInFrame.value}', + ), + _debugItem('Face Left', '${controller.isFaceLeft}'), + _debugItem('Face Right', '${controller.isFaceRight}'), + _debugItem('Eyes Open', '${controller.isEyeOpen}'), + _debugItem('Smiled', '${controller.isSmiled}'), + _debugItem( + 'Ready For Photo', + '${controller.isFaceReadyForPhoto.value}', + ), + _debugItem('Captured', '${controller.isCaptured}'), + _debugItem( + 'Steps Completed', + '${controller.successfulSteps.length}', + ), + _debugItem('Steps', '${controller.successfulSteps}'), + + Divider(), + Text( + 'Selfie Controller', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + SizedBox(height: 8), + _debugItem('Found', '${selfieController != null}'), + + if (selfieController != null) ...[ + _debugItem( + 'Is Performing Check', + '${selfieController?.isPerformingLivenessCheck.value}', + ), + _debugItem( + 'Is Selfie Valid', + '${selfieController?.isSelfieValid.value}', + ), + _debugItem( + 'Has Confirmed Selfie', + '${selfieController?.hasConfirmedSelfie.value}', + ), + _debugItem( + 'Liveness Passed', + '${selfieController?.isLivenessCheckPassed.value}', + ), + ], + + Divider(), + _buildQuickActionsSection(context), + + SizedBox(height: 16), + // Additional test connection button + if (selfieController != null && + controller.capturedImage != null) + OutlinedButton.icon( + icon: Icon(Icons.link), + label: Text('Test Connection to Selfie Controller'), + onPressed: () { + selfieController?.selfieImage.value = + controller.capturedImage; + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Test data sent to SelfieVerificationController', + ), + ), + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + // Quick actions section + Widget _buildQuickActionsSection(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Quick Actions', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.amber.shade800, + ), + ), + SizedBox(height: 12), + Text( + 'Use these actions to bypass steps or debug issues:', + style: TextStyle(fontSize: 14, color: Colors.black87), + ), + SizedBox(height: 16), + + // Button group - layout for buttons + GridView.count( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 2.5, + children: [ + // Skip all steps button + ElevatedButton.icon( + icon: Icon(Icons.skip_next, color: Colors.white, size: 18), + label: Text( + 'Skip All Steps', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + onPressed: () { + controller.skipAllVerificationSteps(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Skipping all steps'), + backgroundColor: Colors.green, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + + // Force next step button + ElevatedButton.icon( + icon: Icon(Icons.arrow_forward, color: Colors.white, size: 18), + label: Text( + 'Next Step', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + onPressed: () { + controller.forceAdvanceToNextStep(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Forced next step'), + backgroundColor: Colors.amber, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.amber, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + + // Force capture button + ElevatedButton.icon( + icon: Icon(Icons.camera, color: Colors.white, size: 18), + label: Text( + 'Force Capture', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + onPressed: () { + if (controller.cameraController != null && + controller.cameraController!.value.isInitialized) { + controller.forceCaptureImage(); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Camera not ready'), + backgroundColor: Colors.red, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + + // Reset process button + ElevatedButton.icon( + icon: Icon(Icons.refresh, color: Colors.white, size: 18), + label: Text( + 'Reset Process', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + onPressed: () { + controller.resetProcess(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Process reset'), + backgroundColor: Colors.red, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ], + ), + ); + } + + // Debug list item + Widget _debugItem(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Text('$label: ', style: TextStyle(fontWeight: FontWeight.w500)), + Expanded(child: Text(value, style: TextStyle(color: Colors.blue))), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart new file mode 100644 index 0000000..aa5e112 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; + +class ErrorStateWidget extends StatelessWidget { + final String message; + + const ErrorStateWidget({ + Key? key, + required this.message, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + String userFriendlyMessage = message; + + // Convert technical errors to user-friendly messages + if (message.contains('server_config_error') || + message.contains('environment variables')) { + userFriendlyMessage = + 'The face verification service is temporarily unavailable. Please try again later.'; + } else if (message.contains('network') || message.contains('connection')) { + userFriendlyMessage = + 'Network error. Please check your internet connection and try again.'; + } else if (message.contains('timeout')) { + userFriendlyMessage = + 'The request timed out. Please try again when you have a stronger connection.'; + } else if (message.contains('Camera initialization failed')) { + userFriendlyMessage = + 'Unable to access camera. Please check your camera permissions and try again.'; + } else if (message.contains('decode') || + message.contains('Body can not be decoded')) { + userFriendlyMessage = + 'There was a problem processing your image. Please try again.'; + } else if (message.contains('invalid_request_format')) { + userFriendlyMessage = + 'There was a problem with the image format. Please try again with a different image.'; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 48), + SizedBox(height: 16), + Text( + 'Verification Error', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + userFriendlyMessage, + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: () => Get.back(), + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + ), + child: Text('Go Back'), + ), + SizedBox(height: 8), + TextButton( + onPressed: () { + // Reset and try again + try { + final controller = Get.find(); + controller.resetProcess(); + } catch (e) { + // Handle case where controller isn't available + Get.back(); + } + }, + child: Text('Try Again'), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart new file mode 100644 index 0000000..fb85f40 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; + +class InstructionBanner extends StatelessWidget { + final FaceLivenessController controller; + + const InstructionBanner({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: TColors.primary.withOpacity(0.08), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: TColors.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.face_retouching_natural, + color: TColors.primary, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx( + () => Text( + controller.getCurrentDirection(), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: TColors.primary, + height: 1.4, + ), + ), + ), + ), + // Status indicator + Obx( + () => Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getStatusColor(controller.status.value), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _getStatusText(controller.status.value), + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ], + ), + ); + } + + // Function to get status color + Color _getStatusColor(LivenessStatus status) { + switch (status) { + case LivenessStatus.preparing: + case LivenessStatus.detectingFace: + return Colors.orange; + case LivenessStatus.failed: + return Colors.red; + case LivenessStatus.completed: + case LivenessStatus.photoTaken: + return Colors.green; + default: + return TColors.primary; + } + } + + // Function to get status text + String _getStatusText(LivenessStatus status) { + switch (status) { + case LivenessStatus.preparing: + return 'Preparing'; + case LivenessStatus.detectingFace: + return 'Detecting'; + case LivenessStatus.checkLeftRotation: + return 'Look Left'; + case LivenessStatus.checkRightRotation: + return 'Look Right'; + case LivenessStatus.checkSmile: + return 'Smile'; + case LivenessStatus.checkEyesOpen: + return 'Open Eyes'; + case LivenessStatus.readyForPhoto: + return 'Ready'; + case LivenessStatus.photoTaken: + return 'Processing'; + case LivenessStatus.completed: + return 'Success'; + case LivenessStatus.failed: + return 'Failed'; + default: + return 'Unknown'; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/verification_progress_widget.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/verification_progress_widget.dart new file mode 100644 index 0000000..53131ff --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/widgets/verification_progress_widget.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; + +class VerificationProgressWidget extends StatelessWidget { + final FaceLivenessController controller; + + const VerificationProgressWidget({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(20), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + spreadRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.verified_outlined, color: TColors.primary, size: 20), + const SizedBox(width: 8), + Text( + 'Verification Progress', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Progress indicator + Obx( + () => LinearProgressIndicator( + value: controller.successfulSteps.length / 4, + backgroundColor: Colors.grey.shade200, + color: TColors.primary, + minHeight: 6, + borderRadius: BorderRadius.circular(3), + ), + ), + + const SizedBox(height: 16), + + // Steps list + Obx(() { + if (controller.successfulSteps.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Follow the instructions to complete verification', + style: TextStyle( + fontSize: 14, + color: Colors.black54, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return Column( + children: + controller.successfulSteps + .map( + (step) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: Colors.green.shade600, + size: 14, + ), + ), + const SizedBox(width: 12), + Text( + step, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + ), + ), + ], + ), + ), + ) + .toList(), + ); + }), + ], + ), + ); + } +} diff --git a/sigap-mobile/pubspec.lock b/sigap-mobile/pubspec.lock index e073101..a766762 100644 --- a/sigap-mobile/pubspec.lock +++ b/sigap-mobile/pubspec.lock @@ -109,18 +109,18 @@ packages: dependency: "direct main" description: name: camera - sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb" + sha256: "26ff41045772153f222ffffecba711a206f670f5834d40ebf5eed3811692f167" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.11.0+2" camera_android_camerax: - dependency: transitive + dependency: "direct main" description: name: camera_android_camerax - sha256: "9fb44e73e0fea3647a904dc26d38db24055e5b74fc68fd2b6d3abfa1bd20f536" + sha256: "8bd9cab67551642eb33ceb33ece7acc0890014fc90ddfae637c7e2b683657e65" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.7+2" camera_avfoundation: dependency: transitive description: @@ -693,22 +693,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.3+1" - google_ml_kit: - dependency: "direct main" - description: - name: google_ml_kit - sha256: a2da12a62353a6cad71534b52ada3af14a5b842e6c9b1014ce4d243652b30f4b - url: "https://pub.dev" - source: hosted - version: "0.20.0" - google_mlkit_barcode_scanning: - dependency: transitive - description: - name: google_mlkit_barcode_scanning - sha256: b38505df2d3fdf7830979d60fee55039c2f442d189b2e06fcb2fe494ba65d0db - url: "https://pub.dev" - source: hosted - version: "0.14.1" google_mlkit_commons: dependency: transitive description: @@ -717,22 +701,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" - google_mlkit_digital_ink_recognition: - dependency: transitive - description: - name: google_mlkit_digital_ink_recognition - sha256: "8d2b89401bdeeba97158377167429dbc5cb339ebbd21e0889dca773f1c79a884" - url: "https://pub.dev" - source: hosted - version: "0.14.1" - google_mlkit_entity_extraction: - dependency: transitive - description: - name: google_mlkit_entity_extraction - sha256: "145bc26422b7e62d50cc4eca1ac394d13ac6a97e4c09b8baf7ff058b64d2f9cc" - url: "https://pub.dev" - source: hosted - version: "0.15.1" google_mlkit_face_detection: dependency: "direct main" description: @@ -749,70 +717,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" - google_mlkit_image_labeling: - dependency: transitive - description: - name: google_mlkit_image_labeling - sha256: "2cac5f7a02dcc23cd3357f89bf1a79df793ae3afce5035a896de467ffa0192e8" - url: "https://pub.dev" - source: hosted - version: "0.14.1" - google_mlkit_language_id: - dependency: transitive - description: - name: google_mlkit_language_id - sha256: fc57bca69cb1dcd8ef67b929f0315e9a8baa80c03c75f7a1226becd7ad2529ff - url: "https://pub.dev" - source: hosted - version: "0.13.0" - google_mlkit_object_detection: - dependency: transitive - description: - name: google_mlkit_object_detection - sha256: "0f740f046d74faf81d9c44cdbe4accf33888ed9f877e30efbfad4578d45ebfcd" - url: "https://pub.dev" - source: hosted - version: "0.15.0" - google_mlkit_pose_detection: - dependency: transitive - description: - name: google_mlkit_pose_detection - sha256: "5ff5fe2a325427c49c02a884a2a888d2d10cbfe414f7ebf2af9777a5155171eb" - url: "https://pub.dev" - source: hosted - version: "0.14.0" - google_mlkit_selfie_segmentation: - dependency: transitive - description: - name: google_mlkit_selfie_segmentation - sha256: e05fc255265595a0fb11cd6a6a5393f106d6ec4d3a40cbc57ff22894eef235f1 - url: "https://pub.dev" - source: hosted - version: "0.10.0" - google_mlkit_smart_reply: - dependency: transitive - description: - name: google_mlkit_smart_reply - sha256: "0c3d737e46f20aa4d4953860ee5757e5250e58f90351f8e2afdeb1d609c7047e" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - google_mlkit_text_recognition: - dependency: transitive - description: - name: google_mlkit_text_recognition - sha256: "96173ad4dd7fd06c660e22ac3f9e9f1798a517fe7e48bee68eeec83853224224" - url: "https://pub.dev" - source: hosted - version: "0.15.0" - google_mlkit_translation: - dependency: transitive - description: - name: google_mlkit_translation - sha256: "7287444a0abd994087a0b354dee952fcd198e57619ded4bba65496d418c9d84b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" google_sign_in: dependency: "direct main" description: diff --git a/sigap-mobile/pubspec.yaml b/sigap-mobile/pubspec.yaml index f4e4791..b7ab7ac 100644 --- a/sigap-mobile/pubspec.yaml +++ b/sigap-mobile/pubspec.yaml @@ -1,8 +1,8 @@ name: sigap -description: 'A mobile app for SIGAP Jember' +description: "A mobile app for SIGAP Jember" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -26,98 +26,97 @@ dependencies: sdk: flutter # --- Date & Time Utilities --- - intl: ^0.19.0 - timeago: - time_slot: - calendar_date_picker2: - easy_date_timeline: + intl: ^0.19.0 + timeago: + time_slot: + calendar_date_picker2: + easy_date_timeline: equatable: ^2.0.7 - camera: + camera: 0.11.0+2 + camera_android_camerax: 0.6.7+2 # --- Logging & Debugging --- - logger: + logger: # --- URL & Launcher --- - url_launcher: + url_launcher: # --- Splash & Icons --- - flutter_launcher_icons: + flutter_launcher_icons: animated_splash_screen: ^1.3.0 flutter_native_splash: ^2.4.6 flutter_tabler_icons: ^1.43.0 # --- UI & Animation --- - smooth_page_indicator: - lottie: - shimmer: - badges: - carousel_slider: - flutter_rating_bar: - readmore: - dropdown_search: - dotted_border: + smooth_page_indicator: + lottie: + shimmer: + badges: + carousel_slider: + flutter_rating_bar: + readmore: + dropdown_search: + dotted_border: flutter_svg: ^2.1.0 - # --- Input & Forms --- - flutter_otp_text_field: - image_picker: - file_picker: + flutter_otp_text_field: + image_picker: + file_picker: # --- Storage & Filesystem --- - path: - path_provider: + path: + path_provider: flutter_secure_storage: # --- Data & Utilities --- - uuid: - crypto: + uuid: + crypto: # --- Notifications & Permissions --- - flutter_local_notifications: - permission_handler: + flutter_local_notifications: + permission_handler: # --- Environment & Config --- - flutter_dotenv: + flutter_dotenv: # --- Connectivity --- - connectivity_plus: + connectivity_plus: # --- Map & Location --- - mapbox_maps_flutter: + mapbox_maps_flutter: - # mapbox_gl: - polyline_do: - flutter_polyline_points: + # mapbox_gl: + polyline_do: + flutter_polyline_points: location: - latlong2: - geolocator: + latlong2: + geolocator: geocoding: # --- Icons --- - iconsax: - cupertino_icons: - font_awesome_flutter: + iconsax: + cupertino_icons: + font_awesome_flutter: # --- State Management & Storage --- - get: - get_storage: + get: + get_storage: # --- Supabase & API Services --- - supabase_flutter: - dio: + supabase_flutter: + dio: # --- Authentication --- google_sign_in: local_auth: # --- Fonts --- - google_fonts: + google_fonts: # --- Machine Learning --- google_mlkit_face_detection: ^0.13.1 google_mlkit_face_mesh_detection: ^0.4.1 - google_ml_kit: ^0.20.0 # --- Localization --- # (add localization dependencies here if needed) @@ -143,9 +142,9 @@ flutter: #--------------- LOCAL ASSETS ------------------# assets: - # Environment variables + # Environment variables - .env - # Images + # Images - assets/logos/ - assets/icons/brands/ - assets/images/content/ @@ -155,7 +154,7 @@ flutter: - assets/icons/categories/ - assets/images/animations/ - assets/images/on_boarding_images/ - # JSON Files + # JSON Files - public/jsons/provinsi.json - public/jsons/kabupaten_kota.json - public/jsons/kecamatan.json