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

View File

@ -4,6 +4,7 @@ import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/data/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/identity-verification/identity_verification_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/personalization/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/features/personalization/data/repositories/profile_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/shared/widgets/state_screeen/state_screen.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';
class FormRegistrationController extends GetxController {
@ -225,6 +229,8 @@ class FormRegistrationController extends GetxController {
void _initializeControllers() {
final isOfficer = registrationData.value.isOfficer;
Logger().d('Initializing controllers with isOfficer: $isOfficer');
// Clear existing controllers first to prevent duplicates
_clearExistingControllers();
@ -716,7 +722,7 @@ class FormRegistrationController extends GetxController {
// Fixed missing parenthesis
currentStep.value++;
} else {
submitForm();
// submitForm();
}
}
@ -771,38 +777,192 @@ class FormRegistrationController extends GetxController {
}
}
// Submit the entire form using centralized registration data
Future<bool> submitForm() async {
if (!validateCurrentStep()) {
print('Form validation failed for step ${currentStep.value}');
return false;
}
// Update officer information
// void updateOfficerRegistrationData() async {
// try {
// isSubmitting.value = true;
// submitMessage.value = 'Submitting officer registration...';
if (currentStep.value < totalSteps - 1) {
nextStep();
return false;
}
// final isConnected = await NetworkManager.instance.isConnected();
// if (!isConnected) {
// 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 {
isSubmitting.value = true;
submitMessage.value = 'Submitting your registration...';
final result = await saveRegistrationData();
if (result) {
isSubmitSuccess.value = true;
submitMessage.value = 'Registration completed successfully!';
} else {
isSubmitSuccess.value = false;
submitMessage.value = 'Registration failed. Please try again.';
final isConnected = await NetworkManager.instance.isConnected();
if (!isConnected) {
TLoaders.errorSnackBar(
title: 'No Internet Connection',
message: 'Please check your internet connection and try again.',
);
isSubmitting.value = false;
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) {
print('Error submitting form: $e');
Logger().e('Error updating registration data: $e');
isSubmitSuccess.value = false;
submitMessage.value = 'Error during registration: $e';
return false;
submitMessage.value = 'Failed to complete registration: ${e.toString()}';
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to complete registration: ${e.toString()}',
);
} finally {
isSubmitting.value = false;
}

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:get/get.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/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/step/selfie-verification/facial_verification_controller.dart';
@ -545,60 +544,28 @@ class IdentityVerificationController extends GetxController {
}
// Save registration data using the centralized model
Future<bool> saveRegistrationData() async {
void saveRegistrationData() async {
try {
isSavingData.value = true;
dataSaveMessage.value = 'Saving your registration data...';
// Final validation
if (!validate(null)) {
dataSaveMessage.value = 'Please fix the errors before submitting';
return false;
// Call the appropriate registration update function based on user type
if (isOfficer) {
// For officers, use the officer registration update function
// mainController.updateOfficerRegistrationData();
}
// Update the registration data with identity verification data
final currentIdentityData = IdentityVerificationData(
nik: nikController.text,
fullName: fullNameController.text,
placeOfBirth: placeOfBirthController.text,
birthDate: birthDateController.text,
gender: selectedGender.value ?? '',
address: addressController.text,
);
// For regular users, call the three required updates
mainController.updateUserRegistrationData();
// 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) {
isDataSaved.value = false;
dataSaveMessage.value = 'Error saving registration data: $e';
print('Error saving registration data: $e');
return false;
Logger().e('Error saving registration data: $e');
} finally {
isSavingData.value = false;
}
}
@override
void onClose() {
// Dispose form controllers

View File

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

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.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/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/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/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/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
@ -16,8 +19,9 @@ class IdentityVerificationStep extends StatelessWidget {
final formKey = GlobalKey<FormState>();
final mainController = Get.find<FormRegistrationController>();
final controller = Get.find<IdentityVerificationController>();
Get.find<SelfieVerificationController>();
// final personalInfoController = Get.find<PersonalInfoController>();
// final idCardController = Get.find<IdCardVerificationController>();
// final selfieController = Get.find<SelfieVerificationController>();
mainController.formKey = formKey;
@ -39,40 +43,14 @@ class IdentityVerificationStep extends StatelessWidget {
const SizedBox(height: TSizes.spaceBtwItems),
// Verification Progress Card
GetBuilder<IdentityVerificationController>(
builder: (ctrl) => _buildVerificationProgressCard(ctrl),
),
// GetBuilder<IdentityVerificationController>(
// builder: (ctrl) => _buildVerificationProgressCard(ctrl),
// ),
const SizedBox(height: TSizes.spaceBtwItems),
// Form section header
Container(
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',
),
),
// Data Summary Section
_buildDataSummaryCards(context, isOfficer),
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>(
id: 'saveButton',
builder:
@ -80,7 +58,7 @@ class IdentityVerificationStep extends StatelessWidget {
onPressed:
ctrl.isSavingData.value
? null
: () => _submitRegistrationData(ctrl, context),
: () => _submitRegistrationData(controller, context),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
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
Widget _buildVerificationProgressCard(
IdentityVerificationController controller,
@ -196,7 +391,7 @@ class IdentityVerificationStep extends StatelessWidget {
// ID Card status
_buildVerificationItem(
'ID Card Verification',
controller.isIdCardVerified.value,
controller.extractedIdCardNumber!.isNotEmpty,
),
// Selfie status
@ -281,50 +476,10 @@ class IdentityVerificationStep extends StatelessWidget {
IdentityVerificationController controller,
BuildContext context,
) async {
final formKey = FormRegistrationController().formKey;
// Validate form
if (!controller.validate(formKey)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Please complete all required fields'),
backgroundColor: TColors.error,
),
);
return;
}
// Save registration data
// final result = await controller.saveRegistrationData();
// Call saveRegistrationData with all the collected data
controller.saveRegistrationData();
// if (result) {
// // 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'),
// ),
// ],
// ),
// );
// }
// The rest of submission logic would be handled in the controller
}
}

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/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/instruction_banner.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/utils/constants/colors.dart';
@ -54,7 +53,8 @@ class LivenessDetectionPage extends StatelessWidget {
}
},
child: Scaffold(
backgroundColor: Colors.white,
backgroundColor: Colors.black,
extendBodyBehindAppBar: true, // Allow content behind app bar
appBar: _buildAppBar(context, controller, selfieController),
body: GetBuilder<FaceLivenessController>(
builder: (_) {
@ -70,7 +70,7 @@ class LivenessDetectionPage extends StatelessWidget {
return StateScreen(
icon: Icons.camera_alt_outlined,
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(
elevation: 0,
centerTitle: true,
backgroundColor: Colors.white,
title: const Text(
backgroundColor: Colors.transparent, // Transparent background
iconTheme: IconThemeData(color: Colors.white), // White icons
title: Text(
'Face Verification',
style: TextStyle(
color: Colors.black87,
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
// Add debug button
// Debug button
actions: [
IconButton(
icon: Icon(Icons.bug_report, color: TColors.warning),
icon: Icon(Icons.bug_report, color: Colors.white.withOpacity(0.8)),
onPressed:
() =>
showLivenessDebugPanel(context, controller, selfieController),
),
],
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87),
icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
dev.log('Back button pressed', name: 'LIVENESS_DEBUG');
// Ensure we clean up resources when going back
controller.handleCancellation();
// This allows the user to go back to selfie verification step
Get.back();
},
),
@ -138,37 +135,29 @@ class LivenessDetectionPage extends StatelessWidget {
// Camera initializing state UI
Widget _buildCameraInitializingState(FaceLivenessController controller) {
return Obx(() {
if (controller.status.value == LivenessStatus.detectingFace) {
return Center(
child: Text(
'Camera initialized successfully!',
style: TextStyle(fontSize: 16, color: Colors.green),
),
);
}
return Center(
return Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(
CircularProgressIndicator(
color: TColors.primary,
strokeWidth: 3,
),
const SizedBox(height: 24),
SizedBox(height: 24),
Text(
'Initializing camera...',
style: TextStyle(
fontSize: 16,
color: Colors.black87,
color: Colors.white70,
fontWeight: FontWeight.w500,
),
),
],
),
);
});
),
);
}
// Main detection view UI with the new layout structure
@ -179,34 +168,27 @@ class LivenessDetectionPage extends StatelessWidget {
final screenSize = MediaQuery.of(context).size;
return Stack(
fit: StackFit.expand,
children: [
// Main content area with specified layout structure
Column(
children: [
// 1. Header with instructions (smaller to give more space to camera)
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),
),
],
// Full screen camera preview
CameraPreviewWidget(
controller: controller,
screenWidth: screenSize.width,
),
// 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),
],
);

View File

@ -1,11 +1,12 @@
import 'package:camera/camera.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:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
// Enum for bracket positions
enum BracketPosition { topLeft, topRight, bottomLeft, bottomRight }
class CameraPreviewWidget extends StatelessWidget {
final FaceLivenessController controller;
final double screenWidth;
@ -18,234 +19,199 @@ class CameraPreviewWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double 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
final screenHeight = MediaQuery.of(context).size.height;
return Obx(() {
// Check if controller is disposed
if (controller.status.value == LivenessStatus.dispose) {
return Container(
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),
),
],
),
),
),
);
return _buildCameraPlaceholder(context, "Camera is not available");
}
final bool isInitialized =
controller.cameraController?.value.isInitialized ?? false;
final bool isActive =
true; // Always show camera when controller is initialized
final bool isCountdown =
controller.status.value == LivenessStatus.countdown;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Stack(
alignment: Alignment.center,
children: [
// Camera frame/background
Container(
width: previewSize,
height: previewSize,
if (!isInitialized) {
return _buildCameraPlaceholder(context, "Camera is initializing...");
}
// Calculate aspect ratio to maintain camera feed proportions
final cameraRatio = controller.cameraController!.value.aspectRatio;
// Full screen camera preview with overlays
return Stack(
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(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.grey.shade300, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
spreadRadius: 0,
),
],
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.3),
Colors.transparent,
Colors.transparent,
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
if (isInitialized && isActive)
SizedBox(
width: previewSize,
height: previewSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
fit: StackFit.expand,
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,
// Instructions banner at the bottom of the screen (fixed position)
Positioned(
bottom: screenHeight * 0.15, // Position from bottom
left: 0,
right: 0,
child: Center(
child: Container(
margin: EdgeInsets.symmetric(horizontal: 20),
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 20),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(24),
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.camera_alt_outlined,
size: 48,
color: Colors.grey.shade600,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_getInstructionIcon(), color: Colors.white, size: 22),
SizedBox(width: 12),
Flexible(
child: Text(
_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
Widget _buildScanningAnimation(double previewSize) {
// Build face guide with corner brackets
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(
width: previewSize * 0.8,
height: 3,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
TColors.primary.withOpacity(0.8),
Colors.transparent,
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.camera_alt_outlined,
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() {
switch (controller.status.value) {
case LivenessStatus.detectingFace:
return 'Position your face within the circle';
return 'Position your face within the frame';
case LivenessStatus.checkLeftRotation:
return 'Turn your head to the left';
case LivenessStatus.checkRightRotation:
@ -267,11 +233,34 @@ class CameraPreviewWidget extends StatelessWidget {
return 'Keep your eyes open';
case LivenessStatus.readyForPhoto:
return 'Perfect! Hold still';
case LivenessStatus.countdown:
return 'Please hold your pose';
default:
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
Color _getFaceGuideColor() {
if (controller.status.value == LivenessStatus.countdown) {
@ -281,7 +270,60 @@ class CameraPreviewWidget extends StatelessWidget {
? Colors.green
: TColors.primary;
} 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:get/get.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 {
final FaceLivenessController controller;
@ -9,45 +9,55 @@ class CountdownOverlayWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Directly access the observable values without additional wrapping
final isCountdown = controller.status.value == LivenessStatus.countdown;
final countdownValue = controller.countdownSeconds.value;
return Obx(() {
final isCountdown = controller.status.value == LivenessStatus.countdown;
if (!isCountdown) {
return const SizedBox.shrink();
}
if (!isCountdown) {
return const SizedBox.shrink();
}
return _buildMinimalistCountdown(context);
});
}
Widget _buildMinimalistCountdown(BuildContext context) {
return Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
child: Center(
child: AnimatedScale(
scale: isCountdown ? 1.0 : 0.0,
child: Center(
child: Obx(() {
final countdownValue = controller.countdownSeconds.value;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
shape: BoxShape.circle,
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,
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(
scale: Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
),
),
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;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Progress indicator
LinearProgressIndicator(
value: steps.isEmpty ? 0 : completedSteps.length / steps.length,
backgroundColor: Colors.grey.shade200,
color: TColors.primary,
minHeight: 6,
borderRadius: BorderRadius.circular(3),
),
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,
),
Stack(
children: [
// Background of the progress bar
Container(
width: double.infinity,
height: 6,
decoration: BoxDecoration(
color:
isCompleted
? Colors.green.withOpacity(0.1)
: isInProgress
? TColors.primary.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
isCompleted
? Colors.green.withOpacity(0.3)
: isInProgress
? TColors.primary.withOpacity(0.3)
: Colors.grey.withOpacity(0.3),
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(3),
),
),
// Filled part of the progress bar
FractionallySizedBox(
widthFactor:
steps.isEmpty ? 0 : completedSteps.length / steps.length,
child: Container(
height: 6,
decoration: BoxDecoration(
gradient: LinearGradient(
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
? Icons.check_circle
: isInProgress
? Icons.timelapse
: Icons.circle_outlined,
size: 16,
color:
isCompleted
? Colors.green
: isInProgress
? TColors.primary
: Colors.grey,
),
],
),
const SizedBox(height: 12),
// Completed steps as a row of chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: BouncingScrollPhysics(),
child: Row(
children: List.generate(steps.length, (index) {
final isCompleted = index < controller.currentStepIndex;
final isInProgress = index == controller.currentStepIndex;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
const SizedBox(width: 6),
Text(
_getShortStepName(steps[index]),
style: TextStyle(
fontSize: 12,
color:
isCompleted
? Colors.green
: isInProgress
? TColors.primary
: Colors.grey,
fontWeight:
isInProgress
? FontWeight.bold
: FontWeight.normal,
decoration: BoxDecoration(
color: _getStepBackgroundColor(
isCompleted,
isInProgress,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _getStepBorderColor(isCompleted, isInProgress),
width: 1.5,
),
),
],
),
);
}),
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
String _getShortStepName(String step) {
if (step.contains('left')) return 'Look Left';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,12 +13,12 @@ class UserRepository extends GetxController {
static UserRepository get instance => Get.find();
final _supabase = SupabaseService.instance.client;
final _logger = Get.find<Logger>();
final _logger = Get.put(Logger());
// Get current user ID
String? get currentUserId => SupabaseService.instance.currentUserId;
// Check if user is authenticated
// Check if user is aPuthenticated
bool get isAuthenticated => currentUserId != null;
// Check if user is an 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
Future<void> updateUserMetadata(Map<String, dynamic> metadata) async {
try {
@ -91,7 +117,6 @@ class UserRepository extends GetxController {
}
await _supabase.auth.updateUser(UserAttributes(data: metadata));
} on AuthException catch (e) {
_logger.e('AuthException in updateUserMetadata: ${e.message}');
throw TExceptions(e.message);
@ -141,7 +166,6 @@ class UserRepository extends GetxController {
.from('users')
.update({'phone': newPhone})
.eq('id', currentUserId!);
} on AuthException catch (e) {
_logger.e('AuthException in updateUserPhone: ${e.message}');
throw TExceptions(e.message);
@ -162,7 +186,6 @@ class UserRepository extends GetxController {
}
await _supabase.auth.updateUser(UserAttributes(password: newPassword));
} on AuthException catch (e) {
_logger.e('AuthException in updateUserPassword: ${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
Future<bool> isEmailInUse(String email) async {
try {
@ -283,7 +340,6 @@ class UserRepository extends GetxController {
}
}
// Search users by name/username/email
Future<List<UserModel>> searchUsers(String query, {int limit = 20}) async {
try {

View File

@ -37,75 +37,82 @@ class StateScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Padding(
padding: DSpacingStyle.paddingWithAppBarHeight * 2,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Image, Icon, or Lottie
if (icon != null)
Icon(
icon,
size: THelperFunctions.screenWidth() * 0.3,
color: TColors.primary,
)
else if (isLottie == true && image != null)
Lottie.asset(
image!,
width: THelperFunctions.screenWidth() * 1.5,
)
else if (image != null)
Image(
image: AssetImage(image!),
width: THelperFunctions.screenWidth(),
),
const SizedBox(height: TSizes.spaceBtwSections),
// Title & subtitle
Text(
title,
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),
body: Center(
child: SingleChildScrollView(
child: Padding(
padding: DSpacingStyle.paddingWithAppBarHeight,
child: Container(
width: double.infinity,
constraints: BoxConstraints(
maxWidth: THelperFunctions.screenWidth() * 0.9,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Image, Icon, or Lottie
if (icon != null)
Icon(
icon,
size: THelperFunctions.screenWidth() * 0.3,
color: TColors.primary,
)
else if (isLottie == true && image != null)
Lottie.asset(
image!,
width: THelperFunctions.screenWidth() * 0.8,
)
else if (image != null)
Image(
image: AssetImage(image!),
width: THelperFunctions.screenWidth() * 0.8,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
const SizedBox(height: TSizes.spaceBtwSections),
if (secondaryButton)
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),
// Title & subtitle
Text(
title,
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.spaceBtwItems),
// Additional child widget
if (child != null) child!,
],
if (secondaryButton)
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!,
],
),
),
),
),