Add animation for empty address and implement custom SliverPersistentHeaderDelegate

- Added a new JSON animation file for empty address in the assets/images/animations directory.
- Created a custom SliverPersistentHeaderDelegate class for tab bar functionality in custom_silverbar.dart.
- Introduced a global form key management class to streamline form key usage across the application in form_key.dart.
This commit is contained in:
vergiLgood1 2025-05-22 09:42:28 +07:00
parent ac39366371
commit 7f6f0c40b7
36 changed files with 1977 additions and 904 deletions

File diff suppressed because it is too large Load Diff

View File

@ -13,22 +13,12 @@ class AnimatedSplashScreenWidget extends StatelessWidget {
Widget build(BuildContext context) {
final isDark = THelperFunctions.isDarkMode(context);
// Try to find SplashController, but don't fail if it's not ready yet
// SplashController? splashController;
// if (Get.isRegistered<SplashController>()) {
// splashController = Get.find<SplashController>();
// } else {
// // Register a temporary controller if the real one isn't ready
// splashController = Get.put(SplashController());
// }
return AnimatedSplashScreen(
splash: Center(
child: Lottie.asset(
isDark ? TImages.darkSplashApp : TImages.lightSplashApp,
frameRate: FrameRate.max,
repeat: true,
),
),
splashIconSize: 300,
@ -38,30 +28,3 @@ class AnimatedSplashScreenWidget extends StatelessWidget {
);
}
}
// A transition screen that shows a loading indicator
// until authentication is ready
// class _LoadingScreen extends StatelessWidget {
// const _LoadingScreen();
// @override
// Widget build(BuildContext context) {
// final isDark = THelperFunctions.isDarkMode(context);
// // This will be shown after the animated splash screen
// // while we wait for initialization to complete
// return Scaffold(
// backgroundColor: isDark ? TColors.dark : TColors.white,
// body: const Center(
// child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// CircularProgressIndicator(),
// SizedBox(height: 24),
// Text("Menyiapkan aplikasi..."),
// ],
// ),
// ),
// );
// }
// }

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
@ -39,6 +40,11 @@ class AppPages {
page: () => const SignupWithRoleScreen(),
),
GetPage(
name: AppRoutes.emailVerification,
page: () => const EmailVerificationScreen(),
),
GetPage(
name: AppRoutes.forgotPassword,
page: () => const ForgotPasswordScreen(),

View File

@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
class LocationService extends GetxService {
@ -143,6 +144,7 @@ class LocationService extends GetxService {
// Get city name from coordinates
if (currentPosition.value != null) {
await _updateCityName();
}
@ -203,6 +205,8 @@ class LocationService extends GetxService {
if (placemarks.isNotEmpty) {
currentCity.value = placemarks.first.locality ?? '';
}
Logger().i('Current city: ${currentCity.value}');
} catch (e) {
currentCity.value = '';
}

View File

@ -26,7 +26,7 @@ class SupabaseService extends GetxService {
bool get isAuthenticated => currentUser != null;
/// Check if current user is an officer based on metadata
bool get isOfficer => userMetadata.isOfficer ?? false;
bool get isOfficer => userMetadata.isOfficer;
/// Get the stored identifier (NIK or NRP) of the current user
String? get userIdentifier {

View File

@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/biometric_service.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
@ -28,13 +29,17 @@ class AuthenticationRepository extends GetxController {
// Getters that use the Supabase service
User? get authUser => SupabaseService.instance.currentUser;
String? get currentUserId => SupabaseService.instance.currentUserId;
Session? get currentSession => _supabase.auth.currentSession;
// ---------------------------------------------------------------------------
// LIFECYCLE & REDIRECT
// ---------------------------------------------------------------------------
@override
void onReady() {
// Delay the redirect to avoid issues during build
Future.delayed(Duration.zero, () {
screenRedirect();
});
}
// Check for biometric login on app start
@ -63,40 +68,54 @@ class AuthenticationRepository extends GetxController {
}
}
// Redirect user to appropriate screen on app start
screenRedirect() async {
/// Updated screenRedirect method to accept arguments
void screenRedirect({UserMetadataModel? arguments}) async {
// Use addPostFrameCallback to ensure navigation happens after the build cycle
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final session = _supabase.auth.currentSession;
// Check if onboarding has been shown before
final isFirstTime = storage.read('isFirstTime') ?? false;
if (await _locationService.isLocationValidForFeature() == false) {
// Location is not valid, navigate to warning screen
Get.offAllNamed(AppRoutes.locationWarning);
return;
}
if (session != null) {
if (session.user.emailConfirmedAt == null) {
// User is not verified, go to email verification screen
Get.offAllNamed(AppRoutes.emailVerification);
} else if (session.user.userMetadata!['profile_status'] == 'incomplete') {
// User is regular user, go to main app screen
Get.offAllNamed(AppRoutes.registrationForm);
} else if (session.user.userMetadata!['profile_status'] == 'complete' &&
} else if (session.user.userMetadata?['profile_status'] ==
'incomplete' &&
session.user.emailConfirmedAt != null) {
// Redirect to the main app screen
// User is incomplete, go to registration form with arguments if provided
Get.offAllNamed(AppRoutes.registrationForm);
} else {
// User is logged in and verified, go to the main app screen
Get.offAllNamed(AppRoutes.panicButton);
}
} else {
// Try biometric login first
// Try biometric login first - but only if we're not already in a navigation
if (Get.currentRoute != AppRoutes.signIn &&
Get.currentRoute != AppRoutes.onboarding) {
bool biometricSuccess = await attemptBiometricLogin();
if (!biometricSuccess) {
// If not first time, go to sign in directly
// If first time, show onboarding first
if (isFirstTime) {
storage.writeIfNull('isFirstTime', true);
// check if user is already logged in
storage.read('isFirstTime') != true
? Get.offAllNamed(AppRoutes.signIn)
: Get.offAllNamed(AppRoutes.onboarding);
}
}
}
} catch (e) {
Logger().e('Error in screenRedirect: $e');
// Fallback to sign in screen on error
Get.offAll(() => const SignInScreen());
} else {
// Mark that onboarding has been shown
storage.write('isFirstTime', true);
Get.offAll(() => const OnboardingScreen());
}
}
}
});
}
// ---------------------------------------------------------------------------
@ -570,9 +589,7 @@ class AuthenticationRepository extends GetxController {
// First update auth metadata
await _supabase.auth.updateUser(
UserAttributes(
data: userMetadataModel.toProfileCompletionJson(),
),
UserAttributes(data: userMetadataModel.toProfileCompletionJson()),
);
// Then update or insert relevant tables based on the role
@ -616,7 +633,6 @@ class AuthenticationRepository extends GetxController {
}
}
// Update user role (officer/user) and metadata
Future<UserResponse> updateUserRole({
required bool isOfficer,
@ -651,7 +667,6 @@ class AuthenticationRepository extends GetxController {
// Add these methods to the AuthenticationRepository class
// ---------------------------------------------------------------------------
// BIOMETRIC AUTHENTICATION
// ---------------------------------------------------------------------------

View File

@ -7,6 +7,9 @@ import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class EmailVerificationController extends GetxController {
// Singleton instance
static EmailVerificationController get instance => Get.find();
// OTP text controllers
final List<TextEditingController> otpControllers = List.generate(
6,

View File

@ -5,6 +5,9 @@ import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class ForgotPasswordController extends GetxController {
// Singleton instance
static ForgotPasswordController get instance => Get.find();
// Form key for validation
final formKey = GlobalKey<FormState>();

View File

@ -1,4 +1,6 @@
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
@ -10,6 +12,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info
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/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/num_int.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class FormRegistrationController extends GetxController {
@ -26,6 +29,8 @@ class FormRegistrationController extends GetxController {
late final OfficerInfoController? officerInfoController;
late final UnitInfoController? unitInfoController;
final storage = GetStorage();
// Current step index
final RxInt currentStep = 0.obs;
@ -35,7 +40,7 @@ class FormRegistrationController extends GetxController {
// User metadata model
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
// Vievewer data
// Viewer data
final Rx<UserModel?> viewerModel = Rx<UserModel?>(null);
final Rx<ProfileModel?> profileModel = Rx<ProfileModel?>(null);
@ -48,77 +53,207 @@ class FormRegistrationController extends GetxController {
@override
void onInit() {
super.onInit();
// Get role and initial data from arguments
final arguments = Get.arguments;
if (arguments != null) {
if (arguments['role'] != null) {
selectedRole.value = arguments['role'] as RoleModel;
// Initialize user data directly from current session
_initializeFromCurrentUser();
}
if (arguments['initialData'] != null) {
// Initialize with data from signup
userMetadata.value = arguments['initialData'] as UserMetadataModel;
}
/// Initialize the controller directly from current user session
void _initializeFromCurrentUser() async {
try {
Logger().d('Initializing registration form from current user');
// Store userId if provided
if (arguments['userId'] != null) {
userMetadata.value = userMetadata.value.copyWith(
userId: arguments['userId'],
// Get the current user session from AuthenticationRepository
final session = AuthenticationRepository.instance.currentSession;
// Initialize with default metadata
UserMetadataModel metadata = const UserMetadataModel(
profileStatus: 'incomplete',
isOfficer: false,
);
}
// Initialize userMetadata with the selected role information
if ((userMetadata.value.roleId?.isEmpty ?? true) &&
selectedRole.value != null) {
userMetadata.value = userMetadata.value.copyWith(
roleId: selectedRole.value!.id,
isOfficer: selectedRole.value!.isOfficer,
// If there is an active session, use that data
if (session?.user != null) {
final user = session!.user;
Logger().d('Found active user session: ${user.id} - ${user.email}');
// Extract metadata from user session
metadata = UserMetadataModel(
userId: user.id,
email: user.email,
roleId: user.userMetadata?['role_id'] as String?,
isOfficer: user.userMetadata?['is_officer'] as bool? ?? false,
profileStatus:
user.userMetadata?['profile_status'] as String? ?? 'incomplete',
);
}
_initializeControllers();
// If user has additional metadata and it's in the expected format, use it
if (user.userMetadata != null) {
try {
// Try to parse complete metadata if available
final fullMetadata = UserMetadataModel.fromJson(user.userMetadata);
metadata = fullMetadata;
Logger().d('Successfully parsed complete user metadata');
} catch (e) {
Logger().w('Could not parse full metadata object: $e');
// Continue with the basic metadata already created
}
}
} else {
TLoaders.errorSnackBar(
title: 'Error',
message: 'No role selected. Please go back and select a role.',
// No active session, check if any arguments were passed
final arguments = Get.arguments;
// If arguments contain a user ID, use it as fallback
if (arguments is Map<String, dynamic> &&
arguments.containsKey('userId')) {
metadata = metadata.copyWith(
userId: arguments['userId'] as String?,
email: arguments['email'] as String?,
roleId: arguments['roleId'] as String?,
isOfficer: arguments['isOfficer'] as bool? ?? false,
);
Logger().d('Using arguments as fallback: ${metadata.userId}');
} else {
// No user data available, check temporary storage
final tempUserId = storage.read('TEMP_USER_ID') as String?;
final tempEmail = storage.read('CURRENT_USER_EMAIL') as String?;
if (tempUserId != null || tempEmail != null) {
metadata = metadata.copyWith(
userId: tempUserId,
email: tempEmail,
roleId: storage.read('TEMP_ROLE_ID') as String?,
isOfficer: storage.read('TEMP_IS_OFFICER') as bool? ?? false,
);
Logger().d(
'Using temporary storage as fallback: ${metadata.userId}',
);
} else {
Logger().w('No user data available, using default empty metadata');
}
}
}
if (selectedRole.value?.isOfficer == true) {
_fetchAvailableUnits();
// Set the user metadata
userMetadata.value = metadata;
Logger().d('Final user metadata: ${userMetadata.value.toString()}');
// Complete initialization
await _finalizeInitialization();
} catch (e) {
Logger().e('Error initializing from current user: $e');
userMetadata.value = const UserMetadataModel(
profileStatus: 'incomplete',
isOfficer: false,
);
await _finalizeInitialization();
}
}
/// Finalize initialization after metadata is set
Future<void> _finalizeInitialization() async {
try {
// Initialize form controllers
_initializeControllers();
// Set role information if available
if (userMetadata.value.roleId?.isNotEmpty == true) {
await _setRoleFromMetadata();
}
// Fetch units if user is an officer
if (userMetadata.value.isOfficer ||
(selectedRole.value?.isOfficer == true)) {
await _fetchAvailableUnits();
}
Logger().d('Initialization completed successfully');
} catch (e) {
Logger().e('Error in finalization: $e');
}
}
/// Set role information from metadata
Future<void> _setRoleFromMetadata() async {
try {
final roleId = userMetadata.value.roleId;
if (roleId?.isNotEmpty == true) {
// Try to find the role in available roles
final role = await _findRoleById(roleId!);
if (role != null) {
selectedRole.value = role;
Logger().d('Role set from metadata: ${role.name}');
}
}
} catch (e) {
Logger().e('Error setting role from metadata: $e');
}
}
/// Find role by ID (implement based on your role management system)
Future<RoleModel?> _findRoleById(String roleId) async {
try {
// Implement based on your role fetching logic
// This is a placeholder - replace with your actual implementation
return null;
} catch (e) {
Logger().e('Error finding role by ID: $e');
return null;
}
}
void _initializeControllers() {
final isOfficer = selectedRole.value?.isOfficer ?? false;
final isOfficer = userMetadata.value.isOfficer;
// Always initialize personal info controller
personalInfoController = Get.put(PersonalInfoController());
// Initialize controllers with built-in static form keys
Get.put<PersonalInfoController>(PersonalInfoController(), permanent: false);
// Initialize ID Card verification controller
idCardVerificationController = Get.put(
Get.put<IdCardVerificationController>(
IdCardVerificationController(isOfficer: isOfficer),
permanent: false,
);
// Initialize Selfie verification controller
selfieVerificationController = Get.put(SelfieVerificationController());
Get.put<SelfieVerificationController>(
SelfieVerificationController(),
permanent: false,
);
// Initialize identity verification controller
identityController = Get.put(
Get.put<IdentityVerificationController>(
IdentityVerificationController(isOfficer: isOfficer),
permanent: false,
);
// Initialize officer-specific controllers only if user is an officer
if (isOfficer) {
// Initialize officer-specific controllers
officerInfoController = Get.put(OfficerInfoController());
unitInfoController = Get.put(UnitInfoController());
totalSteps = 5; // Personal, ID Card, Selfie, Officer Info, Unit Info
Get.put<OfficerInfoController>(OfficerInfoController(), permanent: false);
Get.put<UnitInfoController>(UnitInfoController(), permanent: false);
totalSteps =
TNum.totalStepOfficer; // Personal, ID Card, Selfie, Officer Info, Unit Info
// Assign officer-specific controllers
officerInfoController = Get.find<OfficerInfoController>();
unitInfoController = Get.find<UnitInfoController>();
} else {
// For civilian users
officerInfoController = null;
unitInfoController = null;
totalSteps = 4; // Personal, ID Card, Selfie, Identity
totalSteps = TNum.totalStepViewer; // Personal, ID Card, Selfie, Identity
}
// Assign shared controllers
personalInfoController = Get.find<PersonalInfoController>();
idCardVerificationController = Get.find<IdCardVerificationController>();
selfieVerificationController = Get.find<SelfieVerificationController>();
identityController = Get.find<IdentityVerificationController>();
// Initialize selectedRole based on isOfficer
if (selectedRole.value == null &&
userMetadata.value.additionalData != null) {
final roleData = userMetadata.value.additionalData?['role'];
if (roleData != null) {
selectedRole.value = roleData as RoleModel;
}
}
}
@ -315,7 +450,6 @@ class FormRegistrationController extends GetxController {
},
);
} else {
// Regular user - create profile-related data
final viewerData = viewerModel.value?.copyWith(
phone: personalInfoController.phoneController.text,

View File

@ -7,6 +7,9 @@ import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class SignInController extends GetxController {
// Singleton instance
static SignInController get instance => Get.find();
final rememberMe = false.obs;
final isPasswordVisible = false.obs;

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';

View File

@ -12,6 +12,7 @@ import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
// Define the role types
enum RoleType { viewer, officer }
@ -166,6 +167,7 @@ class SignupWithRoleController extends GetxController {
}
// Sign up function
/// Updated signup function with better error handling and argument passing
void signUp(bool isOfficer) async {
if (!validateSignupForm()) {
return;
@ -184,21 +186,30 @@ class SignupWithRoleController extends GetxController {
return;
}
// Make sure we have a role selected
// Ensure we have a role selected
if (selectedRoleId.value.isEmpty) {
// Find a role based on the selected role type
_updateSelectedRoleBasedOnType();
}
// Create initial user metadata
// Validate role selection
if (selectedRoleId.value.isEmpty) {
TLoaders.errorSnackBar(
title: 'Role Required',
message: 'Please select a role before continuing.',
);
return;
}
// Create comprehensive initial user metadata
final initialMetadata = UserMetadataModel(
email: emailController.text.trim(),
roleId: selectedRoleId.value,
isOfficer: isOfficer,
profileStatus: 'incomplete',
);
try {
// First create the basic account with email/password
// Create the account
final authResponse = await AuthenticationRepository.instance
.initialSignUp(
email: emailController.text.trim(),
@ -206,48 +217,57 @@ class SignupWithRoleController extends GetxController {
initialData: initialMetadata,
);
// Check if authResponse has a user property
if (authResponse.user == null || authResponse.session == null) {
// Validate response
if (authResponse.session == null || authResponse.user == null) {
throw Exception('Failed to create account. Please try again.');
}
// Store email for verification
storage.write('CURRENT_USER_EMAIL', emailController.text.trim());
storage.write('TEMP_AUTH_TOKEN', authResponse.session?.accessToken);
storage.write('TEMP_USER_ID', authResponse.user?.id);
storage.write('TEMP_ROLE_ID', selectedRoleId.value);
final user = authResponse.user!;
Logger().d('Account created successfully for user: ${user.id}');
// Store temporary data for verification process
await _storeTemporaryData(authResponse, isOfficer);
// Navigate with arguments
AuthenticationRepository.instance.screenRedirect();
// Navigate to registration form
Get.offNamed(
AppRoutes.registrationForm,
arguments: {
'role': selectedRole.value,
'userId': authResponse.user?.id,
'initialData': initialMetadata,
},
);
} catch (authError) {
// Handle specific authentication errors
Logger().e('Error during signup: $authError');
Logger().e('Authentication error during signup: $authError');
TLoaders.errorSnackBar(
title: 'Registration Failed',
message: authError.toString(),
message: _getReadableErrorMessage(authError.toString()),
);
// Important: Do not navigate or redirect on error
return;
}
} catch (e) {
Logger().e('Error during signup: $e');
Logger().e('Unexpected error during signup: $e');
TLoaders.errorSnackBar(
title: 'Registration Failed',
message: e.toString(),
message: 'An unexpected error occurred. Please try again.',
);
// No navigation on error
} finally {
isLoading.value = false;
}
}
/// Store temporary data for the verification process
Future<void> _storeTemporaryData(
AuthResponse authResponse,
bool isOfficer,
) async {
try {
await storage.write('CURRENT_USER_EMAIL', emailController.text.trim());
await storage.write('TEMP_AUTH_TOKEN', authResponse.session?.accessToken);
await storage.write('TEMP_USER_ID', authResponse.user?.id);
await storage.write('TEMP_ROLE_ID', selectedRoleId.value);
await storage.write('TEMP_IS_OFFICER', isOfficer);
Logger().d('Temporary data stored successfully');
} catch (e) {
Logger().e('Failed to store temporary data: $e');
}
}
// Sign in with Google
Future<void> signInWithGoogle() async {
try {
@ -517,7 +537,7 @@ class SignupWithRoleController extends GetxController {
// Navigate to registration form to complete profile
Get.offNamed(
AppRoutes.registrationForm,
AppRoutes.emailVerification,
arguments: {
'role': selectedRole.value,
'userId': userId,
@ -545,4 +565,19 @@ class SignupWithRoleController extends GetxController {
void goToSignIn() {
Get.offNamed(AppRoutes.signIn);
}
/// Convert technical error messages to user-friendly messages
String _getReadableErrorMessage(String error) {
if (error.contains('email')) {
return 'Please check your email address and try again.';
} else if (error.contains('password')) {
return 'Password must be at least 6 characters long.';
} else if (error.contains('network') || error.contains('connection')) {
return 'Network error. Please check your connection and try again.';
} else if (error.contains('already registered') ||
error.contains('already exists')) {
return 'This email is already registered. Please try signing in instead.';
}
return 'Registration failed. Please try again.';
}
}

View File

@ -4,12 +4,20 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
class IdCardVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Singleton instance
static IdCardVerificationController get instance => Get.find();
// Static form key
final GlobalKey<FormState> formKey = TGlobalFormKey.idCardVerification();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer;
// Maximum allowed file size in bytes (4MB)
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
IdCardVerificationController({required this.isOfficer});
// ID Card variables
@ -51,7 +59,7 @@ class IdCardVerificationController extends GetxController {
idCardValidationMessage.value = '';
}
// Pick ID Card Image
// Pick ID Card Image with file size validation
Future<void> pickIdCardImage(ImageSource source) async {
try {
isUploadingIdCard.value = true;
@ -61,10 +69,21 @@ class IdCardVerificationController extends GetxController {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
imageQuality: 80,
imageQuality: 80, // Reduce quality to help with file size
);
if (image != null) {
// Check file size
final File file = File(image.path);
final int fileSize = await file.length();
if (fileSize > maxFileSizeBytes) {
idCardError.value =
'Image size exceeds 4MB limit. Please choose a smaller image or lower resolution.';
isIdCardValid.value = false;
return;
}
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
idCardImage.value = image;

View File

@ -2,10 +2,15 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class IdentityVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Singleton instance
static IdentityVerificationController get instance => Get.find();
// Static form key
final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer;

View File

@ -4,6 +4,9 @@ import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
class ImageVerificationController extends GetxController {
// Singleton instance
static ImageVerificationController get instance => Get.find();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer;

View File

@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class OfficerInfoController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Singleton instance
static OfficerInfoController get instance => Get.find();
// Static form key
final GlobalKey<FormState> formKey = TGlobalFormKey.officerInfo();
// Controllers
final nrpController = TextEditingController();

View File

@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class PersonalInfoController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Singleton instance
static PersonalInfoController get instance => Get.find();
// Static form key
final GlobalKey<FormState> formKey = TGlobalFormKey.personalInfo();
// Controllers
final firstNameController = TextEditingController();
@ -21,6 +26,7 @@ class PersonalInfoController extends GetxController {
final RxString bioError = ''.obs;
final RxString addressError = ''.obs;
@override
void onInit() {
super.onInit();

View File

@ -1,12 +1,22 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
class SelfieVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Singleton instance
static SelfieVerificationController get instance => Get.find();
// Static form key
final GlobalKey<FormState> formKey = TGlobalFormKey.selfieVerification();
final AzureOCRService _ocrService = AzureOCRService();
// Maximum allowed file size in bytes (4MB)
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
// Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString('');
@ -51,7 +61,7 @@ class SelfieVerificationController extends GetxController {
selfieValidationMessage.value = '';
}
// Take or pick selfie image
// Take or pick selfie image with file size validation
Future<void> pickSelfieImage(ImageSource source) async {
try {
isUploadingSelfie.value = true;
@ -62,10 +72,21 @@ class SelfieVerificationController extends GetxController {
final XFile? image = await picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80,
imageQuality: 80, // Reduce quality to help with file size
);
if (image != null) {
// Check file size
final File file = File(image.path);
final int fileSize = await file.length();
if (fileSize > maxFileSizeBytes) {
selfieError.value =
'Image size exceeds 4MB limit. Please take a lower resolution photo.';
isSelfieValid.value = false;
return;
}
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
selfieImage.value = image;

View File

@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class UnitInfoController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Singleton instance
static UnitInfoController get instance => Get.find();
// Static form key
final GlobalKey<FormState> formKey = TGlobalFormKey.unitInfo();
// Controllers
final positionController = TextEditingController();

View File

@ -34,16 +34,15 @@ class FormRegistrationScreen extends StatelessWidget {
return Scaffold(
backgroundColor: dark ? TColors.dark : TColors.light,
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
title: Obx(
() => Text(
'Complete Your ${controller.selectedRole.value?.name ?? ""} Profile',
title: Text(
'Complete Your Profile',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
centerTitle: true,
leading: IconButton(
icon: Icon(
@ -55,9 +54,19 @@ class FormRegistrationScreen extends StatelessWidget {
),
),
body: Obx(() {
// Show loading while initializing
if (controller.selectedRole.value == null) {
return const Center(child: CircularProgressIndicator());
// Make loading check more robust - showing a loading state while controller initializes
if (controller.userMetadata.value.userId == null &&
controller.userMetadata.value.roleId == null) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Loading profile information..."),
],
),
);
}
return SafeArea(

View File

@ -209,14 +209,14 @@ class IdCardVerificationStep extends StatelessWidget {
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: backgroundColor, // Using the dynamic background color
color: backgroundColor,
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all(
color: borderColor,
width:
controller.idCardError.value.isNotEmpty
? 2
: 1, // Thicker border for error state
: 1,
),
),
child:
@ -262,7 +262,7 @@ class IdCardVerificationStep extends StatelessWidget {
),
const SizedBox(height: TSizes.xs),
Text(
'Tap to select an image',
'Tap to select an image (max 4MB)',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color:
@ -294,8 +294,7 @@ class IdCardVerificationStep extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
TSizes.borderRadiusMd - 2,
),
child: Image.file(
File(controller.idCardImage.value!.path),
@ -313,8 +312,7 @@ class IdCardVerificationStep extends StatelessWidget {
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
TSizes.borderRadiusMd - 2,
),
color: TColors.error.withOpacity(0.2),
),
@ -341,7 +339,7 @@ class IdCardVerificationStep extends StatelessWidget {
horizontal: TSizes.md,
),
child: Text(
'Please upload a clearer image',
controller.idCardError.value,
textAlign: TextAlign.center,
style: TextStyle(
color: TColors.error,
@ -361,8 +359,7 @@ class IdCardVerificationStep extends StatelessWidget {
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
TSizes.borderRadiusMd - 2,
),
color: Colors.black.withOpacity(0.5),
),
@ -429,6 +426,37 @@ class IdCardVerificationStep extends StatelessWidget {
minimumSize: const Size(double.infinity, 50),
),
),
// Show file size information if image is uploaded
if (controller.idCardImage.value != null)
FutureBuilder<int>(
future: File(controller.idCardImage.value!.path).length(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final fileSizeKB = snapshot.data! / 1024;
final fileSizeMB = fileSizeKB / 1024;
final isOversized =
snapshot.data! > controller.maxFileSizeBytes;
return Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
'File size: ${fileSizeMB.toStringAsFixed(2)} MB',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color:
isOversized
? TColors.error
: TColors.textSecondary,
fontWeight:
isOversized
? FontWeight.bold
: FontWeight.normal,
),
),
);
}
return const SizedBox.shrink();
},
),
],
),
],
@ -443,7 +471,7 @@ class IdCardVerificationStep extends StatelessWidget {
final String idCardType = isOfficer ? 'KTA' : 'KTP';
final String title = 'Select $idCardType Image Source';
final String message =
'Please ensure your ID card is clear, well-lit, and all text is readable';
'Please ensure your ID card is clear, well-lit, and all text is readable. Maximum file size: 4MB';
Get.dialog(
Dialog(

View File

@ -327,7 +327,7 @@ class SelfieVerificationStep extends StatelessWidget {
),
const SizedBox(height: TSizes.xs),
Text(
'Tap to open camera',
'Tap to take a selfie (max 4MB)',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color:
@ -517,6 +517,37 @@ class SelfieVerificationStep extends StatelessWidget {
),
],
),
// File size information
if (controller.selfieImage.value != null)
FutureBuilder<int>(
future: File(controller.selfieImage.value!.path).length(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final fileSizeKB = snapshot.data! / 1024;
final fileSizeMB = fileSizeKB / 1024;
final isOversized =
snapshot.data! > controller.maxFileSizeBytes;
return Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
'File size: ${fileSizeMB.toStringAsFixed(2)} MB',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color:
isOversized
? TColors.error
: TColors.textSecondary,
fontWeight:
isOversized
? FontWeight.bold
: FontWeight.normal,
),
),
);
}
return const SizedBox.shrink();
},
),
],
),
],

View File

@ -8,6 +8,7 @@ import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart';
import 'package:sigap/src/shared/widgets/silver-app-bar/custom_silverbar.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
@ -34,65 +35,70 @@ class SignupWithRoleScreen extends StatelessWidget {
return Scaffold(
body: Obx(
() => Column(
children: [
// Top section with image and role information
_buildTopImageSection(controller, context),
() => NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
// Top image section as SliverAppBar
_buildSliverAppBar(controller, context),
// Bottom section with form
Expanded(
// Tab bar as pinned SliverPersistentHeader
SliverPersistentHeader(
delegate: TSliverTabBarDelegate(
child: _buildTabBar(context, controller),
minHeight: 70, // Height including padding
maxHeight: 70, // Fixed height for the tab bar
),
pinned: true,
),
];
},
body: SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: isDark ? TColors.dark : TColors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(TSizes.borderRadiusLg),
topRight: Radius.circular(TSizes.borderRadiusLg),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Column(
children: [
// Tab bar for switching between viewer and officer
_buildTabBar(context, controller),
// Form content
Expanded(
child: SingleChildScrollView(
child: Padding(
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: _buildSignupForm(context, controller),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildSignupForm(context, controller),
]),
),
),
// Add extra padding at the bottom for safe area
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).padding.bottom,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildTopImageSection(
SliverAppBar _buildSliverAppBar(
SignupWithRoleController controller,
BuildContext context,
BuildContext context
) {
bool isOfficer = controller.roleType.value == RoleType.officer;
final isDark = THelperFunctions.isDarkMode(context);
final topPadding = MediaQuery.of(context).padding.top;
return Container(
height:
MediaQuery.of(context).size.height *
0.35, // Take 35% of screen height
color: isDark ? TColors.dark : TColors.primary,
child: Stack(
return SliverAppBar(
expandedHeight: MediaQuery.of(context).size.height * 0.35,
pinned: true,
backgroundColor: isDark ? TColors.dark : TColors.primary,
elevation: 0,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
children: [
// Background gradient
Positioned.fill(
@ -110,24 +116,7 @@ class SignupWithRoleScreen extends StatelessWidget {
),
),
// Back button
Positioned(
top: MediaQuery.of(context).padding.top + TSizes.sm,
left: TSizes.sm,
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
padding: const EdgeInsets.all(TSizes.sm),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
),
),
// Role image and text content
// Role image
Align(
alignment: Alignment.center,
child: LayoutBuilder(
@ -140,11 +129,7 @@ class SignupWithRoleScreen extends StatelessWidget {
? maxImageHeight
: maxImageWidth;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Role image
SizedBox(
return SizedBox(
height: imageSize,
width: imageSize,
child: SvgPicture.asset(
@ -155,14 +140,29 @@ class SignupWithRoleScreen extends StatelessWidget {
: (isDark ? TImages.fallingDark : TImages.falling),
fit: BoxFit.contain,
),
),
],
);
},
),
),
],
),
),
// Back button in the app bar
leading: Padding(
padding: EdgeInsets.only(top: topPadding * 0.2),
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
margin: const EdgeInsets.only(left: TSizes.sm),
padding: const EdgeInsets.all(TSizes.xs),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
),
),
);
}
@ -172,15 +172,24 @@ class SignupWithRoleScreen extends StatelessWidget {
) {
final isDark = THelperFunctions.isDarkMode(context);
return Padding(
return Container(
decoration: BoxDecoration(
color: isDark ? TColors.dark : TColors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
padding: const EdgeInsets.fromLTRB(
TSizes.defaultSpace,
TSizes.md,
TSizes.xs,
TSizes.defaultSpace,
0,
TSizes.xs,
),
child: Container(
// Increase height from 50 to 60 or more
height: 60,
decoration: BoxDecoration(
color: isDark ? TColors.darkContainer : TColors.lightContainer,
@ -425,6 +434,9 @@ class SignupWithRoleScreen extends StatelessWidget {
),
],
),
// Add extra space at bottom for safety
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
);
@ -486,3 +498,5 @@ class SignupWithRoleScreen extends StatelessWidget {
);
}
}

View File

@ -1,427 +0,0 @@
// import 'package:flutter/material.dart';
// import 'package:flutter/services.dart';
// import 'package:get/get.dart';
// import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
// import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
// import 'package:sigap/src/features/personalization/data/models/index.dart';
// import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
// import 'package:sigap/src/shared/widgets/indicators/step_indicator/index.dart';
// import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
// import 'package:sigap/src/utils/constants/colors.dart';
// import 'package:sigap/src/utils/validators/validation.dart';
// class FormRegistrationScreen extends StatelessWidget {
// const FormRegistrationScreen({super.key});
// @override
// Widget build(BuildContext context) {
// // Get the controller
// final controller = Get.find<FormRegistrationController>();
// // Set system overlay style
// SystemChrome.setSystemUIOverlayStyle(
// const SystemUiOverlayStyle(
// statusBarColor: Colors.transparent,
// statusBarIconBrightness: Brightness.dark,
// ),
// );
// return Scaffold(
// backgroundColor: TColors.light,
// appBar: AppBar(
// backgroundColor: Colors.transparent,
// elevation: 0,
// title: Obx(
// () => Text(
// 'Complete Your ${controller.selectedRole.value?.name ?? ""} Profile',
// style: TextStyle(
// color: TColors.textPrimary,
// fontWeight: FontWeight.bold,
// ),
// ),
// ),
// centerTitle: true,
// leading: IconButton(
// icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
// onPressed: () => Get.back(),
// ),
// ),
// body: Obx(() {
// // Show loading while initializing
// if (controller.selectedRole.value == null) {
// return const Center(child: CircularProgressIndicator());
// }
// return SafeArea(
// child: Column(
// children: [
// // Step indicator
// Padding(
// padding: const EdgeInsets.all(24.0),
// child: Obx(
// () => StepIndicator(
// currentStep: controller.currentStep.value,
// totalSteps: controller.stepFormKeys.length,
// stepTitles: _getStepTitles(controller.selectedRole.value!),
// onStepTapped: controller.goToStep,
// ),
// ),
// ),
// // Step content
// Expanded(
// child: SingleChildScrollView(
// child: Padding(
// padding: const EdgeInsets.all(24.0),
// child: Obx(() {
// return _buildStepContent(controller);
// }),
// ),
// ),
// ),
// // Navigation buttons
// Padding(
// padding: const EdgeInsets.all(24.0),
// child: Row(
// children: [
// // Back button
// Obx(
// () =>
// controller.currentStep.value > 0
// ? Expanded(
// child: Padding(
// padding: const EdgeInsets.only(right: 8.0),
// child: AuthButton(
// text: 'Previous',
// onPressed: controller.previousStep,
// isPrimary: false,
// ),
// ),
// )
// : const SizedBox.shrink(),
// ),
// // Next/Submit button
// Expanded(
// child: Padding(
// padding: EdgeInsets.only(
// left: controller.currentStep.value > 0 ? 8.0 : 0.0,
// ),
// child: Obx(
// () => AuthButton(
// text:
// controller.currentStep.value ==
// controller.stepFormKeys.length - 1
// ? 'Submit'
// : 'Next',
// onPressed: controller.nextStep,
// isLoading: controller.isLoading.value,
// ),
// ),
// ),
// ),
// ],
// ),
// ),
// ],
// ),
// );
// }),
// );
// }
// List<String> _getStepTitles(RoleModel role) {
// if (role.isOfficer) {
// return ['Personal', 'Officer Info', 'Unit Info'];
// } else {
// return ['Personal', 'Emergency'];
// }
// }
// Widget _buildStepContent(FormRegistrationController controller) {
// final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
// switch (controller.currentStep.value) {
// case 0:
// return _buildPersonalInfoStep(controller);
// case 1:
// return isOfficer
// ? _buildOfficerInfoStep(controller)
// : _buildEmergencyContactStep(controller);
// case 2:
// // This step only exists for officers
// if (isOfficer) {
// return _buildOfficerAdditionalInfoStep(controller);
// }
// return const SizedBox.shrink();
// default:
// return const SizedBox.shrink();
// }
// }
// Widget _buildPersonalInfoStep(FormRegistrationController controller) {
// return Form(
// key: controller.stepFormKeys[0],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Personal Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide your personal details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // First Name field
// Obx(
// () => CustomTextField(
// label: 'First Name',
// controller: controller.firstNameController,
// validator:
// (value) =>
// TValidators.validateUserInput('First name', value, 50),
// errorText: controller.firstNameError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Last Name field
// Obx(
// () => CustomTextField(
// label: 'Last Name',
// controller: controller.lastNameController,
// validator:
// (value) => TValidators.validateUserInput(
// 'Last name',
// value,
// 50,
// required: false,
// ),
// errorText: controller.lastNameError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Phone field
// Obx(
// () => CustomTextField(
// label: 'Phone Number',
// controller: controller.phoneController,
// validator: TValidators.validatePhoneNumber,
// errorText: controller.phoneError.value,
// keyboardType: TextInputType.phone,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Address field
// Obx(
// () => CustomTextField(
// label: 'Address',
// controller: controller.addressController,
// validator:
// (value) =>
// TValidators.validateUserInput('Address', value, 255),
// errorText: controller.addressError.value,
// textInputAction: TextInputAction.done,
// maxLines: 3,
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildEmergencyContactStep(FormRegistrationController controller) {
// return Form(
// key: controller.stepFormKeys[1],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Additional Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide additional personal details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // NIK field
// Obx(
// () => CustomTextField(
// label: 'NIK (Identity Number)',
// controller: controller.nikController,
// validator:
// (value) => TValidators.validateUserInput('NIK', value, 16),
// errorText: controller.nikError.value,
// textInputAction: TextInputAction.next,
// keyboardType: TextInputType.number,
// ),
// ),
// // Bio field
// Obx(
// () => CustomTextField(
// label: 'Bio',
// controller: controller.bioController,
// validator:
// (value) => TValidators.validateUserInput(
// 'Bio',
// value,
// 255,
// required: false,
// ),
// errorText: controller.bioError.value,
// textInputAction: TextInputAction.next,
// maxLines: 3,
// hintText: 'Tell us a little about yourself (optional)',
// ),
// ),
// // Birth Date field
// Obx(
// () => CustomTextField(
// label: 'Birth Date (YYYY-MM-DD)',
// controller: controller.birthDateController,
// validator:
// (value) =>
// TValidators.validateUserInput('Birth date', value, 10),
// errorText: controller.birthDateError.value,
// textInputAction: TextInputAction.done,
// keyboardType: TextInputType.datetime,
// hintText: 'e.g., 1990-01-31',
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildOfficerInfoStep(FormRegistrationController controller) {
// return Form(
// key: controller.stepFormKeys[1],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Officer Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide your officer details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // NRP field
// Obx(
// () => CustomTextField(
// label: 'NRP',
// controller: controller.nrpController,
// validator: TValidators.validateNRP,
// errorText: controller.nrpError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Rank field
// Obx(
// () => CustomTextField(
// label: 'Rank',
// controller: controller.rankController,
// validator: TValidators.validateRank,
// errorText: controller.rankError.value,
// textInputAction: TextInputAction.done,
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildOfficerAdditionalInfoStep(
// FormRegistrationController controller,
// ) {
// return Form(
// key: controller.stepFormKeys[2],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Unit Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide your unit details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // Position field
// Obx(
// () => CustomTextField(
// label: 'Position',
// controller: controller.positionController,
// validator: TValidators.validatePosition,
// errorText: controller.positionError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Unit Dropdown
// Obx(
// () => CustomDropdown(
// label: 'Unit',
// items:
// controller.availableUnits
// .map(
// (unit) => DropdownMenuItem(
// value: unit.codeUnit,
// child: Text(unit.name),
// ),
// )
// .toList(),
// value:
// controller.unitIdController.text.isEmpty
// ? null
// : controller.unitIdController.text,
// onChanged: (value) {
// if (value != null) {
// controller.unitIdController.text = value.toString();
// }
// },
// validator: (value) => TValidators.validateUnitId(value),
// errorText: controller.unitIdError.value,
// ),
// ),
// ],
// ),
// );
// }
// }

View File

@ -46,6 +46,8 @@ class PasswordField extends StatelessWidget {
),
),
const SizedBox(height: TSizes.sm),
Obx(
() =>
TextFormField(
controller: controller,
validator: validator,
@ -79,7 +81,8 @@ class PasswordField extends StatelessWidget {
),
),
filled: true,
fillColor: isDark ? TColors.darkContainer : TColors.lightContainer,
fillColor:
isDark ? TColors.darkContainer : TColors.lightContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
@ -102,6 +105,7 @@ class PasswordField extends StatelessWidget {
),
),
),
),
const SizedBox(height: TSizes.spaceBtwInputFields),
],
);

View File

@ -112,10 +112,13 @@ class OnboardingController extends GetxController
// If location is valid, proceed to role selection
Get.offAllNamed(AppRoutes.signupWithRole);
TLoaders.successSnackBar(
title: 'Location Valid',
message: 'Checking location was successful',
);
// TLoaders.successSnackBar(
// title: 'Location Valid',
// message: 'Checking location was successful',
// );
// Store isfirstTime to false in storage
_storage.write('isFirstTime', false);
} else {
// If location is invalid, show warning screen
Get.offAllNamed(AppRoutes.locationWarning);

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class LocationWarningScreen extends StatelessWidget {
@ -27,10 +29,14 @@ class LocationWarningScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Warning Icon - use theme error color
Icon(
Icons.location_off,
size: 80,
color: theme.colorScheme.error,
Lottie.asset(
TImages.unverifyLocationAnimation,
width: 200,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.error, size: 100, color: Colors.red);
},
),
const SizedBox(height: TSizes.spaceBtwSections),

View File

@ -9,7 +9,6 @@ import '../../controllers/onboarding_controller.dart';
class OnboardingScreen extends StatelessWidget {
const OnboardingScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller

View File

@ -0,0 +1,36 @@
// Custom SliverPersistentHeaderDelegate for the tab bar
import 'package:flutter/widgets.dart';
class TSliverTabBarDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
final double minHeight;
final double maxHeight;
TSliverTabBarDelegate({
required this.child,
required this.minHeight,
required this.maxHeight,
});
@override
double get minExtent => minHeight;
@override
double get maxExtent => maxHeight;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return SizedBox.expand(child: child);
}
@override
bool shouldRebuild(TSliverTabBarDelegate oldDelegate) {
return maxHeight != oldDelegate.maxHeight ||
minHeight != oldDelegate.minHeight ||
child != oldDelegate.child;
}
}

View File

@ -72,9 +72,6 @@ class CustomTextField extends StatelessWidget {
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
errorText:
errorText != null && errorText!.isNotEmpty ? errorText : null,
errorStyle: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: TColors.error),
contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
@ -105,7 +102,17 @@ class CustomTextField extends StatelessWidget {
),
),
),
const SizedBox(height: TSizes.spaceBtwInputFields),
// if (errorText != null && errorText!.isNotEmpty)
// Padding(
// padding: const EdgeInsets.only(top: 6.0),
// child: Text(
// errorText!,
// style: Theme.of(
// context,
// ).textTheme.bodySmall?.copyWith(color: TColors.error),
// ),
// ),
// const SizedBox(height: TSizes.spaceBtwInputFields),
],
);
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
/// Class containing all the FormKey identifiers used across the app
/// This helps prevent duplication of form key strings and makes it easier to manage them
class TGlobalFormKey {
// Registration form step keys
static const String personalInfoForm = 'personal_info_new_form_key';
static const String idCardVerificationForm = 'id_card_verification_form_key';
static const String selfieVerificationForm = 'selfie_verification_form_key';
static const String identityVerificationForm =
'identity_verification_form_key';
static const String officerInfoForm = 'officer_info_form_key';
static const String unitInfoForm = 'unit_info_form_key';
// Authentication form keys
static const String signInForm = 'sign_in_form_key';
static const String signUpForm = 'sign_up_form_key';
static const String forgotPasswordForm = 'forgot_password_form_key';
static const String resetPasswordForm = 'reset_password_form_key';
static const String otpVerificationForm = 'otp_verification_form_key';
// Profile form keys
static const String editProfileForm = 'edit_profile_form_key';
static const String changePasswordForm = 'change_password_form_key';
static const String notificationSettingsForm =
'notification_settings_form_key';
// Create global keys for forms with specific debug labels
static GlobalKey<FormState> createFormKey(String keyName) {
return GlobalKey<FormState>(debugLabel: keyName);
}
// Helper function to get a personal info form key
static GlobalKey<FormState> personalInfo() {
return createFormKey(personalInfoForm);
}
// Helper function to get an ID card verification form key
static GlobalKey<FormState> idCardVerification() {
return createFormKey(idCardVerificationForm);
}
// Helper function to get a selfie verification form key
static GlobalKey<FormState> selfieVerification() {
return createFormKey(selfieVerificationForm);
}
// Helper function to get an identity verification form key
static GlobalKey<FormState> identityVerification() {
return createFormKey(identityVerificationForm);
}
// Helper function to get an officer info form key
static GlobalKey<FormState> officerInfo() {
return createFormKey(officerInfoForm);
}
// Helper function to get a unit info form key
static GlobalKey<FormState> unitInfo() {
return createFormKey(unitInfoForm);
}
}

View File

@ -174,6 +174,8 @@ class TImages {
"assets/images/animations/splash-dark.json";
static const String splashLightAnimation =
"assets/images/animations/splash-light.json";
static const String unverifyLocationAnimation =
"assets/images/animations/empty-address.json";
// -- Content Images (assets/images/content)
static const String backpackingDark =

View File

@ -1,4 +1,6 @@
class DNum {
class TNum {
// Auth Number
static const int oneTimePassword = 6;
static const int totalStepViewer = 4;
static const int totalStepOfficer = 5;
}

View File

@ -9,6 +9,8 @@ class TLoaders {
static hideSnackBar() => ScaffoldMessenger.of(Get.context!).hideCurrentSnackBar();
static customToast({required message}) {
// Use post-frame callback for any UI updates
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(Get.context!).showSnackBar(
SnackBar(
elevation: 0,
@ -19,15 +21,26 @@ class TLoaders {
margin: const EdgeInsets.symmetric(horizontal: 30),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: THelperFunctions.isDarkMode(Get.context!) ? TColors.darkerGrey.withOpacity(0.9) : TColors.grey.withOpacity(0.9),
color:
THelperFunctions.isDarkMode(Get.context!)
? TColors.darkerGrey.withOpacity(0.9)
: TColors.grey.withOpacity(0.9),
),
child: Center(
child: Text(
message,
style: Theme.of(Get.context!).textTheme.labelLarge,
),
),
child: Center(child: Text(message, style: Theme.of(Get.context!).textTheme.labelLarge)),
),
),
);
});
}
static successSnackBar({required title, message = '', duration = 3}) {
// Use post-frame callback for any UI updates
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.snackbar(
title,
message,
@ -40,9 +53,12 @@ class TLoaders {
margin: const EdgeInsets.all(10),
icon: const Icon(Iconsax.check, color: TColors.white),
);
});
}
static warningSnackBar({required title, message = ''}) {
// Use post-frame callback for any UI updates
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.snackbar(
title,
message,
@ -55,9 +71,12 @@ class TLoaders {
margin: const EdgeInsets.all(20),
icon: const Icon(Iconsax.warning_2, color: TColors.white),
);
});
}
static errorSnackBar({required title, message = ''}) {
// Use post-frame callback for any UI updates
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.snackbar(
title,
message,
@ -70,9 +89,12 @@ class TLoaders {
margin: const EdgeInsets.all(20),
icon: const Icon(Iconsax.warning_2, color: TColors.white),
);
});
}
static infoSnackBar({required title, message = ''}) {
// Use post-frame callback for any UI updates
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.snackbar(
title,
message,
@ -85,5 +107,6 @@ class TLoaders {
margin: const EdgeInsets.all(20),
icon: const Icon(Iconsax.info_circle, color: TColors.white),
);
});
}
}

View File

@ -13,7 +13,6 @@ datasource db {
model profiles {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @unique @db.Uuid
nik String @unique @default("") @db.VarChar(100)
avatar String? @db.VarChar(355)
username String? @unique @db.VarChar(255)
first_name String? @db.VarChar(255)
@ -21,9 +20,9 @@ model profiles {
bio String? @db.VarChar
address Json? @db.Json
birth_date DateTime?
nik String? @default("") @db.VarChar(100)
users users @relation(fields: [user_id], references: [id])
@@index([nik], map: "idx_profiles_nik")
@@index([user_id])
@@index([username])
}
@ -43,19 +42,19 @@ model users {
user_metadata Json?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
is_banned Boolean @default(false)
spoofing_attempts Int @default(0)
panic_strike Int @default(0)
banned_reason String? @db.VarChar(255)
banned_until DateTime? @db.Timestamptz(6)
is_anonymous Boolean @default(false)
banned_reason String? @db.VarChar(255)
is_banned Boolean @default(false)
panic_strike Int @default(0)
spoofing_attempts Int @default(0)
events events[]
incident_logs incident_logs[]
location_logs location_logs[]
panic_button_logs panic_button_logs[]
profile profiles?
sessions sessions[]
role roles @relation(fields: [roles_id], references: [id])
panic_button_logs panic_button_logs[]
@@index([is_anonymous])
@@index([created_at])
@ -68,9 +67,9 @@ model roles {
description String?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
officers officers[]
permissions permissions[]
users users[]
officers officers[]
}
model sessions {
@ -267,10 +266,10 @@ model incident_logs {
verified Boolean? @default(false)
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
evidence evidence[]
crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category")
locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
evidence evidence[]
panic_button_logs panic_button_logs[]
@@index([category_id], map: "idx_incident_logs_category_id")
@ -278,15 +277,14 @@ model incident_logs {
}
model evidence {
id String @id @unique @db.VarChar(20)
incident_id String @db.Uuid
type String @db.VarChar(50) // contoh: photo, video, document, images
url String @db.Text
description String? @db.VarChar(255)
caption String? @db.VarChar(255)
metadata Json?
type String @db.VarChar(50)
url String
uploaded_at DateTime? @default(now()) @db.Timestamptz(6)
caption String? @db.VarChar(255)
description String? @db.VarChar(255)
metadata Json?
id String @id @unique @db.VarChar(20)
incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade)
@@index([incident_id], map: "idx_evidence_incident_id")
@ -307,11 +305,11 @@ model units {
location Unsupported("geography")
city_id String @db.VarChar(20)
phone String? @db.VarChar(20)
officers officers[]
patrol_units patrol_units[]
unit_statistics unit_statistics[]
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers[]
patrol_units patrol_units[]
@@index([name], map: "idx_units_name")
@@index([type], map: "idx_units_type")
@ -320,13 +318,9 @@ model units {
@@index([location], map: "idx_unit_location", type: Gist)
@@index([district_id, location], map: "idx_units_location_district")
@@index([location], map: "idx_units_location_gist", type: Gist)
@@index([location], type: Gist)
@@index([location], map: "units_location_idx1", type: Gist)
@@index([location], map: "units_location_idx2", type: Gist)
}
model patrol_units {
id String @id @unique @db.VarChar(100)
unit_id String @db.VarChar(20)
location_id String @db.Uuid
name String @db.VarChar(100)
@ -334,7 +328,7 @@ model patrol_units {
status String @db.VarChar(50)
radius Float
created_at DateTime @default(now()) @db.Timestamptz(6)
id String @id @unique @db.VarChar(100)
members officers[]
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
@ -347,10 +341,8 @@ model patrol_units {
}
model officers {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
unit_id String @db.VarChar(20)
role_id String @db.Uuid
patrol_unit_id String @db.VarChar(100)
nrp String @unique @db.VarChar(100)
name String @db.VarChar(100)
rank String? @db.VarChar(100)
@ -360,16 +352,20 @@ model officers {
avatar String?
valid_until DateTime?
qr_code String?
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
patrol_unit_id String @db.VarChar(100)
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
banned_reason String? @db.VarChar(255)
banned_until DateTime?
is_banned Boolean @default(false)
panic_strike Int @default(0)
spoofing_attempts Int @default(0)
banned_reason String? @db.VarChar(255)
banned_until DateTime?
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
place_of_birth String?
date_of_birth DateTime? @db.Timestamptz(6)
patrol_units patrol_units @relation(fields: [patrol_unit_id], references: [id])
roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id])
units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
panic_button_logs panic_button_logs[]
@@index([unit_id], map: "idx_officers_unit_id")
@ -453,9 +449,9 @@ model panic_button_logs {
officer_id String? @db.Uuid
incident_id String @db.Uuid
timestamp DateTime @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers? @relation(fields: [officer_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
incidents incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers? @relation(fields: [officer_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([user_id], map: "idx_panic_buttons_user_id")
}