update user dan user profile

This commit is contained in:
vergiLgood1 2025-05-25 21:11:30 +07:00
parent e6e0a0ab07
commit 1a6eefe6e3
16 changed files with 1071 additions and 629 deletions

View File

@ -83,7 +83,7 @@ class AuthenticationRepository extends GetxController {
final bool isFirstTime = storage.read('isFirstTime') ?? true; final bool isFirstTime = storage.read('isFirstTime') ?? true;
final isEmailVerified = session?.user.emailConfirmedAt != null; final isEmailVerified = session?.user.emailConfirmedAt != null;
final isProfileComplete = final isProfileComplete =
session?.user.userMetadata?['profile_status'] == 'complete'; session?.user.userMetadata?['profile_status'] == 'completed';
// Log the current state for debugging // Log the current state for debugging
Logger().d( Logger().d(
@ -679,7 +679,7 @@ class AuthenticationRepository extends GetxController {
// Convert to UserModel // Convert to UserModel
final userMetadataModel = UserMetadataModel.fromInitUserMetadata( final userMetadataModel = UserMetadataModel.fromInitUserMetadata(
completeData, completeData,
profileStatus: 'complete', profileStatus: 'completed',
); );
// First update auth metadata // First update auth metadata

View File

@ -4,6 +4,7 @@ import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/data/models/registration_data_model.dart'; import 'package:sigap/src/features/auth/data/models/registration_data_model.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/id-card-verification/id_card_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart';
@ -14,9 +15,12 @@ import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/vie
import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/daily-ops/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/features/personalization/data/repositories/profile_repository.dart';
import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart';
import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart';
import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart';
import 'package:sigap/src/utils/constants/num_int.dart'; import 'package:sigap/src/utils/constants/num_int.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
class FormRegistrationController extends GetxController { class FormRegistrationController extends GetxController {
@ -225,6 +229,8 @@ class FormRegistrationController extends GetxController {
void _initializeControllers() { void _initializeControllers() {
final isOfficer = registrationData.value.isOfficer; final isOfficer = registrationData.value.isOfficer;
Logger().d('Initializing controllers with isOfficer: $isOfficer');
// Clear existing controllers first to prevent duplicates // Clear existing controllers first to prevent duplicates
_clearExistingControllers(); _clearExistingControllers();
@ -716,7 +722,7 @@ class FormRegistrationController extends GetxController {
// Fixed missing parenthesis // Fixed missing parenthesis
currentStep.value++; currentStep.value++;
} else { } else {
submitForm(); // submitForm();
} }
} }
@ -771,38 +777,192 @@ class FormRegistrationController extends GetxController {
} }
} }
// Submit the entire form using centralized registration data // Update officer information
Future<bool> submitForm() async { // void updateOfficerRegistrationData() async {
if (!validateCurrentStep()) { // try {
print('Form validation failed for step ${currentStep.value}'); // isSubmitting.value = true;
return false; // submitMessage.value = 'Submitting officer registration...';
}
if (currentStep.value < totalSteps - 1) { // final isConnected = await NetworkManager.instance.isConnected();
nextStep(); // if (!isConnected) {
return false; // TLoaders.errorSnackBar(
} // title: 'No Internet Connection',
// message: 'Please check your internet connection and try again.',
// );
// isSubmitting.value = false;
// return;
// }
// // Get user ID and other key information
// final userId = AuthenticationRepository.instance.authUser!.id;
// final email = AuthenticationRepository.instance.authUser!.email;
// final roleId =
// AuthenticationRepository.instance.authUser!.userMetadata?['role_id']
// as String? ??
// '';
// // Create officer model
// final officer = OfficerModel(
// id: userId,
// unitId: unitInfoController?.unitIdController.text ?? '',
// roleId: roleId,
// nrp: officerInfoController?.nrpController.text ?? '',
// name:
// '${personalInfoController.firstNameController.text} ${personalInfoController.lastNameController.text}',
// rank: officerInfoController?.rankController.text,
// position: unitInfoController?.positionController.text,
// phone: personalInfoController.phoneController.text,
// email: email,
// placeOfBirth: identityController.placeOfBirthController.text,
// dateOfBirth: _parseBirthDate(
// identityController.birthDateController.text,
// ),
// );
// // Prepare auth metadata update
// final authMetadata = {
// 'profile_status': 'completed',
// 'is_officer': true,
// 'name': officer.name,
// 'nrp': officer.nrp,
// 'unit_id': officer.unitId,
// 'unit_name': _getUnitNameFromId(officer.unitId),
// };
// // Perform updates in a specific order:
// // 1. Update auth metadata
// try {
// await UserRepository.instance.updateUserMetadata(
// authMetadata,
// );
// Logger().d('Officer auth metadata updated successfully');
// } catch (e) {
// Logger().e('Error updating officer auth metadata: $e');
// throw Exception('Failed to update officer auth metadata: $e');
// }
// // 2. Update or create officer record
// try {
// await OfficerRepository.instance.updateOrCreateOfficer(
// officer.toJson(),
// );
// Logger().d('Officer data updated successfully');
// } catch (e) {
// Logger().e('Error updating officer data: $e');
// throw Exception('Failed to update officer data: $e');
// }
// isSubmitSuccess.value = true;
// submitMessage.value = 'Officer registration completed successfully';
// } catch (e) {
// Logger().e('Error updating officer data: $e');
// isSubmitSuccess.value = false;
// submitMessage.value =
// 'Failed to complete officer registration: ${e.toString()}';
// } finally {
// isSubmitting.value = false;
// }
// }
// Storing registration data to Supabase
void updateUserRegistrationData() async {
try { try {
isSubmitting.value = true; isSubmitting.value = true;
submitMessage.value = 'Submitting your registration...';
final result = await saveRegistrationData(); final isConnected = await NetworkManager.instance.isConnected();
if (!isConnected) {
if (result) { TLoaders.errorSnackBar(
isSubmitSuccess.value = true; title: 'No Internet Connection',
submitMessage.value = 'Registration completed successfully!'; message: 'Please check your internet connection and try again.',
} else { );
isSubmitSuccess.value = false; isSubmitting.value = false;
submitMessage.value = 'Registration failed. Please try again.'; return;
} }
return result; final currentUser = await UserRepository.instance.getUserById(
AuthenticationRepository.instance.authUser!.id,
);
final updateUser = currentUser.copyWith(
phone: personalInfoController.phoneController.text,
updatedAt: DateTime.now(),
);
final updateProfile = currentUser.profile?.copyWith(
nik: identityController.nikController.text,
firstName: personalInfoController.firstNameController.text,
lastName: personalInfoController.lastNameController.text,
address: {
'full_address': personalInfoController.addressController.text,
},
placeOfBirth: identityController.placeOfBirthController.text,
birthDate: _parseBirthDate(identityController.birthDateController.text),
);
Logger().d('Created user model for update: ${updateUser.toJson()}');
// Check if NIK is already registered (but ignore if it's the current user's NIK)
final isNikTaken = await UserRepository.instance.isNikExists(
updateProfile!.nik!,
);
if (isNikTaken) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'NIK already registered to another user!',
);
isSubmitting.value = false;
return;
}
// Update user model in database
final userRepository = UserRepository.instance;
final profileRepository = ProfileRepository.instance;
final userUpdated = await userRepository.updateUser(updateUser);
await profileRepository.updateProfile(updateProfile);
if (userUpdated == null) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to update user profile. Please try again.',
);
isSubmitting.value = false;
return;
}
// Update user metadata with common profile info
final metadata =
UserMetadataModel(profileStatus: 'completed').toAuthMetadataJson();
// Update metadata first
await userRepository.updateUserMetadata(metadata);
Logger().d('User metadata updated successfully');
isSubmitSuccess.value = true;
submitMessage.value = 'Registration completed successfully';
Get.off(
() => StateScreen(
title: 'Registration Completed',
subtitle: 'Your registration has been successfully completed.',
icon: Icons.check_circle,
showButton: true,
primaryButtonTitle: 'Go to Home',
onPressed: () => AuthenticationRepository.instance.screenRedirect(),
),
);
} catch (e) { } catch (e) {
print('Error submitting form: $e'); Logger().e('Error updating registration data: $e');
isSubmitSuccess.value = false; isSubmitSuccess.value = false;
submitMessage.value = 'Error during registration: $e'; submitMessage.value = 'Failed to complete registration: ${e.toString()}';
return false; TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to complete registration: ${e.toString()}',
);
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart'; import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/data/models/registration_data_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/facial_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/facial_verification_controller.dart';
@ -545,60 +544,28 @@ class IdentityVerificationController extends GetxController {
} }
// Save registration data using the centralized model // Save registration data using the centralized model
Future<bool> saveRegistrationData() async { void saveRegistrationData() async {
try { try {
isSavingData.value = true; // Call the appropriate registration update function based on user type
dataSaveMessage.value = 'Saving your registration data...'; if (isOfficer) {
// For officers, use the officer registration update function
// Final validation // mainController.updateOfficerRegistrationData();
if (!validate(null)) {
dataSaveMessage.value = 'Please fix the errors before submitting';
return false;
} }
// Update the registration data with identity verification data // For regular users, call the three required updates
final currentIdentityData = IdentityVerificationData( mainController.updateUserRegistrationData();
nik: nikController.text,
fullName: fullNameController.text,
placeOfBirth: placeOfBirthController.text,
birthDate: birthDateController.text,
gender: selectedGender.value ?? '',
address: addressController.text,
);
// Update the centralized registration data
mainController.updateStepData<IdentityVerificationData>(
currentIdentityData,
);
// Update summary with final form data
_updateSummaryWithFormData();
// Send the data to the main controller for submission
final result = await mainController.saveRegistrationData(
summaryData: summaryData,
);
if (result) {
isDataSaved.value = true;
dataSaveMessage.value = 'Registration data saved successfully!';
} else {
isDataSaved.value = false;
dataSaveMessage.value =
'Failed to save registration data. Please try again.';
}
return result;
} catch (e) { } catch (e) {
isDataSaved.value = false; isDataSaved.value = false;
dataSaveMessage.value = 'Error saving registration data: $e'; dataSaveMessage.value = 'Error saving registration data: $e';
print('Error saving registration data: $e'); Logger().e('Error saving registration data: $e');
return false;
} finally { } finally {
isSavingData.value = false; isSavingData.value = false;
} }
} }
@override @override
void onClose() { void onClose() {
// Dispose form controllers // Dispose form controllers

View File

@ -55,7 +55,7 @@ class FaceLivenessController extends GetxController {
final successfulSteps = <String>[].obs; final successfulSteps = <String>[].obs;
// Countdown timer state // Countdown timer state
final countdownSeconds = 5.obs; final countdownSeconds = 3.obs;
Timer? _countdownTimer; Timer? _countdownTimer;
// Image processing // Image processing

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/unit_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/identity-verification/widgets/id_info_form.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/viewer-information/personal_info_controller.dart';
import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';
@ -16,8 +19,9 @@ class IdentityVerificationStep extends StatelessWidget {
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final mainController = Get.find<FormRegistrationController>(); final mainController = Get.find<FormRegistrationController>();
final controller = Get.find<IdentityVerificationController>(); final controller = Get.find<IdentityVerificationController>();
// final personalInfoController = Get.find<PersonalInfoController>();
Get.find<SelfieVerificationController>(); // final idCardController = Get.find<IdCardVerificationController>();
// final selfieController = Get.find<SelfieVerificationController>();
mainController.formKey = formKey; mainController.formKey = formKey;
@ -39,40 +43,14 @@ class IdentityVerificationStep extends StatelessWidget {
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Verification Progress Card // Verification Progress Card
GetBuilder<IdentityVerificationController>( // GetBuilder<IdentityVerificationController>(
builder: (ctrl) => _buildVerificationProgressCard(ctrl), // builder: (ctrl) => _buildVerificationProgressCard(ctrl),
), // ),
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Form section header // Data Summary Section
Container( _buildDataSummaryCards(context, isOfficer),
padding: const EdgeInsets.only(
top: TSizes.spaceBtwItems,
bottom: TSizes.sm,
),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.withOpacity(0.2), width: 1),
),
),
child: FormSectionHeader(
title: 'Confirm Identity Information',
subtitle:
isOfficer
? 'Please verify the pre-filled information from your KTA'
: 'Please verify the pre-filled information from your KTP',
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// ID Card Info Form (with pre-filled data)
IdInfoForm(controller: controller, isOfficer: isOfficer),
const SizedBox(height: TSizes.spaceBtwSections),
// Save & Submit Button
GetBuilder<IdentityVerificationController>( GetBuilder<IdentityVerificationController>(
id: 'saveButton', id: 'saveButton',
builder: builder:
@ -80,7 +58,7 @@ class IdentityVerificationStep extends StatelessWidget {
onPressed: onPressed:
ctrl.isSavingData.value ctrl.isSavingData.value
? null ? null
: () => _submitRegistrationData(ctrl, context), : () => _submitRegistrationData(controller, context),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary, backgroundColor: TColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@ -138,6 +116,223 @@ class IdentityVerificationStep extends StatelessWidget {
); );
} }
Widget _buildDataSummaryCards(BuildContext context, bool isOfficer) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// User Profile Information Summary
_buildUserProfileSummaryCard(context),
const SizedBox(height: TSizes.spaceBtwItems),
// ID Card Information Summary
_buildIdCardSummaryCard(context, isOfficer),
// Officer Information Card (if applicable)
if (isOfficer) ...[
const SizedBox(height: TSizes.spaceBtwItems),
_buildOfficerInfoSummaryCard(context),
],
const SizedBox(height: TSizes.spaceBtwSections),
// Divider to separate summary from the form
Divider(color: Colors.grey.withOpacity(0.3), thickness: 1),
const SizedBox(height: TSizes.spaceBtwItems),
],
);
}
// User Profile Information Summary Card (based on ProfileModel)
Widget _buildUserProfileSummaryCard(BuildContext context) {
final personalInfoController = Get.find<PersonalInfoController>();
final idCardController = Get.find<IdCardVerificationController>();
final selfieController = Get.find<SelfieVerificationController>();
return _buildSummaryCard(
context: context,
icon: Icons.person,
title: 'User Profile Information',
content: [
_buildInfoRow(
'First Name',
personalInfoController.firstNameController.text,
),
_buildInfoRow(
'Last Name',
personalInfoController.lastNameController.text,
),
_buildInfoRow('Phone', personalInfoController.phoneController.text),
_buildInfoRow(
'Bio',
personalInfoController.bioController.text.isEmpty
? 'Not provided'
: personalInfoController.bioController.text,
),
_buildInfoRow('Address', personalInfoController.addressController.text),
],
isVerified: true,
);
}
// ID Card Information Summary Card (based on KTP/KTA models)
Widget _buildIdCardSummaryCard(BuildContext context, bool isOfficer) {
final idCardController = Get.find<IdCardVerificationController>();
final List<Widget> idCardInfo = [];
String cardTitle = '';
if (isOfficer && idCardController.ktaModel.value != null) {
cardTitle = 'Police Officer Card (KTA)';
final kta = idCardController.ktaModel.value!;
idCardInfo.addAll([
_buildInfoRow('NRP', kta.formattedNrp),
_buildInfoRow('Name', kta.name),
_buildInfoRow('Police Unit', kta.policeUnit),
if (kta.extraData != null && kta.extraData!['pangkat'] != null)
_buildInfoRow('Rank', kta.extraData!['pangkat']),
]);
} else if (!isOfficer && idCardController.ktpModel.value != null) {
cardTitle = 'National ID (KTP)';
final ktp = idCardController.ktpModel.value!;
idCardInfo.addAll([
_buildInfoRow('NIK', ktp.formattedNik),
_buildInfoRow('Name', ktp.name),
_buildInfoRow('Birth Place', ktp.birthPlace),
_buildInfoRow('Birth Date', ktp.birthDate),
_buildInfoRow('Gender', ktp.gender),
]);
}
return _buildSummaryCard(
context: context,
icon: Icons.badge,
title: cardTitle,
content: idCardInfo,
isVerified: idCardController.isIdCardValid.value,
);
}
// Officer Information Summary Card (based on OfficerModel)
Widget _buildOfficerInfoSummaryCard(BuildContext context) {
final officerInfoController = Get.find<OfficerInfoController>();
final unitInfoController = Get.find<UnitInfoController>();
final selectedUnit = unitInfoController.availableUnits.firstWhereOrNull(
(unit) => unit.codeUnit == unitInfoController.unitIdController.text,
);
return _buildSummaryCard(
context: context,
icon: Icons.security,
title: 'Officer Information',
content: [
_buildInfoRow('NRP', officerInfoController.nrpController.text),
_buildInfoRow('Rank', officerInfoController.rankController.text),
_buildInfoRow('Position', unitInfoController.positionController.text),
_buildInfoRow(
'Unit',
selectedUnit?.name ?? unitInfoController.unitIdController.text,
),
],
isVerified: true,
);
}
// Generic summary card builder
Widget _buildSummaryCard({
required BuildContext context,
required IconData icon,
required String title,
required List<Widget> content,
required bool isVerified,
}) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
side: BorderSide(
color:
isVerified
? Colors.green.withOpacity(0.5)
: TColors.primary.withOpacity(0.5),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(TSizes.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
icon,
color: isVerified ? Colors.green : TColors.primary,
size: TSizes.iconMd,
),
const SizedBox(width: TSizes.sm),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isVerified ? Colors.green : TColors.primary,
),
),
const Spacer(),
Icon(
isVerified ? Icons.check_circle : Icons.info_outline,
color: isVerified ? Colors.green : TColors.warning,
size: TSizes.iconSm,
),
const SizedBox(width: TSizes.xs),
Text(
isVerified ? 'Verified' : 'Review',
style: TextStyle(
fontSize: TSizes.fontSizeXs,
color: isVerified ? Colors.green : TColors.warning,
),
),
],
),
const Divider(height: TSizes.spaceBtwItems),
// Content
...content,
],
),
),
);
}
// Helper to build info rows in summary cards
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: TSizes.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
),
Expanded(
child: Text(value, style: TextStyle(fontSize: TSizes.fontSizeSm)),
),
],
),
);
}
// Build Verification Progress Card // Build Verification Progress Card
Widget _buildVerificationProgressCard( Widget _buildVerificationProgressCard(
IdentityVerificationController controller, IdentityVerificationController controller,
@ -196,7 +391,7 @@ class IdentityVerificationStep extends StatelessWidget {
// ID Card status // ID Card status
_buildVerificationItem( _buildVerificationItem(
'ID Card Verification', 'ID Card Verification',
controller.isIdCardVerified.value, controller.extractedIdCardNumber!.isNotEmpty,
), ),
// Selfie status // Selfie status
@ -281,50 +476,10 @@ class IdentityVerificationStep extends StatelessWidget {
IdentityVerificationController controller, IdentityVerificationController controller,
BuildContext context, BuildContext context,
) async { ) async {
final formKey = FormRegistrationController().formKey;
// Validate form // Call saveRegistrationData with all the collected data
if (!controller.validate(formKey)) { controller.saveRegistrationData();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Please complete all required fields'),
backgroundColor: TColors.error,
),
);
return;
}
// Save registration data
// final result = await controller.saveRegistrationData();
// if (result) { // The rest of submission logic would be handled in the controller
// // Navigate to success page or show success dialog
// showDialog(
// context: context,
// barrierDismissible: false,
// builder:
// (context) => AlertDialog(
// title: Row(
// children: [
// Icon(Icons.check_circle, color: Colors.green),
// SizedBox(width: TSizes.sm),
// Text('Registration Successful'),
// ],
// ),
// content: Text(
// 'Your registration has been submitted successfully. You will be notified once your account is verified.',
// ),
// actions: [
// TextButton(
// onPressed: () {
// Navigator.of(context).pop();
// // Navigate to login or home page
// Get.offAllNamed('/login');
// },
// child: Text('Go to Login'),
// ),
// ],
// ),
// );
// }
} }
} }

View File

@ -8,7 +8,6 @@ import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-ve
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/captured_selfie_view.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/captured_selfie_view.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/debug_panel.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/debug_panel.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/instruction_banner.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart';
import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart'; import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
@ -54,7 +53,8 @@ class LivenessDetectionPage extends StatelessWidget {
} }
}, },
child: Scaffold( child: Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.black,
extendBodyBehindAppBar: true, // Allow content behind app bar
appBar: _buildAppBar(context, controller, selfieController), appBar: _buildAppBar(context, controller, selfieController),
body: GetBuilder<FaceLivenessController>( body: GetBuilder<FaceLivenessController>(
builder: (_) { builder: (_) {
@ -70,7 +70,7 @@ class LivenessDetectionPage extends StatelessWidget {
return StateScreen( return StateScreen(
icon: Icons.camera_alt_outlined, icon: Icons.camera_alt_outlined,
title: 'Camera Error', title: 'Camera Error',
subtitle: 'Unable to access camera. Please try again later .', subtitle: 'Unable to access camera. Please try again later.',
); );
} }
@ -103,33 +103,30 @@ class LivenessDetectionPage extends StatelessWidget {
return AppBar( return AppBar(
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
backgroundColor: Colors.white, backgroundColor: Colors.transparent, // Transparent background
title: const Text( iconTheme: IconThemeData(color: Colors.white), // White icons
title: Text(
'Face Verification', 'Face Verification',
style: TextStyle( style: TextStyle(
color: Colors.black87, color: Colors.white,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 18, fontSize: 18,
), ),
), ),
// Add debug button // Debug button
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.bug_report, color: TColors.warning), icon: Icon(Icons.bug_report, color: Colors.white.withOpacity(0.8)),
onPressed: onPressed:
() => () =>
showLivenessDebugPanel(context, controller, selfieController), showLivenessDebugPanel(context, controller, selfieController),
), ),
], ],
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87), icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: () { onPressed: () {
dev.log('Back button pressed', name: 'LIVENESS_DEBUG'); dev.log('Back button pressed', name: 'LIVENESS_DEBUG');
// Ensure we clean up resources when going back
controller.handleCancellation(); controller.handleCancellation();
// This allows the user to go back to selfie verification step
Get.back(); Get.back();
}, },
), ),
@ -138,37 +135,29 @@ class LivenessDetectionPage extends StatelessWidget {
// Camera initializing state UI // Camera initializing state UI
Widget _buildCameraInitializingState(FaceLivenessController controller) { Widget _buildCameraInitializingState(FaceLivenessController controller) {
return Obx(() { return Container(
if (controller.status.value == LivenessStatus.detectingFace) { color: Colors.black,
return Center( child: Center(
child: Text(
'Camera initialized successfully!',
style: TextStyle(fontSize: 16, color: Colors.green),
),
);
}
return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator( CircularProgressIndicator(
color: TColors.primary, color: TColors.primary,
strokeWidth: 3, strokeWidth: 3,
), ),
const SizedBox(height: 24), SizedBox(height: 24),
Text( Text(
'Initializing camera...', 'Initializing camera...',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.black87, color: Colors.white70,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
], ],
), ),
); ),
}); );
} }
// Main detection view UI with the new layout structure // Main detection view UI with the new layout structure
@ -179,34 +168,27 @@ class LivenessDetectionPage extends StatelessWidget {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.of(context).size;
return Stack( return Stack(
fit: StackFit.expand,
children: [ children: [
// Main content area with specified layout structure // Full screen camera preview
Column( CameraPreviewWidget(
children: [ controller: controller,
// 1. Header with instructions (smaller to give more space to camera) screenWidth: screenSize.width,
Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: InstructionBanner(controller: controller),
),
// 2. Content area with camera preview (expanded to fill available space)
Expanded(
flex: 8, // Give most of the space to camera
child: CameraPreviewWidget(
controller: controller,
screenWidth: screenSize.width,
),
),
// 3. Bottom verification progress list (small fixed height)
Container(
padding: const EdgeInsets.only(bottom: 16),
child: VerificationProgressWidget(controller: controller),
),
],
), ),
// Overlay components // Progress indicator at the bottom
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.only(bottom: 20),
color: Colors.black.withOpacity(0.5),
child: VerificationProgressWidget(controller: controller),
),
),
// Overlay components including countdown
CountdownOverlayWidget(controller: controller), CountdownOverlayWidget(controller: controller),
], ],
); );

View File

@ -1,11 +1,12 @@
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
// Enum for bracket positions
enum BracketPosition { topLeft, topRight, bottomLeft, bottomRight }
class CameraPreviewWidget extends StatelessWidget { class CameraPreviewWidget extends StatelessWidget {
final FaceLivenessController controller; final FaceLivenessController controller;
final double screenWidth; final double screenWidth;
@ -18,234 +19,199 @@ class CameraPreviewWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double screenHeight = MediaQuery.of(context).size.height; final screenHeight = MediaQuery.of(context).size.height;
// Calculate available height for camera preview
final double availableHeight =
screenHeight * 0.6; // Use 60% of screen height
// Use the smallest dimension to ensure a square preview
final double previewSize =
availableHeight < screenWidth
? availableHeight
: screenWidth * 0.92; // Use 92% of screen width if height is large
return Obx(() { return Obx(() {
// Check if controller is disposed // Check if controller is disposed
if (controller.status.value == LivenessStatus.dispose) { if (controller.status.value == LivenessStatus.dispose) {
return Container( return _buildCameraPlaceholder(context, "Camera is not available");
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: previewSize,
height: previewSize,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
TablerIcons.camera_off,
size: 48,
color: Colors.grey.shade600,
),
const SizedBox(height: 16),
Text(
'Camera is not available',
style: TextStyle(color: Colors.grey.shade700),
),
],
),
),
),
);
} }
final bool isInitialized = final bool isInitialized =
controller.cameraController?.value.isInitialized ?? false; controller.cameraController?.value.isInitialized ?? false;
final bool isActive =
true; // Always show camera when controller is initialized
final bool isCountdown = final bool isCountdown =
controller.status.value == LivenessStatus.countdown; controller.status.value == LivenessStatus.countdown;
return Container( if (!isInitialized) {
width: double.infinity, return _buildCameraPlaceholder(context, "Camera is initializing...");
padding: const EdgeInsets.symmetric(horizontal: 16), }
child: Stack(
alignment: Alignment.center, // Calculate aspect ratio to maintain camera feed proportions
children: [ final cameraRatio = controller.cameraController!.value.aspectRatio;
// Camera frame/background
Container( // Full screen camera preview with overlays
width: previewSize, return Stack(
height: previewSize, children: [
// Camera feed takes the full available width and adjusts height based on aspect ratio
Positioned.fill(
child: ClipRRect(
child: Transform.scale(
scale: 1.1, // Slight zoom for better framing
child: AspectRatio(
aspectRatio: 1 / cameraRatio,
child: CameraPreview(controller.cameraController!),
),
),
),
),
// Dark overlay for better visibility of UI elements
Positioned.fill(
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05), gradient: LinearGradient(
borderRadius: BorderRadius.circular(24), begin: Alignment.topCenter,
border: Border.all(color: Colors.grey.shade300, width: 2), end: Alignment.bottomCenter,
boxShadow: [ colors: [
BoxShadow( Colors.black.withOpacity(0.3),
color: Colors.black.withOpacity(0.1), Colors.transparent,
blurRadius: 8, Colors.transparent,
spreadRadius: 0, Colors.black.withOpacity(0.3),
), ],
], stops: const [0.0, 0.25, 0.75, 1.0],
),
),
),
),
// Face outline guide with corner brackets
Center(child: _buildFaceGuideWithBrackets(screenWidth)),
// Face indicator icon (shows when no face is detected)
if (!controller.isFaceInFrame.value)
Center(
child: Icon(
Icons.face,
color: Colors.white.withOpacity(0.7),
size: 48,
), ),
), ),
// Full camera feed // Instructions banner at the bottom of the screen (fixed position)
if (isInitialized && isActive) Positioned(
SizedBox( bottom: screenHeight * 0.15, // Position from bottom
width: previewSize, left: 0,
height: previewSize, right: 0,
child: ClipRRect( child: Center(
borderRadius: BorderRadius.circular(22), child: Container(
child: Stack( margin: EdgeInsets.symmetric(horizontal: 20),
fit: StackFit.expand, padding: EdgeInsets.symmetric(vertical: 12, horizontal: 20),
children: [
Center(
child: SizedBox(
width: previewSize,
height: previewSize,
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width:
previewSize /
controller
.cameraController!
.value
.aspectRatio,
height: previewSize,
child: CameraPreview(
controller.cameraController!,
),
),
),
),
),
// Overlay for better face visibility
Container(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.transparent,
Colors.black.withOpacity(0.2),
],
stops: const [0.7, 1.0],
center: Alignment.center,
radius: 0.9,
),
),
),
],
),
),
)
else
// Show placeholder when camera is not active
Container(
width: previewSize,
height: previewSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05), color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(12),
), ),
child: Center( child: Row(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Icon(_getInstructionIcon(), color: Colors.white, size: 22),
Icon( SizedBox(width: 12),
Icons.camera_alt_outlined, Flexible(
size: 48, child: Text(
color: Colors.grey.shade600, _getActionText(),
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(height: 16),
Text(
'Camera is initializing...',
style: TextStyle(color: Colors.grey.shade700),
),
],
),
),
),
// Scanning animation when not in countdown
if (isInitialized && isActive && !isCountdown)
Positioned(
top: previewSize * 0.2, // Position at 20% from the top
child: _buildScanningAnimation(previewSize),
),
// Face guide overlay
if (isInitialized && isActive)
Center(
child: Container(
width:
previewSize *
0.7, // Make face guide 70% of the camera preview
height: previewSize * 0.7,
decoration: BoxDecoration(
border: Border.all(
color: _getFaceGuideColor(),
width: 2.5,
strokeAlign: BorderSide.strokeAlignOutside,
), ),
shape: BoxShape.circle, ],
),
child: Obx(
() =>
controller.isFaceInFrame.value
? Center()
: Center(
child: Icon(
Icons.face,
color: Colors.white.withOpacity(0.7),
size: 48,
),
),
),
), ),
), ),
),
// Instructions overlay ),
if (isInitialized && isActive && !isCountdown) ],
Positioned(
bottom: 20,
child: Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
child: Text(
_getActionText(),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
); );
}); });
} }
// Build scanning animation widget // Build face guide with corner brackets
Widget _buildScanningAnimation(double previewSize) { Widget _buildFaceGuideWithBrackets(double screenWidth) {
final guideWidth = screenWidth * 0.7;
final guideHeight =
guideWidth * 1.2; // Make it slightly taller than wide for face
final color = _getFaceGuideColor();
return SizedBox(
width: guideWidth,
height: guideHeight,
child: Stack(
children: [
// Invisible container to define the area
Container(
width: guideWidth,
height: guideHeight,
decoration: BoxDecoration(color: Colors.transparent),
),
// Top-left corner
Positioned(
top: 0,
left: 0,
child: _buildCornerBracket(color, BracketPosition.topLeft),
),
// Top-right corner
Positioned(
top: 0,
right: 0,
child: _buildCornerBracket(color, BracketPosition.topRight),
),
// Bottom-left corner
Positioned(
bottom: 0,
left: 0,
child: _buildCornerBracket(color, BracketPosition.bottomLeft),
),
// Bottom-right corner
Positioned(
bottom: 0,
right: 0,
child: _buildCornerBracket(color, BracketPosition.bottomRight),
),
],
),
);
}
// Build a corner bracket
Widget _buildCornerBracket(Color color, BracketPosition position) {
final bracketThickness = 3.0;
final bracketLength = 25.0;
return SizedBox(
width: bracketLength,
height: bracketLength,
child: CustomPaint(
painter: CornerBracketPainter(
color: color,
strokeWidth: bracketThickness,
position: position,
),
),
);
}
// Placeholder widget when camera isn't available/initialized
Widget _buildCameraPlaceholder(BuildContext context, String message) {
return Container( return Container(
width: previewSize * 0.8, color: Colors.black,
height: 3, child: Center(
decoration: BoxDecoration( child: Column(
gradient: LinearGradient( mainAxisSize: MainAxisSize.min,
colors: [ children: [
Colors.transparent, Icon(
TColors.primary.withOpacity(0.8), Icons.camera_alt_outlined,
Colors.transparent, size: 48,
color: Colors.white.withOpacity(0.7),
),
const SizedBox(height: 16),
Text(
message,
style: TextStyle(color: Colors.white.withOpacity(0.7)),
),
], ],
), ),
), ),
@ -256,7 +222,7 @@ class CameraPreviewWidget extends StatelessWidget {
String _getActionText() { String _getActionText() {
switch (controller.status.value) { switch (controller.status.value) {
case LivenessStatus.detectingFace: case LivenessStatus.detectingFace:
return 'Position your face within the circle'; return 'Position your face within the frame';
case LivenessStatus.checkLeftRotation: case LivenessStatus.checkLeftRotation:
return 'Turn your head to the left'; return 'Turn your head to the left';
case LivenessStatus.checkRightRotation: case LivenessStatus.checkRightRotation:
@ -267,11 +233,34 @@ class CameraPreviewWidget extends StatelessWidget {
return 'Keep your eyes open'; return 'Keep your eyes open';
case LivenessStatus.readyForPhoto: case LivenessStatus.readyForPhoto:
return 'Perfect! Hold still'; return 'Perfect! Hold still';
case LivenessStatus.countdown:
return 'Please hold your pose';
default: default:
return 'Follow instructions'; return 'Follow instructions';
} }
} }
// Get appropriate icon for instructions
IconData _getInstructionIcon() {
switch (controller.status.value) {
case LivenessStatus.detectingFace:
return Icons.face;
case LivenessStatus.checkLeftRotation:
return Icons.rotate_left;
case LivenessStatus.checkRightRotation:
return Icons.rotate_right;
case LivenessStatus.checkSmile:
return Icons.sentiment_satisfied_alt;
case LivenessStatus.checkEyesOpen:
return Icons.remove_red_eye;
case LivenessStatus.readyForPhoto:
case LivenessStatus.countdown:
return Icons.photo_camera;
default:
return Icons.info;
}
}
// Function to color the face guide based on detection state // Function to color the face guide based on detection state
Color _getFaceGuideColor() { Color _getFaceGuideColor() {
if (controller.status.value == LivenessStatus.countdown) { if (controller.status.value == LivenessStatus.countdown) {
@ -281,7 +270,60 @@ class CameraPreviewWidget extends StatelessWidget {
? Colors.green ? Colors.green
: TColors.primary; : TColors.primary;
} else { } else {
return Colors.white.withOpacity(0.7); return Colors.white;
} }
} }
} }
// Custom painter for corner brackets
class CornerBracketPainter extends CustomPainter {
final Color color;
final double strokeWidth;
final BracketPosition position;
CornerBracketPainter({
required this.color,
required this.strokeWidth,
required this.position,
});
@override
void paint(Canvas canvas, Size size) {
final paint =
Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.square
..style = PaintingStyle.stroke;
final path = Path();
switch (position) {
case BracketPosition.topLeft:
path.moveTo(0, size.height * 0.4);
path.lineTo(0, 0);
path.lineTo(size.width * 0.4, 0);
break;
case BracketPosition.topRight:
path.moveTo(size.width * 0.6, 0);
path.lineTo(size.width, 0);
path.lineTo(size.width, size.height * 0.4);
break;
case BracketPosition.bottomLeft:
path.moveTo(0, size.height * 0.6);
path.lineTo(0, size.height);
path.lineTo(size.width * 0.4, size.height);
break;
case BracketPosition.bottomRight:
path.moveTo(size.width * 0.6, size.height);
path.lineTo(size.width, size.height);
path.lineTo(size.width, size.height * 0.6);
break;
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class CountdownOverlayWidget extends StatelessWidget { class CountdownOverlayWidget extends StatelessWidget {
final FaceLivenessController controller; final FaceLivenessController controller;
@ -9,45 +9,55 @@ class CountdownOverlayWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Directly access the observable values without additional wrapping return Obx(() {
final isCountdown = controller.status.value == LivenessStatus.countdown; final isCountdown = controller.status.value == LivenessStatus.countdown;
final countdownValue = controller.countdownSeconds.value;
if (!isCountdown) { if (!isCountdown) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return _buildMinimalistCountdown(context);
});
}
Widget _buildMinimalistCountdown(BuildContext context) {
return Positioned.fill( return Positioned.fill(
child: Container( child: Center(
color: Colors.black.withOpacity(0.3), child: Obx(() {
child: Center( final countdownValue = controller.countdownSeconds.value;
child: AnimatedScale(
scale: isCountdown ? 1.0 : 0.0, return AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: Container( transitionBuilder: (Widget child, Animation<double> animation) {
width: 120, return ScaleTransition(
height: 120, scale: Tween<double>(begin: 0.8, end: 1.0).animate(
decoration: BoxDecoration( CurvedAnimation(
color: Colors.black.withOpacity(0.7), parent: animation,
shape: BoxShape.circle, curve: Curves.easeOutCubic,
border: Border.all(
color: TColors.primary.withOpacity(0.5),
width: 3,
),
),
child: Center(
child: Text(
'$countdownValue',
style: const TextStyle(
color: Colors.white,
fontSize: 60,
fontWeight: FontWeight.bold,
), ),
), ),
child: FadeTransition(opacity: animation, child: child),
);
},
child: Text(
'$countdownValue',
key: ValueKey<int>(countdownValue),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w300,
fontSize: 120,
height: 1.0,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
), ),
), ),
), );
), }),
), ),
); );
} }

View File

@ -15,88 +15,105 @@ class VerificationProgressWidget extends StatelessWidget {
final completedSteps = controller.successfulSteps; final completedSteps = controller.successfulSteps;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Progress indicator // Progress indicator
LinearProgressIndicator( Stack(
value: steps.isEmpty ? 0 : completedSteps.length / steps.length, children: [
backgroundColor: Colors.grey.shade200, // Background of the progress bar
color: TColors.primary, Container(
minHeight: 6, width: double.infinity,
borderRadius: BorderRadius.circular(3), height: 6,
),
const SizedBox(height: 12),
// Completed steps
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(steps.length, (index) {
final isCompleted = index < controller.currentStepIndex;
final isInProgress = index == controller.currentStepIndex;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: Colors.white.withOpacity(0.3),
isCompleted borderRadius: BorderRadius.circular(3),
? Colors.green.withOpacity(0.1) ),
: isInProgress ),
? TColors.primary.withOpacity(0.1)
: Colors.grey.withOpacity(0.1), // Filled part of the progress bar
borderRadius: BorderRadius.circular(12), FractionallySizedBox(
border: Border.all( widthFactor:
color: steps.isEmpty ? 0 : completedSteps.length / steps.length,
isCompleted child: Container(
? Colors.green.withOpacity(0.3) height: 6,
: isInProgress decoration: BoxDecoration(
? TColors.primary.withOpacity(0.3) gradient: LinearGradient(
: Colors.grey.withOpacity(0.3), colors: [TColors.primary, Colors.greenAccent],
),
borderRadius: BorderRadius.circular(3),
boxShadow: [
BoxShadow(
color: TColors.primary.withOpacity(0.5),
blurRadius: 6,
spreadRadius: 0,
),
],
), ),
), ),
child: Row( ),
mainAxisSize: MainAxisSize.min, ],
children: [ ),
Icon(
isCompleted const SizedBox(height: 12),
? Icons.check_circle
: isInProgress // Completed steps as a row of chips
? Icons.timelapse SingleChildScrollView(
: Icons.circle_outlined, scrollDirection: Axis.horizontal,
size: 16, physics: BouncingScrollPhysics(),
color: child: Row(
isCompleted children: List.generate(steps.length, (index) {
? Colors.green final isCompleted = index < controller.currentStepIndex;
: isInProgress final isInProgress = index == controller.currentStepIndex;
? TColors.primary
: Colors.grey, return Padding(
padding: const EdgeInsets.only(right: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
), ),
const SizedBox(width: 6), decoration: BoxDecoration(
Text( color: _getStepBackgroundColor(
_getShortStepName(steps[index]), isCompleted,
style: TextStyle( isInProgress,
fontSize: 12, ),
color: borderRadius: BorderRadius.circular(16),
isCompleted border: Border.all(
? Colors.green color: _getStepBorderColor(isCompleted, isInProgress),
: isInProgress width: 1.5,
? TColors.primary
: Colors.grey,
fontWeight:
isInProgress
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], child: Row(
), mainAxisSize: MainAxisSize.min,
); children: [
}), Icon(
_getStepIcon(isCompleted, isInProgress, index),
size: 16,
color: _getStepIconColor(isCompleted, isInProgress),
),
const SizedBox(width: 6),
Text(
_getShortStepName(steps[index]),
style: TextStyle(
fontSize: 13,
fontWeight:
isInProgress
? FontWeight.bold
: FontWeight.normal,
color: _getStepTextColor(
isCompleted,
isInProgress,
),
),
),
],
),
),
);
}),
),
), ),
], ],
), ),
@ -104,6 +121,73 @@ class VerificationProgressWidget extends StatelessWidget {
}); });
} }
// Get step background color based on state
Color _getStepBackgroundColor(bool isCompleted, bool isInProgress) {
if (isCompleted) {
return Colors.green.withOpacity(0.2);
} else if (isInProgress) {
return TColors.primary.withOpacity(0.2);
} else {
return Colors.white.withOpacity(0.1);
}
}
// Get step border color based on state
Color _getStepBorderColor(bool isCompleted, bool isInProgress) {
if (isCompleted) {
return Colors.green.withOpacity(0.6);
} else if (isInProgress) {
return TColors.primary.withOpacity(0.6);
} else {
return Colors.white.withOpacity(0.3);
}
}
// Get step icon based on state and index
IconData _getStepIcon(bool isCompleted, bool isInProgress, int index) {
if (isCompleted) {
return Icons.check_circle;
} else if (isInProgress) {
return Icons.timelapse;
}
// Different icons for different steps
switch (index) {
case 0:
return Icons.rotate_left;
case 1:
return Icons.rotate_right;
case 2:
return Icons.sentiment_satisfied_alt;
case 3:
return Icons.remove_red_eye;
default:
return Icons.circle_outlined;
}
}
// Get step icon color
Color _getStepIconColor(bool isCompleted, bool isInProgress) {
if (isCompleted) {
return Colors.green;
} else if (isInProgress) {
return TColors.primary;
} else {
return Colors.white.withOpacity(0.7);
}
}
// Get step text color
Color _getStepTextColor(bool isCompleted, bool isInProgress) {
if (isCompleted) {
return Colors.green;
} else if (isInProgress) {
return TColors.primary;
} else {
return Colors.white.withOpacity(0.7);
}
}
// Get shorter step names for chips // Get shorter step names for chips
String _getShortStepName(String step) { String _getShortStepName(String step) {
if (step.contains('left')) return 'Look Left'; if (step.contains('left')) return 'Look Left';

View File

@ -55,7 +55,7 @@ class OfficerModel {
phone: json['phone'] as String?, phone: json['phone'] as String?,
email: json['email'] as String?, email: json['email'] as String?,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String?,
placeOfBirth: json['place_of_birth'] as String?, placeOfBirth: json['birth_place'] as String?,
dateOfBirth: dateOfBirth:
json['date_of_birth'] != null json['date_of_birth'] != null
? DateTime.parse(json['date_of_birth'] as String) ? DateTime.parse(json['date_of_birth'] as String)
@ -94,7 +94,7 @@ class OfficerModel {
'phone': phone, 'phone': phone,
'email': email, 'email': email,
'avatar': avatar, 'avatar': avatar,
'place_of_birth': placeOfBirth, 'birth_place': placeOfBirth,
'date_of_birth': dateOfBirth?.toIso8601String(), 'date_of_birth': dateOfBirth?.toIso8601String(),
'valid_until': validUntil?.toIso8601String(), 'valid_until': validUntil?.toIso8601String(),
'qr_code': qrCode, 'qr_code': qrCode,
@ -165,7 +165,7 @@ class OfficerModel {
phone: officerData['phone'], phone: officerData['phone'],
email: metadata['email'], email: metadata['email'],
avatar: officerData['avatar'], avatar: officerData['avatar'],
placeOfBirth: officerData['place_of_birth'], placeOfBirth: officerData['birth_place'],
dateOfBirth: dateOfBirth:
officerData['date_of_birth'] != null officerData['date_of_birth'] != null
? DateTime.parse(officerData['date_of_birth']) ? DateTime.parse(officerData['date_of_birth'])

View File

@ -1,7 +1,7 @@
class ProfileModel { class ProfileModel {
final String id; final String id;
final String userId; final String userId;
final String nik; final String? nik;
final String? avatar; final String? avatar;
final String? username; final String? username;
final String? firstName; final String? firstName;
@ -31,7 +31,7 @@ class ProfileModel {
return ProfileModel( return ProfileModel(
id: json['id'] as String, id: json['id'] as String,
userId: json['user_id'] as String, userId: json['user_id'] as String,
nik: json['nik'] as String, nik: json['nik'] as String?,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String?,
username: json['username'] as String?, username: json['username'] as String?,
firstName: json['first_name'] as String?, firstName: json['first_name'] as String?,
@ -41,7 +41,7 @@ class ProfileModel {
json['address'] != null json['address'] != null
? Map<String, dynamic>.from(json['address'] as Map) ? Map<String, dynamic>.from(json['address'] as Map)
: null, : null,
placeOfBirth: json['place_of_birth'] as String?, placeOfBirth: json['birth_place'] as String?,
birthDate: birthDate:
json['birth_date'] != null json['birth_date'] != null
? DateTime.parse(json['birth_date'] as String) ? DateTime.parse(json['birth_date'] as String)
@ -61,7 +61,7 @@ class ProfileModel {
'last_name': lastName, 'last_name': lastName,
'bio': bio, 'bio': bio,
'address': address, 'address': address,
'place_of_birth': placeOfBirth, 'birth_place': placeOfBirth,
'birth_date': birthDate?.toIso8601String(), 'birth_date': birthDate?.toIso8601String(),
}; };
} }

View File

@ -142,7 +142,7 @@ class UserMetadataModel {
/// Convert to specialized JSON for profile completion /// Convert to specialized JSON for profile completion
Map<String, dynamic> toProfileCompletionJson() { Map<String, dynamic> toProfileCompletionJson() {
return { return {
'profile_status': 'complete', 'profile_status': 'completed',
'is_officer': isOfficer, 'is_officer': isOfficer,
if (name != null) 'name': name, if (name != null) 'name': name,
if (phone != null) 'phone': phone, if (phone != null) 'phone': phone,

View File

@ -109,8 +109,8 @@ class UserModel {
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'banned_until': bannedUntil?.toIso8601String(), 'banned_until': bannedUntil?.toIso8601String(),
'is_anonymous': isAnonymous, 'is_anonymous': isAnonymous,
if (profile != null) 'profiles': profile!.toJson(), // if (profile != null) 'profiles': profile!.toJson(),
if (role != null) 'role': role!.toJson(), // if (role != null) 'role': role!.toJson(),
}; };
} }

View File

@ -56,9 +56,7 @@ class ProfileRepository extends GetxController {
final fileName = final fileName =
'${currentUserId}_${DateTime.now().millisecondsSinceEpoch}.jpg'; '${currentUserId}_${DateTime.now().millisecondsSinceEpoch}.jpg';
await _supabase.storage await _supabase.storage.from('avatars').upload(fileName, File(filePath));
.from('avatars')
.upload(fileName, File(filePath));
final avatarUrl = _supabase.storage final avatarUrl = _supabase.storage
.from('avatars') .from('avatars')
@ -84,42 +82,23 @@ class ProfileRepository extends GetxController {
} }
// Update profile // Update profile
Future<ProfileModel> updateProfile({ Future<ProfileModel> updateProfile(ProfileModel profile) async {
String? firstName,
String? lastName,
String? bio,
Map<String, dynamic>? address,
DateTime? birthDate,
String? avatar,
String? username,
}) async {
try { try {
if (currentUserId == null) { if (currentUserId == null) {
throw 'User not authenticated'; throw 'User not authenticated';
} }
// Build update data object final profileData = profile.toJson();
final Map<String, dynamic> updateData = {};
if (firstName != null) updateData['first_name'] = firstName; final updatedProfile =
if (lastName != null) updateData['last_name'] = lastName; await _supabase
if (bio != null) updateData['bio'] = bio; .from('profiles')
if (address != null) updateData['address'] = address; .update(profileData)
if (birthDate != null) .eq('user_id', currentUserId!)
updateData['birth_date'] = birthDate.toIso8601String(); .select()
if (avatar != null) updateData['avatar'] = avatar; .single();
if (username != null) updateData['username'] = username;
// Only update if there's data to update return ProfileModel.fromJson(updatedProfile);
if (updateData.isNotEmpty) {
await _supabase
.from('profiles')
.update(updateData)
.eq('user_id', currentUserId!);
}
// Fetch and return updated profile
return await getProfileData();
} on PostgrestException catch (error) { } on PostgrestException catch (error) {
_logger.e('PostgrestException in updateProfile: ${error.message}'); _logger.e('PostgrestException in updateProfile: ${error.message}');
throw TExceptions.fromCode(error.code ?? 'unknown-error'); throw TExceptions.fromCode(error.code ?? 'unknown-error');
@ -194,7 +173,7 @@ class ProfileRepository extends GetxController {
throw 'Failed to fetch profile data: ${e.toString()}'; throw 'Failed to fetch profile data: ${e.toString()}';
} }
} }
// Get profile by NIK // Get profile by NIK
Future<ProfileModel?> getProfileByNIK(String nik) async { Future<ProfileModel?> getProfileByNIK(String nik) async {
try { try {

View File

@ -13,12 +13,12 @@ class UserRepository extends GetxController {
static UserRepository get instance => Get.find(); static UserRepository get instance => Get.find();
final _supabase = SupabaseService.instance.client; final _supabase = SupabaseService.instance.client;
final _logger = Get.find<Logger>(); final _logger = Get.put(Logger());
// Get current user ID // Get current user ID
String? get currentUserId => SupabaseService.instance.currentUserId; String? get currentUserId => SupabaseService.instance.currentUserId;
// Check if user is authenticated // Check if user is aPuthenticated
bool get isAuthenticated => currentUserId != null; bool get isAuthenticated => currentUserId != null;
// Check if user is an officer // Check if user is an officer
@ -42,7 +42,7 @@ class UserRepository extends GetxController {
.select('role:roles(name)') .select('role:roles(name)')
.eq('id', currentUserId!) .eq('id', currentUserId!)
.single(); .single();
final roleName = final roleName =
(userData['role'] as Map<String, dynamic>)['name'] as String; (userData['role'] as Map<String, dynamic>)['name'] as String;
return roleName.toLowerCase() == 'officer'; return roleName.toLowerCase() == 'officer';
@ -83,6 +83,32 @@ class UserRepository extends GetxController {
} }
} }
Future<UserModel?> updateUser(UserModel user) async {
try {
if (!isAuthenticated) {
throw 'User not authenticated';
}
final userData = user.toJson();
// Update user data in database
await _supabase.from('users').update(userData).eq('id', user.id);
// Return updated user model
return UserModel.fromJson(userData);
} on PostgrestException catch (error) {
_logger.e('PostgrestException in updateUser: ${error.message}');
throw TExceptions.fromCode(error.code ?? 'unknown-error');
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
_logger.e('Exception in updateUser: $e');
throw 'Failed to update user data: ${e.toString()}';
}
}
// Update user metadata // Update user metadata
Future<void> updateUserMetadata(Map<String, dynamic> metadata) async { Future<void> updateUserMetadata(Map<String, dynamic> metadata) async {
try { try {
@ -91,7 +117,6 @@ class UserRepository extends GetxController {
} }
await _supabase.auth.updateUser(UserAttributes(data: metadata)); await _supabase.auth.updateUser(UserAttributes(data: metadata));
} on AuthException catch (e) { } on AuthException catch (e) {
_logger.e('AuthException in updateUserMetadata: ${e.message}'); _logger.e('AuthException in updateUserMetadata: ${e.message}');
throw TExceptions(e.message); throw TExceptions(e.message);
@ -141,7 +166,6 @@ class UserRepository extends GetxController {
.from('users') .from('users')
.update({'phone': newPhone}) .update({'phone': newPhone})
.eq('id', currentUserId!); .eq('id', currentUserId!);
} on AuthException catch (e) { } on AuthException catch (e) {
_logger.e('AuthException in updateUserPhone: ${e.message}'); _logger.e('AuthException in updateUserPhone: ${e.message}');
throw TExceptions(e.message); throw TExceptions(e.message);
@ -162,7 +186,6 @@ class UserRepository extends GetxController {
} }
await _supabase.auth.updateUser(UserAttributes(password: newPassword)); await _supabase.auth.updateUser(UserAttributes(password: newPassword));
} on AuthException catch (e) { } on AuthException catch (e) {
_logger.e('AuthException in updateUserPassword: ${e.message}'); _logger.e('AuthException in updateUserPassword: ${e.message}');
throw TExceptions(e.message); throw TExceptions(e.message);
@ -263,6 +286,40 @@ class UserRepository extends GetxController {
} }
} }
// Get user by nik
Future<UserModel?> getUserByNik(String nik) async {
try {
final userData =
await _supabase
.from('users')
.select('*, profiles(*), role:roles(*)')
.eq('profiles.nik', nik)
.maybeSingle();
if (userData == null) return null;
return UserModel.fromJson(userData);
} catch (e) {
_logger.e('Exception in getUserByNik: $e');
return null;
}
}
Future<bool> isNikExists(String nik) async {
try {
final result = await _supabase
.from('profiles')
.select('id')
.eq('nik', nik)
.limit(1);
return result.isNotEmpty;
} catch (e) {
_logger.e('Exception in isNikExists: $e');
return false; // Assume NIK doesn't exist if error
}
}
// Chekch if email is already in use // Chekch if email is already in use
Future<bool> isEmailInUse(String email) async { Future<bool> isEmailInUse(String email) async {
try { try {
@ -283,7 +340,6 @@ class UserRepository extends GetxController {
} }
} }
// Search users by name/username/email // Search users by name/username/email
Future<List<UserModel>> searchUsers(String query, {int limit = 20}) async { Future<List<UserModel>> searchUsers(String query, {int limit = 20}) async {
try { try {

View File

@ -37,75 +37,82 @@ class StateScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: SingleChildScrollView( body: Center(
child: Padding( child: SingleChildScrollView(
padding: DSpacingStyle.paddingWithAppBarHeight * 2, child: Padding(
child: Center( padding: DSpacingStyle.paddingWithAppBarHeight,
child: Column( child: Container(
mainAxisAlignment: MainAxisAlignment.center, width: double.infinity,
children: [ constraints: BoxConstraints(
// Image, Icon, or Lottie maxWidth: THelperFunctions.screenWidth() * 0.9,
if (icon != null) ),
Icon( child: Column(
icon, mainAxisAlignment: MainAxisAlignment.center,
size: THelperFunctions.screenWidth() * 0.3, crossAxisAlignment: CrossAxisAlignment.center,
color: TColors.primary, children: [
) // Image, Icon, or Lottie
else if (isLottie == true && image != null) if (icon != null)
Lottie.asset( Icon(
image!, icon,
width: THelperFunctions.screenWidth() * 1.5, size: THelperFunctions.screenWidth() * 0.3,
) color: TColors.primary,
else if (image != null) )
Image( else if (isLottie == true && image != null)
image: AssetImage(image!), Lottie.asset(
width: THelperFunctions.screenWidth(), image!,
), width: THelperFunctions.screenWidth() * 0.8,
const SizedBox(height: TSizes.spaceBtwSections), )
else if (image != null)
// Title & subtitle Image(
Text( image: AssetImage(image!),
title, width: THelperFunctions.screenWidth() * 0.8,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: TSizes.spaceBtwItems),
Text(
subtitle,
style: Theme.of(context).textTheme.labelMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: TSizes.spaceBtwSections),
// Button
if (showButton)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onPressed,
child: Text(primaryButtonTitle),
), ),
), const SizedBox(height: TSizes.spaceBtwSections),
const SizedBox(height: TSizes.spaceBtwItems),
if (secondaryButton) // Title & subtitle
SizedBox( Text(
width: double.infinity, title,
child: OutlinedButton( style: Theme.of(context).textTheme.headlineMedium,
onPressed: onSecondaryPressed, textAlign: TextAlign.center,
style: ElevatedButton.styleFrom( ),
side: const BorderSide(color: Colors.transparent), const SizedBox(height: TSizes.spaceBtwItems),
), Text(
child: Text( subtitle,
secondaryTitle, style: Theme.of(context).textTheme.labelMedium,
style: const TextStyle(color: TColors.primary), textAlign: TextAlign.center,
),
const SizedBox(height: TSizes.spaceBtwSections),
// Button
if (showButton)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onPressed,
child: Text(primaryButtonTitle),
), ),
), ),
), const SizedBox(height: TSizes.spaceBtwItems),
// Additional child widget if (secondaryButton)
if (child != null) child!, SizedBox(
], width: double.infinity,
child: OutlinedButton(
onPressed: onSecondaryPressed,
style: ElevatedButton.styleFrom(
side: const BorderSide(color: Colors.transparent),
),
child: Text(
secondaryTitle,
style: const TextStyle(color: TColors.primary),
),
),
),
// Additional child widget
if (child != null) child!,
],
),
), ),
), ),
), ),