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) { Widget build(BuildContext context) {
final isDark = THelperFunctions.isDarkMode(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( return AnimatedSplashScreen(
splash: Center( splash: Center(
child: Lottie.asset( child: Lottie.asset(
isDark ? TImages.darkSplashApp : TImages.lightSplashApp, isDark ? TImages.darkSplashApp : TImages.lightSplashApp,
frameRate: FrameRate.max, frameRate: FrameRate.max,
repeat: true, repeat: true,
), ),
), ),
splashIconSize: 300, 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: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/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/registration-form/registraion_form_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
@ -38,6 +39,11 @@ class AppPages {
name: AppRoutes.signupWithRole, name: AppRoutes.signupWithRole,
page: () => const SignupWithRoleScreen(), page: () => const SignupWithRoleScreen(),
), ),
GetPage(
name: AppRoutes.emailVerification,
page: () => const EmailVerificationScreen(),
),
GetPage( GetPage(
name: AppRoutes.forgotPassword, name: AppRoutes.forgotPassword,

View File

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

View File

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

View File

@ -8,7 +8,7 @@ class UserMetadataModel {
// Core properties that define the user type // Core properties that define the user type
final bool isOfficer; final bool isOfficer;
final String? userId; final String? userId;
final String? roleId; final String? roleId;
final String profileStatus; final String profileStatus;
// Related models that hold specific data // Related models that hold specific data

View File

@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.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/biometric_service.dart';
import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/data/models/user_metadata_model.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/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/constants/app_routes.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_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 // Getters that use the Supabase service
User? get authUser => SupabaseService.instance.currentUser; User? get authUser => SupabaseService.instance.currentUser;
String? get currentUserId => SupabaseService.instance.currentUserId; String? get currentUserId => SupabaseService.instance.currentUserId;
Session? get currentSession => _supabase.auth.currentSession;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LIFECYCLE & REDIRECT // LIFECYCLE & REDIRECT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@override @override
void onReady() { void onReady() {
screenRedirect(); // Delay the redirect to avoid issues during build
Future.delayed(Duration.zero, () {
screenRedirect();
});
} }
// Check for biometric login on app start // Check for biometric login on app start
@ -63,40 +68,54 @@ class AuthenticationRepository extends GetxController {
} }
} }
// Redirect user to appropriate screen on app start /// Updated screenRedirect method to accept arguments
screenRedirect() async { void screenRedirect({UserMetadataModel? arguments}) async {
final session = _supabase.auth.currentSession; // 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 if (await _locationService.isLocationValidForFeature() == false) {
final isFirstTime = storage.read('isFirstTime') ?? false; // Location is not valid, navigate to warning screen
Get.offAllNamed(AppRoutes.locationWarning);
if (session != null) { return;
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' &&
session.user.emailConfirmedAt != null) {
// Redirect to the main app screen
Get.offAllNamed(AppRoutes.panicButton);
}
} else {
// Try biometric login first
bool biometricSuccess = await attemptBiometricLogin();
if (!biometricSuccess) {
// If not first time, go to sign in directly
// If first time, show onboarding first
if (isFirstTime) {
Get.offAll(() => const SignInScreen());
} else {
// Mark that onboarding has been shown
storage.write('isFirstTime', true);
Get.offAll(() => const OnboardingScreen());
} }
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' &&
session.user.emailConfirmedAt != null) {
// 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 - 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
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());
} }
} });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -544,7 +563,7 @@ class AuthenticationRepository extends GetxController {
initialData, initialData,
email: email, email: email,
); );
final AuthResponse res = await _supabase.auth.signUp( final AuthResponse res = await _supabase.auth.signUp(
email: email, email: email,
password: password, password: password,
@ -567,12 +586,10 @@ class AuthenticationRepository extends GetxController {
completeData, completeData,
profileStatus: 'complete', profileStatus: 'complete',
); );
// First update auth metadata // First update auth metadata
await _supabase.auth.updateUser( await _supabase.auth.updateUser(
UserAttributes( UserAttributes(data: userMetadataModel.toProfileCompletionJson()),
data: userMetadataModel.toProfileCompletionJson(),
),
); );
// Then update or insert relevant tables based on the role // 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 // Update user role (officer/user) and metadata
Future<UserResponse> updateUserRole({ Future<UserResponse> updateUserRole({
required bool isOfficer, required bool isOfficer,
@ -651,7 +667,6 @@ class AuthenticationRepository extends GetxController {
// Add these methods to the AuthenticationRepository class // Add these methods to the AuthenticationRepository class
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// BIOMETRIC AUTHENTICATION // 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'; import 'package:sigap/src/utils/popups/loaders.dart';
class EmailVerificationController extends GetxController { class EmailVerificationController extends GetxController {
// Singleton instance
static EmailVerificationController get instance => Get.find();
// OTP text controllers // OTP text controllers
final List<TextEditingController> otpControllers = List.generate( final List<TextEditingController> otpControllers = List.generate(
6, 6,

View File

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

View File

@ -1,4 +1,6 @@
import 'package:get/get.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/models/user_metadata_model.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/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.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/daily-ops/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart';
import 'package:sigap/src/utils/constants/app_routes.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'; import 'package:sigap/src/utils/popups/loaders.dart';
class FormRegistrationController extends GetxController { class FormRegistrationController extends GetxController {
@ -25,6 +28,8 @@ class FormRegistrationController extends GetxController {
late final IdentityVerificationController identityController; late final IdentityVerificationController identityController;
late final OfficerInfoController? officerInfoController; late final OfficerInfoController? officerInfoController;
late final UnitInfoController? unitInfoController; late final UnitInfoController? unitInfoController;
final storage = GetStorage();
// Current step index // Current step index
final RxInt currentStep = 0.obs; final RxInt currentStep = 0.obs;
@ -35,7 +40,7 @@ class FormRegistrationController extends GetxController {
// User metadata model // User metadata model
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs; final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
// Vievewer data // Viewer data
final Rx<UserModel?> viewerModel = Rx<UserModel?>(null); final Rx<UserModel?> viewerModel = Rx<UserModel?>(null);
final Rx<ProfileModel?> profileModel = Rx<ProfileModel?>(null); final Rx<ProfileModel?> profileModel = Rx<ProfileModel?>(null);
@ -48,77 +53,207 @@ class FormRegistrationController extends GetxController {
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Initialize user data directly from current session
_initializeFromCurrentUser();
}
// Get role and initial data from arguments /// Initialize the controller directly from current user session
final arguments = Get.arguments; void _initializeFromCurrentUser() async {
if (arguments != null) { try {
if (arguments['role'] != null) { Logger().d('Initializing registration form from current user');
selectedRole.value = arguments['role'] as RoleModel;
}
if (arguments['initialData'] != null) { // Get the current user session from AuthenticationRepository
// Initialize with data from signup final session = AuthenticationRepository.instance.currentSession;
userMetadata.value = arguments['initialData'] as UserMetadataModel;
}
// Store userId if provided // Initialize with default metadata
if (arguments['userId'] != null) { UserMetadataModel metadata = const UserMetadataModel(
userMetadata.value = userMetadata.value.copyWith( profileStatus: 'incomplete',
userId: arguments['userId'], 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,
);
}
_initializeControllers();
} else {
TLoaders.errorSnackBar(
title: 'Error',
message: 'No role selected. Please go back and select a role.',
); );
}
if (selectedRole.value?.isOfficer == true) { // If there is an active session, use that data
_fetchAvailableUnits(); 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',
);
// 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 {
// 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');
}
}
}
// 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() { void _initializeControllers() {
final isOfficer = selectedRole.value?.isOfficer ?? false; final isOfficer = userMetadata.value.isOfficer;
// Always initialize personal info controller // Initialize controllers with built-in static form keys
personalInfoController = Get.put(PersonalInfoController()); Get.put<PersonalInfoController>(PersonalInfoController(), permanent: false);
// Initialize ID Card verification controller Get.put<IdCardVerificationController>(
idCardVerificationController = Get.put(
IdCardVerificationController(isOfficer: isOfficer), IdCardVerificationController(isOfficer: isOfficer),
permanent: false,
); );
// Initialize Selfie verification controller Get.put<SelfieVerificationController>(
selfieVerificationController = Get.put(SelfieVerificationController()); SelfieVerificationController(),
permanent: false,
);
// Initialize identity verification controller Get.put<IdentityVerificationController>(
identityController = Get.put(
IdentityVerificationController(isOfficer: isOfficer), IdentityVerificationController(isOfficer: isOfficer),
permanent: false,
); );
// Initialize officer-specific controllers only if user is an officer
if (isOfficer) { if (isOfficer) {
// Initialize officer-specific controllers Get.put<OfficerInfoController>(OfficerInfoController(), permanent: false);
officerInfoController = Get.put(OfficerInfoController());
unitInfoController = Get.put(UnitInfoController()); Get.put<UnitInfoController>(UnitInfoController(), permanent: false);
totalSteps = 5; // Personal, ID Card, Selfie, Officer Info, Unit Info
totalSteps =
TNum.totalStepOfficer; // Personal, ID Card, Selfie, Officer Info, Unit Info
// Assign officer-specific controllers
officerInfoController = Get.find<OfficerInfoController>();
unitInfoController = Get.find<UnitInfoController>();
} else { } else {
// For civilian users // For civilian users
officerInfoController = null; officerInfoController = null;
unitInfoController = 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 { } else {
// Regular user - create profile-related data // Regular user - create profile-related data
final viewerData = viewerModel.value?.copyWith( final viewerData = viewerModel.value?.copyWith(
phone: personalInfoController.phoneController.text, 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'; import 'package:sigap/src/utils/popups/loaders.dart';
class SignInController extends GetxController { class SignInController extends GetxController {
// Singleton instance
static SignInController get instance => Get.find();
final rememberMe = false.obs; final rememberMe = false.obs;
final isPasswordVisible = 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/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.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/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/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart'; import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';

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/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart'; import 'package:sigap/src/utils/validators/validation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
// Define the role types // Define the role types
enum RoleType { viewer, officer } enum RoleType { viewer, officer }
@ -166,6 +167,7 @@ class SignupWithRoleController extends GetxController {
} }
// Sign up function // Sign up function
/// Updated signup function with better error handling and argument passing
void signUp(bool isOfficer) async { void signUp(bool isOfficer) async {
if (!validateSignupForm()) { if (!validateSignupForm()) {
return; return;
@ -184,21 +186,30 @@ class SignupWithRoleController extends GetxController {
return; return;
} }
// Make sure we have a role selected // Ensure we have a role selected
if (selectedRoleId.value.isEmpty) { if (selectedRoleId.value.isEmpty) {
// Find a role based on the selected role type
_updateSelectedRoleBasedOnType(); _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( final initialMetadata = UserMetadataModel(
email: emailController.text.trim(), email: emailController.text.trim(),
roleId: selectedRoleId.value, roleId: selectedRoleId.value,
isOfficer: isOfficer, isOfficer: isOfficer,
profileStatus: 'incomplete',
); );
try { try {
// First create the basic account with email/password // Create the account
final authResponse = await AuthenticationRepository.instance final authResponse = await AuthenticationRepository.instance
.initialSignUp( .initialSignUp(
email: emailController.text.trim(), email: emailController.text.trim(),
@ -206,48 +217,57 @@ class SignupWithRoleController extends GetxController {
initialData: initialMetadata, initialData: initialMetadata,
); );
// Check if authResponse has a user property // Validate response
if (authResponse.user == null || authResponse.session == null) { if (authResponse.session == null || authResponse.user == null) {
throw Exception('Failed to create account. Please try again.'); throw Exception('Failed to create account. Please try again.');
} }
// Store email for verification final user = authResponse.user!;
storage.write('CURRENT_USER_EMAIL', emailController.text.trim()); Logger().d('Account created successfully for user: ${user.id}');
storage.write('TEMP_AUTH_TOKEN', authResponse.session?.accessToken);
storage.write('TEMP_USER_ID', authResponse.user?.id); // Store temporary data for verification process
storage.write('TEMP_ROLE_ID', selectedRoleId.value); 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) { } catch (authError) {
// Handle specific authentication errors Logger().e('Authentication error during signup: $authError');
Logger().e('Error during signup: $authError');
TLoaders.errorSnackBar( TLoaders.errorSnackBar(
title: 'Registration Failed', title: 'Registration Failed',
message: authError.toString(), message: _getReadableErrorMessage(authError.toString()),
); );
// Important: Do not navigate or redirect on error
return; return;
} }
} catch (e) { } catch (e) {
Logger().e('Error during signup: $e'); Logger().e('Unexpected error during signup: $e');
TLoaders.errorSnackBar( TLoaders.errorSnackBar(
title: 'Registration Failed', title: 'Registration Failed',
message: e.toString(), message: 'An unexpected error occurred. Please try again.',
); );
// No navigation on error
} finally { } finally {
isLoading.value = false; 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 // Sign in with Google
Future<void> signInWithGoogle() async { Future<void> signInWithGoogle() async {
try { try {
@ -278,7 +298,7 @@ class SignupWithRoleController extends GetxController {
// Check if authResponse has a user property // Check if authResponse has a user property
final userId = AuthenticationRepository.instance.currentUserId; final userId = AuthenticationRepository.instance.currentUserId;
if (userId == null) { if (userId == null) {
throw Exception("Failed to authenticate. Please try again."); throw Exception("Failed to authenticate. Please try again.");
} }
@ -353,7 +373,7 @@ class SignupWithRoleController extends GetxController {
// Check if authResponse has a user property // Check if authResponse has a user property
final userId = AuthenticationRepository.instance.currentUserId; final userId = AuthenticationRepository.instance.currentUserId;
if (userId == null) { if (userId == null) {
throw Exception( throw Exception(
"Failed to authenticate with Apple ID. Please try again.", "Failed to authenticate with Apple ID. Please try again.",
@ -423,7 +443,7 @@ class SignupWithRoleController extends GetxController {
// Check if authResponse has a user property // Check if authResponse has a user property
final userId = AuthenticationRepository.instance.currentUserId; final userId = AuthenticationRepository.instance.currentUserId;
if (userId == null) { if (userId == null) {
throw Exception( throw Exception(
"Failed to authenticate with Facebook. Please try again.", "Failed to authenticate with Facebook. Please try again.",
@ -496,7 +516,7 @@ class SignupWithRoleController extends GetxController {
// Check if authResponse has a user property // Check if authResponse has a user property
final userId = AuthenticationRepository.instance.currentUserId; final userId = AuthenticationRepository.instance.currentUserId;
if (userId == null) { if (userId == null) {
throw Exception( throw Exception(
"Failed to sign in with email. Please check your credentials and try again.", "Failed to sign in with email. Please check your credentials and try again.",
@ -517,7 +537,7 @@ class SignupWithRoleController extends GetxController {
// Navigate to registration form to complete profile // Navigate to registration form to complete profile
Get.offNamed( Get.offNamed(
AppRoutes.registrationForm, AppRoutes.emailVerification,
arguments: { arguments: {
'role': selectedRole.value, 'role': selectedRole.value,
'userId': userId, 'userId': userId,
@ -545,4 +565,19 @@ class SignupWithRoleController extends GetxController {
void goToSignIn() { void goToSignIn() {
Get.offNamed(AppRoutes.signIn); 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:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart'; import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
class IdCardVerificationController extends GetxController { 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 AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer; final bool isOfficer;
// Maximum allowed file size in bytes (4MB)
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
IdCardVerificationController({required this.isOfficer}); IdCardVerificationController({required this.isOfficer});
// ID Card variables // ID Card variables
@ -51,7 +59,7 @@ class IdCardVerificationController extends GetxController {
idCardValidationMessage.value = ''; idCardValidationMessage.value = '';
} }
// Pick ID Card Image // Pick ID Card Image with file size validation
Future<void> pickIdCardImage(ImageSource source) async { Future<void> pickIdCardImage(ImageSource source) async {
try { try {
isUploadingIdCard.value = true; isUploadingIdCard.value = true;
@ -61,10 +69,21 @@ class IdCardVerificationController extends GetxController {
final ImagePicker picker = ImagePicker(); final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage( final XFile? image = await picker.pickImage(
source: source, source: source,
imageQuality: 80, imageQuality: 80, // Reduce quality to help with file size
); );
if (image != null) { 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 // Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
idCardImage.value = image; idCardImage.value = image;
@ -105,7 +124,7 @@ class IdCardVerificationController extends GetxController {
idCardImage.value!, idCardImage.value!,
isOfficer, isOfficer,
); );
// If we get here without an exception, the image is likely valid // If we get here without an exception, the image is likely valid
isImageValid = result.isNotEmpty; isImageValid = result.isNotEmpty;

View File

@ -2,10 +2,15 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.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/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'; import 'package:sigap/src/utils/validators/validation.dart';
class IdentityVerificationController extends GetxController { 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 AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer; 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'; import 'package:sigap/src/cores/services/azure_ocr_service.dart';
class ImageVerificationController extends GetxController { class ImageVerificationController extends GetxController {
// Singleton instance
static ImageVerificationController get instance => Get.find();
final GlobalKey<FormState> formKey = GlobalKey<FormState>(); final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final AzureOCRService _ocrService = AzureOCRService(); final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer; final bool isOfficer;

View File

@ -1,9 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
import 'package:sigap/src/utils/validators/validation.dart'; import 'package:sigap/src/utils/validators/validation.dart';
class OfficerInfoController extends GetxController { 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 // Controllers
final nrpController = TextEditingController(); final nrpController = TextEditingController();

View File

@ -1,9 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
import 'package:sigap/src/utils/validators/validation.dart'; import 'package:sigap/src/utils/validators/validation.dart';
class PersonalInfoController extends GetxController { 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 // Controllers
final firstNameController = TextEditingController(); final firstNameController = TextEditingController();
@ -21,6 +26,7 @@ class PersonalInfoController extends GetxController {
final RxString bioError = ''.obs; final RxString bioError = ''.obs;
final RxString addressError = ''.obs; final RxString addressError = ''.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();

View File

@ -1,12 +1,22 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart'; import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/utils/constants/form_key.dart';
class SelfieVerificationController extends GetxController { 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(); final AzureOCRService _ocrService = AzureOCRService();
// Maximum allowed file size in bytes (4MB)
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
// Face verification variables // Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null); final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString(''); final RxString selfieError = RxString('');
@ -51,7 +61,7 @@ class SelfieVerificationController extends GetxController {
selfieValidationMessage.value = ''; selfieValidationMessage.value = '';
} }
// Take or pick selfie image // Take or pick selfie image with file size validation
Future<void> pickSelfieImage(ImageSource source) async { Future<void> pickSelfieImage(ImageSource source) async {
try { try {
isUploadingSelfie.value = true; isUploadingSelfie.value = true;
@ -62,10 +72,21 @@ class SelfieVerificationController extends GetxController {
final XFile? image = await picker.pickImage( final XFile? image = await picker.pickImage(
source: source, source: source,
preferredCameraDevice: CameraDevice.front, preferredCameraDevice: CameraDevice.front,
imageQuality: 80, imageQuality: 80, // Reduce quality to help with file size
); );
if (image != null) { 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 // Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
selfieImage.value = image; selfieImage.value = image;

View File

@ -1,10 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/daily-ops/data/models/index.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'; import 'package:sigap/src/utils/validators/validation.dart';
class UnitInfoController extends GetxController { 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 // Controllers
final positionController = TextEditingController(); final positionController = TextEditingController();

View File

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

View File

@ -209,14 +209,14 @@ class IdCardVerificationStep extends StatelessWidget {
height: 180, height: 180,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, // Using the dynamic background color color: backgroundColor,
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all( border: Border.all(
color: borderColor, color: borderColor,
width: width:
controller.idCardError.value.isNotEmpty controller.idCardError.value.isNotEmpty
? 2 ? 2
: 1, // Thicker border for error state : 1,
), ),
), ),
child: child:
@ -262,7 +262,7 @@ class IdCardVerificationStep extends StatelessWidget {
), ),
const SizedBox(height: TSizes.xs), const SizedBox(height: TSizes.xs),
Text( Text(
'Tap to select an image', 'Tap to select an image (max 4MB)',
style: TextStyle( style: TextStyle(
fontSize: TSizes.fontSizeSm, fontSize: TSizes.fontSizeSm,
color: color:
@ -294,8 +294,7 @@ class IdCardVerificationStep extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd - TSizes.borderRadiusMd - 2,
2, // Adjust for border width
), ),
child: Image.file( child: Image.file(
File(controller.idCardImage.value!.path), File(controller.idCardImage.value!.path),
@ -313,8 +312,7 @@ class IdCardVerificationStep extends StatelessWidget {
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd - TSizes.borderRadiusMd - 2,
2, // Adjust for border width
), ),
color: TColors.error.withOpacity(0.2), color: TColors.error.withOpacity(0.2),
), ),
@ -341,7 +339,7 @@ class IdCardVerificationStep extends StatelessWidget {
horizontal: TSizes.md, horizontal: TSizes.md,
), ),
child: Text( child: Text(
'Please upload a clearer image', controller.idCardError.value,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: TColors.error, color: TColors.error,
@ -361,8 +359,7 @@ class IdCardVerificationStep extends StatelessWidget {
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd - TSizes.borderRadiusMd - 2,
2, // Adjust for border width
), ),
color: Colors.black.withOpacity(0.5), color: Colors.black.withOpacity(0.5),
), ),
@ -429,6 +426,37 @@ class IdCardVerificationStep extends StatelessWidget {
minimumSize: const Size(double.infinity, 50), 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 idCardType = isOfficer ? 'KTA' : 'KTP';
final String title = 'Select $idCardType Image Source'; final String title = 'Select $idCardType Image Source';
final String message = 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( Get.dialog(
Dialog( Dialog(

View File

@ -327,7 +327,7 @@ class SelfieVerificationStep extends StatelessWidget {
), ),
const SizedBox(height: TSizes.xs), const SizedBox(height: TSizes.xs),
Text( Text(
'Tap to open camera', 'Tap to take a selfie (max 4MB)',
style: TextStyle( style: TextStyle(
fontSize: TSizes.fontSizeSm, fontSize: TSizes.fontSizeSm,
color: 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/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.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/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/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/image_strings.dart'; import 'package:sigap/src/utils/constants/image_strings.dart';
@ -34,134 +35,133 @@ class SignupWithRoleScreen extends StatelessWidget {
return Scaffold( return Scaffold(
body: Obx( body: Obx(
() => Column( () => NestedScrollView(
children: [ headerSliverBuilder: (context, innerBoxIsScrolled) {
// Top section with image and role information return [
_buildTopImageSection(controller, context), // Top image section as SliverAppBar
_buildSliverAppBar(controller, context),
// Bottom section with form
Expanded( // Tab bar as pinned SliverPersistentHeader
child: Container( SliverPersistentHeader(
decoration: BoxDecoration( delegate: TSliverTabBarDelegate(
color: isDark ? TColors.dark : TColors.white, child: _buildTabBar(context, controller),
borderRadius: BorderRadius.only( minHeight: 70, // Height including padding
topLeft: Radius.circular(TSizes.borderRadiusLg), maxHeight: 70, // Fixed height for the tab bar
topRight: Radius.circular(TSizes.borderRadiusLg), ),
pinned: true,
),
];
},
body: SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: isDark ? TColors.dark : TColors.white,
),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildSignupForm(context, controller),
]),
),
), ),
boxShadow: [ // Add extra padding at the bottom for safe area
BoxShadow( SliverToBoxAdapter(
color: Colors.black.withOpacity(0.1), child: SizedBox(
blurRadius: 10, height: MediaQuery.of(context).padding.bottom,
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(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: _buildSignupForm(context, controller),
),
),
),
],
),
), ),
), ),
], ),
), ),
), ),
); );
} }
Widget _buildTopImageSection( SliverAppBar _buildSliverAppBar(
SignupWithRoleController controller, SignupWithRoleController controller,
BuildContext context, BuildContext context
) { ) {
bool isOfficer = controller.roleType.value == RoleType.officer; bool isOfficer = controller.roleType.value == RoleType.officer;
final isDark = THelperFunctions.isDarkMode(context); final isDark = THelperFunctions.isDarkMode(context);
final topPadding = MediaQuery.of(context).padding.top;
return Container(
height: return SliverAppBar(
MediaQuery.of(context).size.height * expandedHeight: MediaQuery.of(context).size.height * 0.35,
0.35, // Take 35% of screen height pinned: true,
color: isDark ? TColors.dark : TColors.primary, backgroundColor: isDark ? TColors.dark : TColors.primary,
child: Stack( elevation: 0,
children: [ automaticallyImplyLeading: false,
// Background gradient flexibleSpace: FlexibleSpaceBar(
Positioned.fill( background: Stack(
child: Container( children: [
decoration: BoxDecoration( // Background gradient
gradient: LinearGradient( Positioned.fill(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
isDark ? Colors.black : TColors.primary,
isDark ? TColors.dark : TColors.primary.withOpacity(0.8),
],
),
),
),
),
// Back button
Positioned(
top: MediaQuery.of(context).padding.top + TSizes.sm,
left: TSizes.sm,
child: GestureDetector(
onTap: () => Get.back(),
child: Container( child: Container(
padding: const EdgeInsets.all(TSizes.sm),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2), gradient: LinearGradient(
shape: BoxShape.circle, begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
isDark ? Colors.black : TColors.primary,
isDark ? TColors.dark : TColors.primary.withOpacity(0.8),
],
),
), ),
child: const Icon(Icons.arrow_back, color: Colors.white),
), ),
), ),
),
// Role image and text content // Role image
Align( Align(
alignment: Alignment.center, alignment: Alignment.center,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Responsive image size based on available height/width // Responsive image size based on available height/width
final double maxImageHeight = constraints.maxHeight * 0.9; final double maxImageHeight = constraints.maxHeight * 0.9;
final double maxImageWidth = constraints.maxWidth * 0.9; final double maxImageWidth = constraints.maxWidth * 0.9;
final double imageSize = final double imageSize =
maxImageHeight < maxImageWidth maxImageHeight < maxImageWidth
? maxImageHeight ? maxImageHeight
: maxImageWidth; : maxImageWidth;
return Column( return SizedBox(
mainAxisSize: MainAxisSize.min, height: imageSize,
children: [ width: imageSize,
// Role image child: SvgPicture.asset(
SizedBox( isOfficer
height: imageSize, ? (isDark
width: imageSize, ? TImages.communicationDark
child: SvgPicture.asset( : TImages.communication)
isOfficer : (isDark ? TImages.fallingDark : TImages.falling),
? (isDark fit: BoxFit.contain,
? TImages.communicationDark
: TImages.communication)
: (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); 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( padding: const EdgeInsets.fromLTRB(
TSizes.defaultSpace, TSizes.defaultSpace,
TSizes.md, TSizes.xs,
TSizes.defaultSpace, TSizes.defaultSpace,
0, TSizes.xs,
), ),
child: Container( child: Container(
// Increase height from 50 to 60 or more
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? TColors.darkContainer : TColors.lightContainer, 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,59 +46,63 @@ class PasswordField extends StatelessWidget {
), ),
), ),
const SizedBox(height: TSizes.sm), const SizedBox(height: TSizes.sm),
TextFormField( Obx(
controller: controller, () =>
validator: validator, TextFormField(
obscureText: !isVisible.value, controller: controller,
textInputAction: textInputAction, validator: validator,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( obscureText: !isVisible.value,
color: isDark ? TColors.white : TColors.textPrimary, textInputAction: textInputAction,
), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
decoration: InputDecoration( color: isDark ? TColors.white : TColors.textPrimary,
hintText: hintText,
hintStyle: Theme.of(
context,
).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,
), ),
prefixIcon: prefixIcon, decoration: InputDecoration(
suffixIcon: IconButton( hintText: hintText,
onPressed: onToggleVisibility, hintStyle: Theme.of(
icon: Icon( context,
isVisible.value ? Icons.visibility_off : Icons.visibility, ).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
color: effectiveAccentColor, errorText:
semanticLabel: errorText != null && errorText!.isNotEmpty ? errorText : null,
isVisible.value ? 'Hide password' : 'Show password', errorStyle: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: TColors.error),
contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
),
prefixIcon: prefixIcon,
suffixIcon: IconButton(
onPressed: onToggleVisibility,
icon: Icon(
isVisible.value ? Icons.visibility_off : Icons.visibility,
color: effectiveAccentColor,
semanticLabel:
isVisible.value ? 'Hide password' : 'Show password',
),
),
filled: true,
fillColor:
isDark ? TColors.darkContainer : TColors.lightContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: effectiveAccentColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.error, width: 1.5),
), ),
),
filled: true,
fillColor: isDark ? TColors.darkContainer : TColors.lightContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: effectiveAccentColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.error, width: 1.5),
), ),
), ),
), ),

View File

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

View File

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

View File

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

@ -71,10 +71,7 @@ class CustomTextField extends StatelessWidget {
context, context,
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary), ).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
errorText: errorText:
errorText != null && errorText!.isNotEmpty ? errorText : null, errorText != null && errorText!.isNotEmpty ? errorText : null,
errorStyle: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: TColors.error),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.md, horizontal: TSizes.md,
vertical: 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"; "assets/images/animations/splash-dark.json";
static const String splashLightAnimation = static const String splashLightAnimation =
"assets/images/animations/splash-light.json"; "assets/images/animations/splash-light.json";
static const String unverifyLocationAnimation =
"assets/images/animations/empty-address.json";
// -- Content Images (assets/images/content) // -- Content Images (assets/images/content)
static const String backpackingDark = static const String backpackingDark =

View File

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

View File

@ -9,81 +9,104 @@ class TLoaders {
static hideSnackBar() => ScaffoldMessenger.of(Get.context!).hideCurrentSnackBar(); static hideSnackBar() => ScaffoldMessenger.of(Get.context!).hideCurrentSnackBar();
static customToast({required message}) { static customToast({required message}) {
ScaffoldMessenger.of(Get.context!).showSnackBar( // Use post-frame callback for any UI updates
SnackBar( WidgetsBinding.instance.addPostFrameCallback((_) {
elevation: 0, ScaffoldMessenger.of(Get.context!).showSnackBar(
duration: const Duration(seconds: 3), SnackBar(
backgroundColor: Colors.transparent, elevation: 0,
content: Container( duration: const Duration(seconds: 3),
padding: const EdgeInsets.all(12.0), backgroundColor: Colors.transparent,
margin: const EdgeInsets.symmetric(horizontal: 30), content: Container(
decoration: BoxDecoration( padding: const EdgeInsets.all(12.0),
borderRadius: BorderRadius.circular(30), margin: const EdgeInsets.symmetric(horizontal: 30),
color: THelperFunctions.isDarkMode(Get.context!) ? TColors.darkerGrey.withOpacity(0.9) : TColors.grey.withOpacity(0.9), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
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}) { static successSnackBar({required title, message = '', duration = 3}) {
Get.snackbar( // Use post-frame callback for any UI updates
title, WidgetsBinding.instance.addPostFrameCallback((_) {
message, Get.snackbar(
isDismissible: true, title,
shouldIconPulse: true, message,
colorText: Colors.white, isDismissible: true,
backgroundColor: TColors.primary, shouldIconPulse: true,
snackPosition: SnackPosition.TOP, colorText: Colors.white,
duration: Duration(seconds: duration), backgroundColor: TColors.primary,
margin: const EdgeInsets.all(10), snackPosition: SnackPosition.TOP,
icon: const Icon(Iconsax.check, color: TColors.white), duration: Duration(seconds: duration),
); margin: const EdgeInsets.all(10),
icon: const Icon(Iconsax.check, color: TColors.white),
);
});
} }
static warningSnackBar({required title, message = ''}) { static warningSnackBar({required title, message = ''}) {
Get.snackbar( // Use post-frame callback for any UI updates
title, WidgetsBinding.instance.addPostFrameCallback((_) {
message, Get.snackbar(
isDismissible: true, title,
shouldIconPulse: true, message,
colorText: TColors.white, isDismissible: true,
backgroundColor: Colors.orange, shouldIconPulse: true,
snackPosition: SnackPosition.TOP, colorText: TColors.white,
duration: const Duration(seconds: 3), backgroundColor: Colors.orange,
margin: const EdgeInsets.all(20), snackPosition: SnackPosition.TOP,
icon: const Icon(Iconsax.warning_2, color: TColors.white), duration: const Duration(seconds: 3),
); margin: const EdgeInsets.all(20),
icon: const Icon(Iconsax.warning_2, color: TColors.white),
);
});
} }
static errorSnackBar({required title, message = ''}) { static errorSnackBar({required title, message = ''}) {
Get.snackbar( // Use post-frame callback for any UI updates
title, WidgetsBinding.instance.addPostFrameCallback((_) {
message, Get.snackbar(
isDismissible: true, title,
shouldIconPulse: true, message,
colorText: TColors.white, isDismissible: true,
backgroundColor: Colors.red.shade600, shouldIconPulse: true,
snackPosition: SnackPosition.TOP, colorText: TColors.white,
duration: const Duration(seconds: 3), backgroundColor: Colors.red.shade600,
margin: const EdgeInsets.all(20), snackPosition: SnackPosition.TOP,
icon: const Icon(Iconsax.warning_2, color: TColors.white), duration: const Duration(seconds: 3),
); margin: const EdgeInsets.all(20),
icon: const Icon(Iconsax.warning_2, color: TColors.white),
);
});
} }
static infoSnackBar({required title, message = ''}) { static infoSnackBar({required title, message = ''}) {
Get.snackbar( // Use post-frame callback for any UI updates
title, WidgetsBinding.instance.addPostFrameCallback((_) {
message, Get.snackbar(
isDismissible: true, title,
shouldIconPulse: true, message,
colorText: TColors.white, isDismissible: true,
backgroundColor: Colors.blue.shade600, shouldIconPulse: true,
snackPosition: SnackPosition.TOP, colorText: TColors.white,
duration: const Duration(seconds: 3), backgroundColor: Colors.blue.shade600,
margin: const EdgeInsets.all(20), snackPosition: SnackPosition.TOP,
icon: const Icon(Iconsax.info_circle, color: TColors.white), duration: const Duration(seconds: 3),
); margin: const EdgeInsets.all(20),
icon: const Icon(Iconsax.info_circle, color: TColors.white),
);
});
} }
} }

View File

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