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.
This commit is contained in:
vergiLgood1 2025-05-24 16:27:58 +07:00
parent 790eb4325d
commit 5dc7aa3cc7
12 changed files with 1431 additions and 1214 deletions

View File

@ -1140,3 +1140,251 @@ class FaceLivenessController extends GetxController
return orientation == Orientation.portrait ? "Portrait" : "Landscape"; 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<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends State<CameraScreen>
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<void> 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<Face> 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!),
);
}
}

View File

@ -70,6 +70,8 @@
<key>MGLMapboxAccessToken</key> <key>MGLMapboxAccessToken</key>
<string>$(MAPBOX_ACCESS_TOKEN)</string> <string>$(MAPBOX_ACCESS_TOKEN)</string>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to detect faces.</string>
<false /> <false />
</dict> </dict>
</plist> </plist>

View File

@ -3,7 +3,7 @@ import 'dart:developer' as dev;
import 'dart:io'; import 'dart:io';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.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:google_mlkit_face_mesh_detection/google_mlkit_face_mesh_detection.dart';
@ -23,6 +23,13 @@ enum LivenessStatus {
failed, failed,
} }
final orientations = {
DeviceOrientation.portraitUp: 0,
DeviceOrientation.landscapeLeft: 90,
DeviceOrientation.portraitDown: 180,
DeviceOrientation.landscapeRight: 270,
};
class FaceLivenessController extends GetxController { class FaceLivenessController extends GetxController {
// Camera // Camera
CameraController? cameraController; CameraController? cameraController;
@ -201,9 +208,10 @@ class FaceLivenessController extends GetxController {
// Detect faces // Detect faces
final faces = await faceDetector.processImage(inputImage); final faces = await faceDetector.processImage(inputImage);
// Process face detection results // Process face detection results
await _processFaceDetection(faces); await _processFaceDetection(faces);
dev.log('Detected ${faces.length} faces', name: 'LIVENESS_CONTROLLER');
} catch (e) { } catch (e) {
dev.log('Error processing image: $e', name: 'LIVENESS_CONTROLLER'); dev.log('Error processing image: $e', name: 'LIVENESS_CONTROLLER');
} finally { } finally {
@ -233,7 +241,18 @@ class FaceLivenessController extends GetxController {
if (Platform.isIOS) { if (Platform.isIOS) {
rotation = InputImageRotationValue.fromRawValue(sensorOrientation); rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) { } 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); rotation = InputImageRotationValue.fromRawValue(rotationCompensation);
} }
@ -242,14 +261,10 @@ class FaceLivenessController extends GetxController {
return null; return null;
} }
final format = InputImageFormatValue.fromRawValue(image.format.raw); final format =
if (format == null) { Platform.isAndroid
dev.log( ? InputImageFormat.nv21
'Unsupported image format: ${image.format.raw}', : InputImageFormat.bgra8888;
name: 'LIVENESS_CONTROLLER',
);
return null;
}
// Handle different plane configurations // Handle different plane configurations
if (image.planes.isEmpty) { if (image.planes.isEmpty) {

View File

@ -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,
],
),
),
),
),
],
);
}
}

View File

@ -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);
}
}

View File

@ -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))),
],
),
);
}
}

View File

@ -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<FaceLivenessController>();
controller.resetProcess();
} catch (e) {
// Handle case where controller isn't available
Get.back();
}
},
child: Text('Try Again'),
),
],
),
);
}
}

View File

@ -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';
}
}
}

View File

@ -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(),
);
}),
],
),
);
}
}

View File

@ -109,18 +109,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: camera name: camera
sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb" sha256: "26ff41045772153f222ffffecba711a206f670f5834d40ebf5eed3811692f167"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.11.0+2"
camera_android_camerax: camera_android_camerax:
dependency: transitive dependency: "direct main"
description: description:
name: camera_android_camerax name: camera_android_camerax
sha256: "9fb44e73e0fea3647a904dc26d38db24055e5b74fc68fd2b6d3abfa1bd20f536" sha256: "8bd9cab67551642eb33ceb33ece7acc0890014fc90ddfae637c7e2b683657e65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.17" version: "0.6.7+2"
camera_avfoundation: camera_avfoundation:
dependency: transitive dependency: transitive
description: description:
@ -693,22 +693,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.3+1" version: "0.3.3+1"
google_ml_kit:
dependency: "direct main"
description:
name: google_ml_kit
sha256: a2da12a62353a6cad71534b52ada3af14a5b842e6c9b1014ce4d243652b30f4b
url: "https://pub.dev"
source: hosted
version: "0.20.0"
google_mlkit_barcode_scanning:
dependency: transitive
description:
name: google_mlkit_barcode_scanning
sha256: b38505df2d3fdf7830979d60fee55039c2f442d189b2e06fcb2fe494ba65d0db
url: "https://pub.dev"
source: hosted
version: "0.14.1"
google_mlkit_commons: google_mlkit_commons:
dependency: transitive dependency: transitive
description: description:
@ -717,22 +701,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.0" version: "0.11.0"
google_mlkit_digital_ink_recognition:
dependency: transitive
description:
name: google_mlkit_digital_ink_recognition
sha256: "8d2b89401bdeeba97158377167429dbc5cb339ebbd21e0889dca773f1c79a884"
url: "https://pub.dev"
source: hosted
version: "0.14.1"
google_mlkit_entity_extraction:
dependency: transitive
description:
name: google_mlkit_entity_extraction
sha256: "145bc26422b7e62d50cc4eca1ac394d13ac6a97e4c09b8baf7ff058b64d2f9cc"
url: "https://pub.dev"
source: hosted
version: "0.15.1"
google_mlkit_face_detection: google_mlkit_face_detection:
dependency: "direct main" dependency: "direct main"
description: description:
@ -749,70 +717,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.4.1" version: "0.4.1"
google_mlkit_image_labeling:
dependency: transitive
description:
name: google_mlkit_image_labeling
sha256: "2cac5f7a02dcc23cd3357f89bf1a79df793ae3afce5035a896de467ffa0192e8"
url: "https://pub.dev"
source: hosted
version: "0.14.1"
google_mlkit_language_id:
dependency: transitive
description:
name: google_mlkit_language_id
sha256: fc57bca69cb1dcd8ef67b929f0315e9a8baa80c03c75f7a1226becd7ad2529ff
url: "https://pub.dev"
source: hosted
version: "0.13.0"
google_mlkit_object_detection:
dependency: transitive
description:
name: google_mlkit_object_detection
sha256: "0f740f046d74faf81d9c44cdbe4accf33888ed9f877e30efbfad4578d45ebfcd"
url: "https://pub.dev"
source: hosted
version: "0.15.0"
google_mlkit_pose_detection:
dependency: transitive
description:
name: google_mlkit_pose_detection
sha256: "5ff5fe2a325427c49c02a884a2a888d2d10cbfe414f7ebf2af9777a5155171eb"
url: "https://pub.dev"
source: hosted
version: "0.14.0"
google_mlkit_selfie_segmentation:
dependency: transitive
description:
name: google_mlkit_selfie_segmentation
sha256: e05fc255265595a0fb11cd6a6a5393f106d6ec4d3a40cbc57ff22894eef235f1
url: "https://pub.dev"
source: hosted
version: "0.10.0"
google_mlkit_smart_reply:
dependency: transitive
description:
name: google_mlkit_smart_reply
sha256: "0c3d737e46f20aa4d4953860ee5757e5250e58f90351f8e2afdeb1d609c7047e"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
google_mlkit_text_recognition:
dependency: transitive
description:
name: google_mlkit_text_recognition
sha256: "96173ad4dd7fd06c660e22ac3f9e9f1798a517fe7e48bee68eeec83853224224"
url: "https://pub.dev"
source: hosted
version: "0.15.0"
google_mlkit_translation:
dependency: transitive
description:
name: google_mlkit_translation
sha256: "7287444a0abd994087a0b354dee952fcd198e57619ded4bba65496d418c9d84b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
google_sign_in: google_sign_in:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -1,8 +1,8 @@
name: sigap 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 # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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. # The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43 # A version number is three numbers separated by dots, like 1.2.43
@ -32,7 +32,8 @@ dependencies:
calendar_date_picker2: calendar_date_picker2:
easy_date_timeline: easy_date_timeline:
equatable: ^2.0.7 equatable: ^2.0.7
camera: camera: 0.11.0+2
camera_android_camerax: 0.6.7+2
# --- Logging & Debugging --- # --- Logging & Debugging ---
logger: logger:
@ -58,7 +59,6 @@ dependencies:
dotted_border: dotted_border:
flutter_svg: ^2.1.0 flutter_svg: ^2.1.0
# --- Input & Forms --- # --- Input & Forms ---
flutter_otp_text_field: flutter_otp_text_field:
image_picker: image_picker:
@ -117,7 +117,6 @@ dependencies:
# --- Machine Learning --- # --- Machine Learning ---
google_mlkit_face_detection: ^0.13.1 google_mlkit_face_detection: ^0.13.1
google_mlkit_face_mesh_detection: ^0.4.1 google_mlkit_face_mesh_detection: ^0.4.1
google_ml_kit: ^0.20.0
# --- Localization --- # --- Localization ---
# (add localization dependencies here if needed) # (add localization dependencies here if needed)