feat(profile): enhance officer and user profile details with improved null checks and formatting
- Updated OfficerProfileDetail to include better null checks for officer attributes and added patrol statistics. - Introduced a new method to format role names for better display. - Enhanced UserProfileDetail to handle cases where user data may not be available, displaying appropriate messages. - Added address display functionality in UserProfileDetail. - Improved SettingsScreen to fetch user profile data if not already loaded and display it with a loading indicator. - Refactored SecuritySettingsScreen to use reactive state management with GetX for better performance and user experience. - Created ProfileEditForm for editing user profiles with validation and image upload options. - Added a custom circular loader widget for consistent loading indicators across the app.
This commit is contained in:
parent
c6931619de
commit
0f3cb701c4
|
@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.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:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||||
|
import 'package:sigap/app.dart';
|
||||||
import 'package:sigap/navigation_menu.dart';
|
import 'package:sigap/navigation_menu.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
|
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
|
||||||
import 'package:sigap/src/utils/theme/theme.dart';
|
import 'package:sigap/src/utils/theme/theme.dart';
|
||||||
|
@ -48,22 +49,6 @@ Future<void> main() async {
|
||||||
|
|
||||||
MapboxOptions.setAccessToken(mapboxAccesToken);
|
MapboxOptions.setAccessToken(mapboxAccesToken);
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const App());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
|
||||||
const MyApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GetMaterialApp(
|
|
||||||
title: 'SIGAP',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: TAppTheme.lightTheme,
|
|
||||||
darkTheme: TAppTheme.darkTheme,
|
|
||||||
themeMode: ThemeMode.system,
|
|
||||||
initialBinding: PersonalizationBindings(),
|
|
||||||
home: const NavigationMenu(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/bindings/auth_bindings.dart';
|
import 'package:sigap/src/features/auth/presentasion/bindings/auth_bindings.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/bindings/onboarding_binding.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/bindings/onboarding_binding.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
|
||||||
|
|
||||||
class ControllerBindings extends Bindings {
|
class ControllerBindings extends Bindings {
|
||||||
@override
|
@override
|
||||||
|
@ -13,5 +14,8 @@ class ControllerBindings extends Bindings {
|
||||||
// Auth Bindings
|
// Auth Bindings
|
||||||
AuthControllerBindings().dependencies();
|
AuthControllerBindings().dependencies();
|
||||||
|
|
||||||
|
// Personalization Bindings
|
||||||
|
PersonalizationBindings().dependencies();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,8 @@ import 'package:sigap/src/features/onboarding/presentasion/pages/location-warnin
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart';
|
import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/pages/settings/setting_screen.dart';
|
||||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||||
|
|
||||||
class AppPages {
|
class AppPages {
|
||||||
|
@ -67,6 +69,20 @@ class AppPages {
|
||||||
page: () => const LivenessDetectionPage(),
|
page: () => const LivenessDetectionPage(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Personalization
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.settings,
|
||||||
|
page: () => const SettingsScreen(),
|
||||||
|
preventDuplicates: false,
|
||||||
|
|
||||||
|
),
|
||||||
|
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.profile,
|
||||||
|
page: () => const ProfileScreen(isCurrentUser: true),
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ class AuthenticationRepository extends GetxController {
|
||||||
final _supabase = SupabaseService.instance.client;
|
final _supabase = SupabaseService.instance.client;
|
||||||
final _locationService = LocationService.instance;
|
final _locationService = LocationService.instance;
|
||||||
final _biometricService = Get.find<BiometricService>();
|
final _biometricService = Get.find<BiometricService>();
|
||||||
|
final _logger = Logger();
|
||||||
|
|
||||||
// Getters that use the Supabase service
|
// Getters that use the Supabase service
|
||||||
User? get authUser => SupabaseService.instance.currentUser;
|
User? get authUser => SupabaseService.instance.currentUser;
|
||||||
|
@ -41,26 +42,23 @@ class AuthenticationRepository extends GetxController {
|
||||||
|
|
||||||
// Check for biometric login on app start
|
// Check for biometric login on app start
|
||||||
Future<bool> attemptBiometricLogin() async {
|
Future<bool> attemptBiometricLogin() async {
|
||||||
|
try {
|
||||||
if (!await _biometricService.isBiometricLoginEnabled()) {
|
if (!await _biometricService.isBiometricLoginEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? refreshToken = await _biometricService.attemptBiometricLogin();
|
String? sessionToken = await _biometricService.attemptBiometricLogin();
|
||||||
if (refreshToken == null || refreshToken.isEmpty) {
|
if (sessionToken == null || sessionToken.isEmpty) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Use the session token to restore the session
|
||||||
// Use the refresh token to recover the session
|
final response = await restoreSession(sessionToken);
|
||||||
final response = await _supabase.auth.refreshSession(refreshToken);
|
return response;
|
||||||
if (response.session != null) {
|
|
||||||
Get.offAllNamed(AppRoutes.explore);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If refresh token is invalid or expired, disable biometric login
|
// If token is invalid or expired, disable biometric login
|
||||||
await _biometricService.disableBiometricLogin();
|
await _biometricService.disableBiometricLogin();
|
||||||
|
_logger.e('Error during biometric login: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -827,4 +825,23 @@ class AuthenticationRepository extends GetxController {
|
||||||
throw TExceptions('Something went wrong. Please try again later.');
|
throw TExceptions('Something went wrong. Please try again later.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore session using a stored token (for biometric authentication)
|
||||||
|
Future<bool> restoreSession(String sessionToken) async {
|
||||||
|
try {
|
||||||
|
// Use the token to restore a session
|
||||||
|
await _supabase.auth.recoverSession(sessionToken);
|
||||||
|
|
||||||
|
// Check if session was successfully restored
|
||||||
|
final session = _supabase.auth.currentSession;
|
||||||
|
if (session != null) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw 'Failed to restore session';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('Error restoring session: $e');
|
||||||
|
throw 'Session restoration failed: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:sigap/src/cores/services/biometric_service.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/utils/constants/app_routes.dart';
|
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';
|
||||||
|
@ -10,6 +11,7 @@ class SignInController extends GetxController {
|
||||||
|
|
||||||
final _logger = Logger();
|
final _logger = Logger();
|
||||||
final _authRepo = Get.find<AuthenticationRepository>();
|
final _authRepo = Get.find<AuthenticationRepository>();
|
||||||
|
final _biometricService = Get.find<BiometricService>();
|
||||||
|
|
||||||
// Form controllers
|
// Form controllers
|
||||||
final email = TextEditingController();
|
final email = TextEditingController();
|
||||||
|
@ -22,6 +24,13 @@ class SignInController extends GetxController {
|
||||||
// States
|
// States
|
||||||
final RxBool isLoading = RxBool(false);
|
final RxBool isLoading = RxBool(false);
|
||||||
final RxBool isPasswordVisible = RxBool(false);
|
final RxBool isPasswordVisible = RxBool(false);
|
||||||
|
final RxBool isBiometricAvailable = RxBool(false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
_checkBiometricAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
|
@ -30,6 +39,48 @@ class SignInController extends GetxController {
|
||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if biometrics is available and enabled for the user
|
||||||
|
Future<void> _checkBiometricAvailability() async {
|
||||||
|
try {
|
||||||
|
final isAvailable = _biometricService.isBiometricAvailable.value;
|
||||||
|
final isEnabled = await _biometricService.isBiometricLoginEnabled();
|
||||||
|
isBiometricAvailable.value = isAvailable && isEnabled;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('Error checking biometric availability: $e');
|
||||||
|
isBiometricAvailable.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to sign in with biometrics
|
||||||
|
Future<void> signInWithBiometrics() async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
// Reuse the existing method from AuthRepository
|
||||||
|
bool success = await _authRepo.attemptBiometricLogin();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Redirect is handled automatically by restoreSession
|
||||||
|
_authRepo.screenRedirect();
|
||||||
|
} else {
|
||||||
|
// Show error if biometric authentication failed
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Authentication Failed',
|
||||||
|
message:
|
||||||
|
'Biometric authentication unsuccessful. Please try again or use email and password.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('Biometric sign-in error: $e');
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Biometric Sign In Failed',
|
||||||
|
message: e.toString(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle password visibility
|
// Toggle password visibility
|
||||||
void togglePasswordVisibility() {
|
void togglePasswordVisibility() {
|
||||||
isPasswordVisible.value = !isPasswordVisible.value;
|
isPasswordVisible.value = !isPasswordVisible.value;
|
||||||
|
|
|
@ -108,6 +108,36 @@ class SignInScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Biometric Sign In Button (shown only if available)
|
||||||
|
Obx(() {
|
||||||
|
return controller.isBiometricAvailable.value
|
||||||
|
? ElevatedButton.icon(
|
||||||
|
onPressed: controller.signInWithBiometrics,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.surface,
|
||||||
|
foregroundColor:
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
minimumSize: const Size(double.infinity, 0),
|
||||||
|
),
|
||||||
|
icon: const Icon(TablerIcons.fingerprint),
|
||||||
|
label: const Text('Sign In with Biometrics'),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
}),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Or divider
|
// Or divider
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:sigap/src/features/personalization/data/repositories/officers_repository.dart';
|
|
||||||
import 'package:sigap/src/features/personalization/data/repositories/profile_repository.dart';
|
|
||||||
import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart';
|
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/display_mode_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/display_mode_controller.dart';
|
||||||
|
@ -22,9 +19,6 @@ class PersonalizationBindings extends Bindings {
|
||||||
Get.lazyPut<Logger>(() => Logger(), fenix: true);
|
Get.lazyPut<Logger>(() => Logger(), fenix: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register repositories
|
|
||||||
_registerRepositories();
|
|
||||||
|
|
||||||
// Register profile controllers
|
// Register profile controllers
|
||||||
_registerProfileControllers();
|
_registerProfileControllers();
|
||||||
|
|
||||||
|
@ -32,22 +26,11 @@ class PersonalizationBindings extends Bindings {
|
||||||
_registerSettingsControllers();
|
_registerSettingsControllers();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerRepositories() {
|
|
||||||
// Register repositories with fenix: true to keep them alive when not in use
|
|
||||||
// but recreate them if they were destroyed
|
|
||||||
Get.lazyPut<UserRepository>(() => UserRepository(), fenix: true);
|
|
||||||
|
|
||||||
Get.lazyPut<ProfileRepository>(() => ProfileRepository(), fenix: true);
|
|
||||||
|
|
||||||
Get.lazyPut<OfficerRepository>(() => OfficerRepository(), fenix: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _registerProfileControllers() {
|
void _registerProfileControllers() {
|
||||||
// Register profile-related controllers
|
// Register profile-related controllers
|
||||||
Get.lazyPut<ProfileController>(() => ProfileController(), fenix: true);
|
Get.lazyPut<ProfileController>(() => ProfileController(), fenix: true);
|
||||||
|
|
||||||
Get.lazyPut<OfficerProfileController>(
|
Get.lazyPut<OfficerController>(() => OfficerController(),
|
||||||
() => OfficerProfileController(),
|
|
||||||
fenix: true,
|
fenix: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:sigap/src/features/personalization/data/repositories/officers_re
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
|
||||||
|
|
||||||
class OfficerProfileController extends BaseProfileController {
|
class OfficerController extends BaseProfileController {
|
||||||
// Use find to get previously registered repository
|
// Use find to get previously registered repository
|
||||||
final _officerRepository = Get.find<OfficerRepository>();
|
final _officerRepository = Get.find<OfficerRepository>();
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,6 @@ class ProfileBinding extends Bindings {
|
||||||
|
|
||||||
// Register controllers
|
// Register controllers
|
||||||
Get.lazyPut(() => ProfileController(), fenix: true);
|
Get.lazyPut(() => ProfileController(), fenix: true);
|
||||||
Get.lazyPut(() => OfficerProfileController(), fenix: true);
|
Get.lazyPut(() => OfficerController(), fenix: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,10 @@ import 'package:sigap/src/features/personalization/data/repositories/users_repos
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart';
|
||||||
|
|
||||||
class ProfileController extends BaseProfileController {
|
class ProfileController extends BaseProfileController {
|
||||||
// Repositories
|
// Repositories using Get.find() instead of direct initialization
|
||||||
final _userRepository = Get.find<UserRepository>();
|
final UserRepository _userRepository = Get.find<UserRepository>();
|
||||||
final _profileRepository = Get.find<ProfileRepository>();
|
final ProfileRepository _profileRepository = Get.find<ProfileRepository>();
|
||||||
final _officerRepository = Get.find<OfficerRepository>();
|
final OfficerRepository _officerRepository = Get.find<OfficerRepository>();
|
||||||
|
|
||||||
// Observable state variables
|
// Observable state variables
|
||||||
final Rx<UserModel?> user = Rx<UserModel?>(null);
|
final Rx<UserModel?> user = Rx<UserModel?>(null);
|
||||||
|
@ -21,6 +21,7 @@ class ProfileController extends BaseProfileController {
|
||||||
final RxBool isOfficer = false.obs;
|
final RxBool isOfficer = false.obs;
|
||||||
final RxBool isInitialized = false.obs;
|
final RxBool isInitialized = false.obs;
|
||||||
final RxBool isEditMode = false.obs;
|
final RxBool isEditMode = false.obs;
|
||||||
|
final RxBool isFetching = false.obs; // Track initial data loading
|
||||||
|
|
||||||
// Form controllers for edit mode
|
// Form controllers for edit mode
|
||||||
late TextEditingController firstNameController;
|
late TextEditingController firstNameController;
|
||||||
|
@ -76,6 +77,7 @@ class ProfileController extends BaseProfileController {
|
||||||
// Fetch the user profile data
|
// Fetch the user profile data
|
||||||
Future<void> fetchUserProfile() async {
|
Future<void> fetchUserProfile() async {
|
||||||
try {
|
try {
|
||||||
|
isFetching.value = true; // Start fetching
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
|
@ -98,6 +100,7 @@ class ProfileController extends BaseProfileController {
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
isFetching.value = false; // Done fetching
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -214,6 +217,7 @@ class ProfileController extends BaseProfileController {
|
||||||
// Fetch profile by user ID
|
// Fetch profile by user ID
|
||||||
Future<void> fetchProfileByUserId(String userId) async {
|
Future<void> fetchProfileByUserId(String userId) async {
|
||||||
try {
|
try {
|
||||||
|
isFetching.value = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
|
@ -237,6 +241,8 @@ class ProfileController extends BaseProfileController {
|
||||||
officer.value = null;
|
officer.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isInitialized.value = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('Failed to load profile: ${e.toString()}');
|
setError('Failed to load profile: ${e.toString()}');
|
||||||
showError(
|
showError(
|
||||||
|
@ -245,6 +251,7 @@ class ProfileController extends BaseProfileController {
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
isFetching.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:sigap/src/cores/services/biometric_service.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/base_settings_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/base_settings_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||||
|
|
||||||
class SecurityController extends BaseSettingsController {
|
class SecurityController extends BaseSettingsController {
|
||||||
|
final _logger = Logger();
|
||||||
|
final _biometricService = Get.find<BiometricService>();
|
||||||
|
final _authRepo = Get.find<AuthenticationRepository>();
|
||||||
|
|
||||||
// Security settings
|
// Security settings
|
||||||
final RxBool requireBiometric = true.obs;
|
final RxBool requireBiometric = false.obs;
|
||||||
final RxBool enable2FA = false.obs;
|
final RxBool enable2FA = false.obs;
|
||||||
final RxBool sendEmailAlerts = true.obs;
|
final RxBool sendEmailAlerts = true.obs;
|
||||||
final RxBool sendPushNotification = false.obs;
|
final RxBool sendPushNotification = false.obs;
|
||||||
|
@ -21,7 +29,64 @@ class SecurityController extends BaseSettingsController {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle security setting
|
// Toggle biometric security setting
|
||||||
|
Future<bool> toggleBiometricAuthentication(bool enable) async {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (enable) {
|
||||||
|
// Check if biometrics is available
|
||||||
|
if (!_biometricService.isBiometricAvailable.value) {
|
||||||
|
setError('Biometric authentication is not available on this device');
|
||||||
|
TLoaders.warningSnackBar(
|
||||||
|
title: 'Not Available',
|
||||||
|
message: 'Biometric authentication is not available on your device',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to authenticate before enabling
|
||||||
|
bool authenticated = await _biometricService.authenticate(
|
||||||
|
reason: 'Authenticate to enable biometric login',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
setError('Biometric authentication failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable biometric login
|
||||||
|
await _biometricService.enableBiometricLogin();
|
||||||
|
requireBiometric.value = true;
|
||||||
|
TLoaders.successSnackBar(
|
||||||
|
title: 'Enabled',
|
||||||
|
message: 'Biometric authentication has been enabled',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Disable biometric login
|
||||||
|
await _biometricService.disableBiometricLogin();
|
||||||
|
requireBiometric.value = false;
|
||||||
|
TLoaders.successSnackBar(
|
||||||
|
title: 'Disabled',
|
||||||
|
message: 'Biometric authentication has been disabled',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('Error toggling biometric authentication: $e');
|
||||||
|
setError('Failed to change biometric settings: ${e.toString()}');
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to change biometric settings',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle other security settings
|
||||||
void toggleSecuritySetting(String setting, bool value) {
|
void toggleSecuritySetting(String setting, bool value) {
|
||||||
switch (setting) {
|
switch (setting) {
|
||||||
case 'require_biometric':
|
case 'require_biometric':
|
||||||
|
@ -123,6 +188,10 @@ class SecurityController extends BaseSettingsController {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// Load biometric setting from actual service
|
||||||
|
requireBiometric.value =
|
||||||
|
await _biometricService.isBiometricLoginEnabled();
|
||||||
|
|
||||||
// TODO: Load security settings from persistent storage
|
// TODO: Load security settings from persistent storage
|
||||||
final prefs = Get.find<dynamic>(); // Replace with your storage solution
|
final prefs = Get.find<dynamic>(); // Replace with your storage solution
|
||||||
// requireBiometric.value = prefs.getBool('require_biometric') ?? true;
|
// requireBiometric.value = prefs.getBool('require_biometric') ?? true;
|
||||||
|
@ -133,6 +202,7 @@ class SecurityController extends BaseSettingsController {
|
||||||
// passcodeLastChanged.value = prefs.getString('passcode_last_changed') ?? '';
|
// passcodeLastChanged.value = prefs.getString('passcode_last_changed') ?? '';
|
||||||
// backupCodes.value = prefs.getStringList('backup_codes') ?? [];
|
// backupCodes.value = prefs.getStringList('backup_codes') ?? [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_logger.e('Failed to load security settings: $e');
|
||||||
setError('Failed to load security settings: ${e.toString()}');
|
setError('Failed to load security settings: ${e.toString()}');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
|
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
|
||||||
import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart';
|
import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart';
|
||||||
import 'package:sigap/src/features/personalization/data/models/models/users_model.dart';
|
import 'package:sigap/src/features/personalization/data/models/models/users_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/profile_edit_form.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart';
|
||||||
|
|
||||||
class ProfileScreen extends StatelessWidget {
|
class ProfileScreen extends StatelessWidget {
|
||||||
|
@ -10,6 +13,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
final ProfileModel? profile;
|
final ProfileModel? profile;
|
||||||
final OfficerModel? officer;
|
final OfficerModel? officer;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
|
final String? userId;
|
||||||
|
|
||||||
const ProfileScreen({
|
const ProfileScreen({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -17,6 +21,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
this.profile,
|
this.profile,
|
||||||
this.officer,
|
this.officer,
|
||||||
this.isCurrentUser = false,
|
this.isCurrentUser = false,
|
||||||
|
this.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -24,14 +29,21 @@ class ProfileScreen extends StatelessWidget {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
|
||||||
// For demo purposes, using dummy data if no data is provided
|
// Get the profile controller
|
||||||
final displayName =
|
final controller = Get.find<ProfileController>();
|
||||||
profile?.fullName ?? user?.profile?.fullName ?? 'Anita Rose';
|
|
||||||
final location = _getLocationString();
|
// If userId is provided, fetch that specific profile
|
||||||
final avatarUrl = profile?.avatar ?? user?.profile?.avatar;
|
if (userId != null && controller.user.value?.id != userId) {
|
||||||
final bio =
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
profile?.bio ??
|
controller.fetchProfileByUserId(userId!);
|
||||||
'I am currently pursuing a major in Management Economics and Finance at the University of Guelph, Ontario, Canada. Please let me know if I can help you in any way';
|
});
|
||||||
|
}
|
||||||
|
// Otherwise if this is current user and not loaded yet, fetch current profile
|
||||||
|
else if (isCurrentUser && !controller.isInitialized.value) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
controller.fetchUserProfile();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(
|
backgroundColor: const Color(
|
||||||
|
@ -45,19 +57,99 @@ class ProfileScreen extends StatelessWidget {
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
CircleAvatar(
|
if (isCurrentUser)
|
||||||
|
Obx(
|
||||||
|
() => CircleAvatar(
|
||||||
backgroundColor: theme.primaryColor.withOpacity(0.1),
|
backgroundColor: theme.primaryColor.withOpacity(0.1),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.person_outline, color: theme.primaryColor),
|
icon: Icon(
|
||||||
|
controller.isEditMode.value
|
||||||
|
? Icons.close
|
||||||
|
: Icons.edit_outlined,
|
||||||
|
color: theme.primaryColor,
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Show profile options
|
controller.toggleEditMode();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Obx(() {
|
||||||
|
// Show loading state
|
||||||
|
if (controller.isFetching.value) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(color: theme.primaryColor),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Loading profile...', style: theme.textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state
|
||||||
|
if (controller.errorMessage.value.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Error loading profile',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
controller.errorMessage.value,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => controller.refreshProfile(),
|
||||||
|
child: Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user data from controller or props
|
||||||
|
final userData = controller.user.value ?? user;
|
||||||
|
final profileData = controller.profile.value ?? profile;
|
||||||
|
final officerData = controller.officer.value ?? officer;
|
||||||
|
final isOfficerUser = controller.isOfficer.value || officerData != null;
|
||||||
|
|
||||||
|
// Check if we're in edit mode
|
||||||
|
if (controller.isEditMode.value && isCurrentUser) {
|
||||||
|
return ProfileEditForm(
|
||||||
|
profile: profileData,
|
||||||
|
user: userData,
|
||||||
|
officer: isOfficerUser ? officerData : null,
|
||||||
|
isOfficer: isOfficerUser,
|
||||||
|
controller: controller,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For display purposes, using dummy data if no data is provided
|
||||||
|
final displayName =
|
||||||
|
profileData?.fullName ?? userData?.profile?.fullName ?? 'User';
|
||||||
|
final locationString = _getLocationString(profileData, officerData);
|
||||||
|
final avatarUrl =
|
||||||
|
profileData?.avatar ??
|
||||||
|
userData?.profile?.avatar ??
|
||||||
|
officerData?.avatar;
|
||||||
|
final bioText = profileData?.bio ?? 'No bio information available';
|
||||||
|
|
||||||
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Top section with avatar, name, and location
|
// Top section with avatar, name, and location
|
||||||
Column(
|
Column(
|
||||||
|
@ -112,7 +204,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
location,
|
locationString,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.hintColor,
|
color: theme.hintColor,
|
||||||
),
|
),
|
||||||
|
@ -169,7 +261,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
Text('About', style: theme.textTheme.titleLarge),
|
Text('About', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
bio,
|
bioText,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
color: theme.textTheme.bodyMedium?.color
|
color: theme.textTheme.bodyMedium?.color
|
||||||
|
@ -180,42 +272,51 @@ class ProfileScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// User-specific information
|
// User/Officer-specific information
|
||||||
officer != null
|
isOfficerUser
|
||||||
? OfficerProfileDetail(officer: officer!)
|
? OfficerProfileDetail(officer: officerData!)
|
||||||
: UserProfileDetail(user: user, profile: profile),
|
: UserProfileDetail(
|
||||||
|
user: userData,
|
||||||
|
profile: profileData,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getLocationString() {
|
// Helper for getting location string from profile/officer data
|
||||||
|
String _getLocationString(ProfileModel? profile, OfficerModel? officer) {
|
||||||
if (officer?.placeOfBirth != null) {
|
if (officer?.placeOfBirth != null) {
|
||||||
return officer!.placeOfBirth!;
|
return officer!.placeOfBirth!;
|
||||||
} else if (profile?.placeOfBirth != null) {
|
} else if (profile?.placeOfBirth != null &&
|
||||||
return profile!.placeOfBirth!;
|
profile!.placeOfBirth!.isNotEmpty) {
|
||||||
|
return profile.placeOfBirth!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to address if available
|
// Fallback to address if available
|
||||||
if (profile?.address != null) {
|
if (profile?.address != null) {
|
||||||
final address = profile!.address!;
|
final address = profile!.address!;
|
||||||
|
final fullAddress = address['full_address'];
|
||||||
final city = address['city'];
|
final city = address['city'];
|
||||||
final country = address['country'];
|
final country = address['country'];
|
||||||
|
|
||||||
if (city != null && country != null) {
|
if (fullAddress != null && fullAddress.toString().isNotEmpty) {
|
||||||
|
return fullAddress.toString();
|
||||||
|
} else if (city != null && country != null) {
|
||||||
return '$city, $country';
|
return '$city, $country';
|
||||||
} else if (city != null) {
|
} else if (city != null) {
|
||||||
return city;
|
return city.toString();
|
||||||
} else if (country != null) {
|
} else if (country != null) {
|
||||||
return country;
|
return country.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Ontario, Canada'; // Default fallback
|
return 'Location not specified'; // Default fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,22 +22,21 @@ class OfficerProfileDetail extends StatelessWidget {
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Officer information
|
// Officer information rows
|
||||||
_buildInfoRow(
|
if (officer.nrp != null)
|
||||||
context,
|
_buildInfoRow(context, 'NRP',
|
||||||
'NRP',
|
officer.nrp.toString()),
|
||||||
officer.nrp?.toString() ?? 'Not Available',
|
|
||||||
),
|
|
||||||
|
|
||||||
if (officer.rank != null)
|
if (officer.rank != null && officer.rank!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Rank', officer.rank!),
|
_buildInfoRow(context, 'Rank', officer.rank!),
|
||||||
|
|
||||||
if (officer.position != null)
|
if (officer.position != null && officer.position!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Position', officer.position!),
|
_buildInfoRow(context, 'Position', officer.position!),
|
||||||
|
|
||||||
_buildInfoRow(context, 'Email', officer.email ?? 'Not Available'),
|
if (officer.email != null && officer.email!.isNotEmpty)
|
||||||
|
_buildInfoRow(context, 'Email', officer.email!),
|
||||||
|
|
||||||
if (officer.phone != null)
|
if (officer.phone != null && officer.phone!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Phone', officer.phone!),
|
_buildInfoRow(context, 'Phone', officer.phone!),
|
||||||
|
|
||||||
if (officer.dateOfBirth != null)
|
if (officer.dateOfBirth != null)
|
||||||
|
@ -47,12 +46,15 @@ class OfficerProfileDetail extends StatelessWidget {
|
||||||
DateFormat('dd MMMM yyyy').format(officer.dateOfBirth!),
|
DateFormat('dd MMMM yyyy').format(officer.dateOfBirth!),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (officer.placeOfBirth != null)
|
if (officer.placeOfBirth != null && officer.placeOfBirth!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Place of Birth', officer.placeOfBirth!),
|
_buildInfoRow(context, 'Place of Birth', officer.placeOfBirth!),
|
||||||
|
|
||||||
if (officer.unitId != null)
|
if (officer.unitId != null && officer.unitId!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Unit ID', officer.unitId!),
|
_buildInfoRow(context, 'Unit ID', officer.unitId!),
|
||||||
|
|
||||||
|
if (officer.patrolUnitId != null && officer.patrolUnitId!.isNotEmpty)
|
||||||
|
_buildInfoRow(context, 'Patrol Unit', officer.patrolUnitId!),
|
||||||
|
|
||||||
if (officer.validUntil != null)
|
if (officer.validUntil != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
|
@ -61,7 +63,14 @@ class OfficerProfileDetail extends StatelessWidget {
|
||||||
),
|
),
|
||||||
|
|
||||||
if (officer.role != null)
|
if (officer.role != null)
|
||||||
_buildInfoRow(context, 'Role', officer.role!.name ?? 'Officer'),
|
_buildInfoRow(context, 'Role', _formatRoleName(officer.role!.name)),
|
||||||
|
|
||||||
|
if (officer.createdAt != null)
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Registered On',
|
||||||
|
DateFormat('dd MMMM yyyy').format(officer.createdAt!),
|
||||||
|
),
|
||||||
|
|
||||||
// Status information if banned
|
// Status information if banned
|
||||||
if (officer.isBanned) ...[
|
if (officer.isBanned) ...[
|
||||||
|
@ -77,7 +86,12 @@ class OfficerProfileDetail extends StatelessWidget {
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
_buildInfoRow(context, 'Status', 'Banned', valueColor: Colors.red),
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Status',
|
||||||
|
'Account Suspended',
|
||||||
|
valueColor: Colors.red,
|
||||||
|
),
|
||||||
|
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
|
@ -88,10 +102,34 @@ class OfficerProfileDetail extends StatelessWidget {
|
||||||
if (officer.bannedUntil != null)
|
if (officer.bannedUntil != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
'Banned Until',
|
'Suspended Until',
|
||||||
DateFormat('dd MMMM yyyy, HH:mm').format(officer.bannedUntil!),
|
DateFormat('dd MMMM yyyy, HH:mm').format(officer.bannedUntil!),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Patrol Stats
|
||||||
|
if (officer.panicStrike > 0 || officer.spoofingAttempts > 0) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('Patrol Statistics', style: theme.textTheme.titleLarge),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (officer.panicStrike > 0)
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Panic Button Strike',
|
||||||
|
officer.panicStrike.toString(),
|
||||||
|
valueColor: officer.panicStrike > 2 ? Colors.orange : null,
|
||||||
|
),
|
||||||
|
|
||||||
|
if (officer.spoofingAttempts > 0)
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Spoofing Attempts',
|
||||||
|
officer.spoofingAttempts.toString(),
|
||||||
|
valueColor: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -128,4 +166,18 @@ class OfficerProfileDetail extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format role name for better display
|
||||||
|
String _formatRoleName(String roleName) {
|
||||||
|
// Convert snake_case or kebab-case to Title Case
|
||||||
|
return roleName
|
||||||
|
.split(RegExp(r'[_\-]'))
|
||||||
|
.map(
|
||||||
|
(word) =>
|
||||||
|
word.isEmpty
|
||||||
|
? ''
|
||||||
|
: '${word[0].toUpperCase()}${word.substring(1)}',
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,480 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/data/models/models/users_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class ProfileEditForm extends StatelessWidget {
|
||||||
|
final ProfileModel? profile;
|
||||||
|
final UserModel? user;
|
||||||
|
final OfficerModel? officer;
|
||||||
|
final bool isOfficer;
|
||||||
|
final ProfileController controller;
|
||||||
|
|
||||||
|
const ProfileEditForm({
|
||||||
|
super.key,
|
||||||
|
required this.profile,
|
||||||
|
required this.user,
|
||||||
|
this.officer,
|
||||||
|
required this.isOfficer,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Avatar Edit Section
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Profile Avatar with edit button
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: theme.cardColor,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child:
|
||||||
|
profile?.avatar != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
child: Image.network(
|
||||||
|
profile!.avatar!,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 50,
|
||||||
|
color: theme.iconTheme.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.camera_alt,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
onPressed: () => _showImageSourceOptions(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
|
// Fields section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwSections),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// First Name field
|
||||||
|
_buildTextField(
|
||||||
|
context: context,
|
||||||
|
controller: controller.firstNameController,
|
||||||
|
label: 'First Name',
|
||||||
|
validator:
|
||||||
|
(v) => TValidators.validateUserInput(
|
||||||
|
'First Name',
|
||||||
|
v,
|
||||||
|
50,
|
||||||
|
required: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Last Name field
|
||||||
|
_buildTextField(
|
||||||
|
context: context,
|
||||||
|
controller: controller.lastNameController,
|
||||||
|
label: 'Last Name',
|
||||||
|
validator:
|
||||||
|
(v) =>
|
||||||
|
TValidators.validateUserInput('Last Name', v, 50),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bio field (multiline)
|
||||||
|
_buildTextField(
|
||||||
|
context: context,
|
||||||
|
controller: controller.bioController,
|
||||||
|
label: 'Bio',
|
||||||
|
validator:
|
||||||
|
(v) => TValidators.validateUserInput('Bio', v, 500),
|
||||||
|
maxLines: 3,
|
||||||
|
hint: 'Tell us about yourself...',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Phone field
|
||||||
|
_buildTextField(
|
||||||
|
context: context,
|
||||||
|
controller: controller.phoneController,
|
||||||
|
label: 'Phone Number',
|
||||||
|
validator: TValidators.validatePhoneNumber,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
hint: 'Enter phone number',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Place of Birth field
|
||||||
|
_buildTextField(
|
||||||
|
context: context,
|
||||||
|
controller: controller.birthPlaceController,
|
||||||
|
label: 'Place of Birth',
|
||||||
|
validator:
|
||||||
|
(v) => TValidators.validateUserInput(
|
||||||
|
'Place of Birth',
|
||||||
|
v,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
hint: 'Enter your place of birth',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Date of Birth picker
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: TSizes.spaceBtwInputFields,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Date of Birth',
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _selectDate(context),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(
|
||||||
|
TSizes.inputFieldRadius,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.dividerColor),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.buttonRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Obx(
|
||||||
|
() => Text(
|
||||||
|
controller.birthDate.value != null
|
||||||
|
? DateFormat(
|
||||||
|
'dd MMM yyyy',
|
||||||
|
).format(controller.birthDate.value!)
|
||||||
|
: 'Select date of birth',
|
||||||
|
style:
|
||||||
|
controller.birthDate.value != null
|
||||||
|
? theme.textTheme.bodyMedium
|
||||||
|
: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: theme.hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
color: theme.hintColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Officer-specific fields
|
||||||
|
if (isOfficer && officer != null) ...[
|
||||||
|
_buildSectionHeader('Officer Information', theme),
|
||||||
|
|
||||||
|
// NRP field
|
||||||
|
_buildReadOnlyField(
|
||||||
|
context: context,
|
||||||
|
label: 'NRP',
|
||||||
|
value: officer?.nrp?.toString() ?? 'Not Available',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Rank field
|
||||||
|
_buildReadOnlyField(
|
||||||
|
context: context,
|
||||||
|
label: 'Rank',
|
||||||
|
value: officer?.rank ?? 'Not Available',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Position field
|
||||||
|
_buildReadOnlyField(
|
||||||
|
context: context,
|
||||||
|
label: 'Position',
|
||||||
|
value: officer?.position ?? 'Not Available',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
controller.isLoading.value
|
||||||
|
? null
|
||||||
|
: () => controller.saveChanges(),
|
||||||
|
child:
|
||||||
|
controller.isLoading.value
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Save Changes'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.errorMessage.value.isNotEmpty
|
||||||
|
? Container(
|
||||||
|
margin: const EdgeInsets.only(
|
||||||
|
top: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.error.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.cardRadiusMd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.errorMessage.value,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title, ThemeData theme) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: TSizes.spaceBtwItems,
|
||||||
|
top: TSizes.spaceBtwSections,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build a text field
|
||||||
|
Widget _buildTextField({
|
||||||
|
required BuildContext context,
|
||||||
|
required String label,
|
||||||
|
TextEditingController? controller,
|
||||||
|
FormFieldValidator<String>? validator,
|
||||||
|
TextInputType keyboardType = TextInputType.text,
|
||||||
|
int maxLines = 1,
|
||||||
|
String? hint,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwInputFields),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
validator: validator,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
maxLines: maxLines,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(TSizes.md),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build a read-only field
|
||||||
|
Widget _buildReadOnlyField({
|
||||||
|
required BuildContext context,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwInputFields),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.dividerColor),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
|
color: theme.disabledColor.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
child: Text(value, style: theme.textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date picker dialog
|
||||||
|
Future<void> _selectDate(BuildContext context) async {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: controller.birthDate.value ?? DateTime(2000),
|
||||||
|
firstDate: DateTime(1920),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(colorScheme: theme.colorScheme),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
controller.setBirthDate(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image source picker
|
||||||
|
void _showImageSourceOptions(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: Radius.circular(TSizes.cardRadiusLg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
builder:
|
||||||
|
(context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
// Drag handle
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.disabledColor,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.photo_camera, color: theme.primaryColor),
|
||||||
|
title: Text('Take a photo', style: theme.textTheme.bodyLarge),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// TODO: Implement camera functionality
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.photo_library, color: theme.primaryColor),
|
||||||
|
title: Text(
|
||||||
|
'Choose from gallery',
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// TODO: Implement gallery functionality
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.defaultSpace),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,26 @@ class UserProfileDetail extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
// Check if we have user data
|
||||||
|
if (user == null && profile == null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_off_outlined, size: 48, color: theme.hintColor),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'User profile information not available',
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -22,15 +42,22 @@ class UserProfileDetail extends StatelessWidget {
|
||||||
Text('Personal Information', style: theme.textTheme.titleLarge),
|
Text('Personal Information', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// User information
|
// NIK (National ID)
|
||||||
if (profile?.nik != null)
|
if (profile?.nik != null && profile!.nik!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'NIK', profile!.nik!),
|
_buildInfoRow(context, 'NIK', profile!.nik!),
|
||||||
|
|
||||||
_buildInfoRow(context, 'Email', user?.email ?? 'Not Available'),
|
// Email
|
||||||
|
if (user?.email != null) _buildInfoRow(context, 'Email', user!.email),
|
||||||
|
|
||||||
if (user?.phone != null)
|
// Username
|
||||||
|
if (profile?.username != null && profile!.username!.isNotEmpty)
|
||||||
|
_buildInfoRow(context, 'Username', profile!.username!),
|
||||||
|
|
||||||
|
// Phone
|
||||||
|
if (user?.phone != null && user!.phone!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Phone', user!.phone!),
|
_buildInfoRow(context, 'Phone', user!.phone!),
|
||||||
|
|
||||||
|
// Birth Date
|
||||||
if (profile?.birthDate != null)
|
if (profile?.birthDate != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
|
@ -38,9 +65,21 @@ class UserProfileDetail extends StatelessWidget {
|
||||||
DateFormat('dd MMMM yyyy').format(profile!.birthDate!),
|
DateFormat('dd MMMM yyyy').format(profile!.birthDate!),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (profile?.placeOfBirth != null)
|
// Place of Birth
|
||||||
|
if (profile?.placeOfBirth != null &&
|
||||||
|
profile!.placeOfBirth!.isNotEmpty)
|
||||||
_buildInfoRow(context, 'Place of Birth', profile!.placeOfBirth!),
|
_buildInfoRow(context, 'Place of Birth', profile!.placeOfBirth!),
|
||||||
|
|
||||||
|
// Address
|
||||||
|
if (profile?.address != null &&
|
||||||
|
_getDisplayAddress(profile!.address!).isNotEmpty)
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Address',
|
||||||
|
_getDisplayAddress(profile!.address!),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Last Sign In
|
||||||
if (user?.lastSignInAt != null)
|
if (user?.lastSignInAt != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
|
@ -48,8 +87,21 @@ class UserProfileDetail extends StatelessWidget {
|
||||||
DateFormat('dd MMM yyyy, HH:mm').format(user!.lastSignInAt!),
|
DateFormat('dd MMM yyyy, HH:mm').format(user!.lastSignInAt!),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Account Created
|
||||||
|
if (user?.createdAt != null)
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Account Created',
|
||||||
|
DateFormat('dd MMMM yyyy').format(user!.createdAt),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Role
|
||||||
if (user?.role != null)
|
if (user?.role != null)
|
||||||
_buildInfoRow(context, 'Role', user!.role!.name ?? 'User'),
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
'Role',
|
||||||
|
_formatRoleName(user!.role!.name ?? 'User'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -78,4 +130,35 @@ class UserProfileDetail extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get displayable address from address map
|
||||||
|
String _getDisplayAddress(Map<String, dynamic> addressMap) {
|
||||||
|
final fullAddress = addressMap['full_address']?.toString();
|
||||||
|
if (fullAddress != null && fullAddress.isNotEmpty) {
|
||||||
|
return fullAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = <String>[];
|
||||||
|
if (addressMap['street'] != null) parts.add(addressMap['street']);
|
||||||
|
if (addressMap['city'] != null) parts.add(addressMap['city']);
|
||||||
|
if (addressMap['state'] != null) parts.add(addressMap['state']);
|
||||||
|
if (addressMap['country'] != null) parts.add(addressMap['country']);
|
||||||
|
if (addressMap['postal_code'] != null) parts.add(addressMap['postal_code']);
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format role name for better display
|
||||||
|
String _formatRoleName(String roleName) {
|
||||||
|
// Convert snake_case or kebab-case to Title Case
|
||||||
|
return roleName
|
||||||
|
.split(RegExp(r'[_\-]'))
|
||||||
|
.map(
|
||||||
|
(word) =>
|
||||||
|
word.isEmpty
|
||||||
|
? ''
|
||||||
|
: '${word[0].toUpperCase()}${word.substring(1)}',
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
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/personalization/presentasion/controllers/profile/profile_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/settings_controller.dart';
|
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/settings_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/contact_screen.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/contact_screen.dart';
|
||||||
|
@ -11,6 +12,7 @@ import 'package:sigap/src/features/personalization/presentasion/pages/settings/w
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/terms_services_screen.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/terms_services_screen.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/text_size_setting.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/text_size_setting.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
@ -24,11 +26,18 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
// Use find instead of implicit creation with Get.put
|
// Use find instead of implicit creation with Get.put
|
||||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||||
|
// Get the profile controller to access user data
|
||||||
|
final ProfileController _profileController = Get.find<ProfileController>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
|
||||||
|
// Fetch user profile data if it's not already loaded
|
||||||
|
if (!_profileController.isInitialized.value) {
|
||||||
|
_profileController.fetchUserProfile();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -49,8 +58,48 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Profile Section - Now clickable
|
// Profile Section with real data
|
||||||
InkWell(
|
Obx(() {
|
||||||
|
// Show loading profile if data is being fetched
|
||||||
|
if (_profileController.isFetching.value) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: theme.primaryColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state if there is an error
|
||||||
|
if (_profileController.errorMessage.value.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load profile',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data
|
||||||
|
final profile = _profileController.profile.value;
|
||||||
|
final user = _profileController.user.value;
|
||||||
|
final officer = _profileController.officer.value;
|
||||||
|
|
||||||
|
// Get display name and avatar
|
||||||
|
final displayName =
|
||||||
|
profile?.fullName ?? user?.email.split('@').first ?? 'User';
|
||||||
|
final username =
|
||||||
|
profile?.username ?? user?.email.split('@').first ?? '';
|
||||||
|
final avatarUrl = profile?.avatar ?? officer?.avatar;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Navigate to the ProfileScreen when tapped
|
// Navigate to the ProfileScreen when tapped
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
|
@ -69,19 +118,24 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 30,
|
radius: 30,
|
||||||
backgroundColor: theme.hintColor,
|
backgroundColor: theme.hintColor,
|
||||||
child: Icon(
|
backgroundImage:
|
||||||
|
avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||||
|
child:
|
||||||
|
avatarUrl == null
|
||||||
|
? Icon(
|
||||||
Icons.person,
|
Icons.person,
|
||||||
size: 35,
|
size: 35,
|
||||||
color: theme.scaffoldBackgroundColor,
|
color: theme.scaffoldBackgroundColor,
|
||||||
),
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 15),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Anita Rose', style: theme.textTheme.titleLarge),
|
Text(displayName, style: theme.textTheme.titleLarge),
|
||||||
Text('anitarose', style: theme.textTheme.bodySmall),
|
Text(username, style: theme.textTheme.bodySmall),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -89,7 +143,8 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
// Tab Bar
|
// Tab Bar
|
||||||
Container(
|
Container(
|
||||||
|
@ -132,6 +187,7 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: theme.scaffoldBackgroundColor,
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: TSizes.defaultSpace / 2),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSettingsItem(
|
_buildSettingsItem(
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/security_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/base_detail_screen.dart';
|
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/base_detail_screen.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/loaders/custom_circular_loader.dart';
|
||||||
|
|
||||||
class SecuritySettingsScreen extends StatefulWidget {
|
class SecuritySettingsScreen extends StatefulWidget {
|
||||||
const SecuritySettingsScreen({super.key});
|
const SecuritySettingsScreen({super.key});
|
||||||
|
@ -9,28 +12,40 @@ class SecuritySettingsScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
bool requireBiometric = true;
|
// Get the controller
|
||||||
bool enable2FA = false;
|
final SecurityController controller = Get.put(SecurityController());
|
||||||
bool sendEmailAlerts = true;
|
|
||||||
bool sendPushNotification = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BaseDetailScreen(
|
return BaseDetailScreen(
|
||||||
title: 'Security Settings',
|
title: 'Security Settings',
|
||||||
content: ListView(
|
content: Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return const Center(child: DCircularLoader());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('App Lock'),
|
_buildSectionHeader('App Lock'),
|
||||||
_buildSwitchItem(
|
Obx(
|
||||||
|
() => _buildSwitchItem(
|
||||||
icon: Icons.fingerprint_outlined,
|
icon: Icons.fingerprint_outlined,
|
||||||
title: 'Require biometric',
|
title: 'Biometric Authentication',
|
||||||
value: requireBiometric,
|
subtitle: 'Use your fingerprint or face to sign in',
|
||||||
onChanged: (value) {
|
value: controller.requireBiometric.value,
|
||||||
setState(() {
|
onChanged: (value) async {
|
||||||
requireBiometric = value;
|
// Show loading
|
||||||
});
|
final wasLoading = controller.isLoading.value;
|
||||||
|
if (!wasLoading) controller.setLoading(true);
|
||||||
|
|
||||||
|
// Toggle biometric
|
||||||
|
await controller.toggleBiometricAuthentication(value);
|
||||||
|
|
||||||
|
// Hide loading if we showed it
|
||||||
|
if (!wasLoading) controller.setLoading(false);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
_buildNavigationItem(
|
_buildNavigationItem(
|
||||||
icon: Icons.lock_outline,
|
icon: Icons.lock_outline,
|
||||||
title: 'Change passcode',
|
title: 'Change passcode',
|
||||||
|
@ -38,16 +53,16 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
),
|
),
|
||||||
|
|
||||||
_buildSectionHeader('Two-Factor Authentication'),
|
_buildSectionHeader('Two-Factor Authentication'),
|
||||||
_buildSwitchItem(
|
Obx(
|
||||||
|
() => _buildSwitchItem(
|
||||||
icon: Icons.security_outlined,
|
icon: Icons.security_outlined,
|
||||||
title: 'Enable 2FA',
|
title: 'Enable 2FA',
|
||||||
value: enable2FA,
|
value: controller.enable2FA.value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
controller.toggleSecuritySetting('enable_2fa', value);
|
||||||
enable2FA = value;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
_buildNavigationItem(
|
_buildNavigationItem(
|
||||||
icon: Icons.backup_outlined,
|
icon: Icons.backup_outlined,
|
||||||
title: 'Manage backup codes',
|
title: 'Manage backup codes',
|
||||||
|
@ -55,28 +70,32 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
),
|
),
|
||||||
|
|
||||||
_buildSectionHeader('Login Alerts'),
|
_buildSectionHeader('Login Alerts'),
|
||||||
_buildSwitchItem(
|
Obx(
|
||||||
|
() => _buildSwitchItem(
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
title: 'Send email alerts',
|
title: 'Send email alerts',
|
||||||
value: sendEmailAlerts,
|
value: controller.sendEmailAlerts.value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
controller.toggleSecuritySetting('send_email_alerts', value);
|
||||||
sendEmailAlerts = value;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildSwitchItem(
|
),
|
||||||
|
Obx(
|
||||||
|
() => _buildSwitchItem(
|
||||||
icon: Icons.notifications_outlined,
|
icon: Icons.notifications_outlined,
|
||||||
title: 'Send push notification',
|
title: 'Send push notification',
|
||||||
value: sendPushNotification,
|
value: controller.sendPushNotification.value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
controller.toggleSecuritySetting(
|
||||||
sendPushNotification = value;
|
'send_push_notification',
|
||||||
});
|
value,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,6 +139,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
Widget _buildSwitchItem({
|
Widget _buildSwitchItem({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
required bool value,
|
required bool value,
|
||||||
required ValueChanged<bool> onChanged,
|
required ValueChanged<bool> onChanged,
|
||||||
}) {
|
}) {
|
||||||
|
@ -131,7 +151,21 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 24, color: theme.iconTheme.color),
|
Icon(icon, size: 24, color: theme.iconTheme.color),
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 15),
|
||||||
Expanded(child: Text(title, style: theme.textTheme.bodyLarge)),
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: theme.textTheme.bodyLarge),
|
||||||
|
if (subtitle != null)
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
||||||
|
|
||||||
|
class DCircularLoader extends StatelessWidget {
|
||||||
|
final double? size;
|
||||||
|
final double width;
|
||||||
|
final Color? color;
|
||||||
|
final String? text;
|
||||||
|
final TextStyle? textStyle;
|
||||||
|
final MainAxisAlignment alignment;
|
||||||
|
|
||||||
|
const DCircularLoader({
|
||||||
|
super.key,
|
||||||
|
this.size,
|
||||||
|
this.width = 2.0,
|
||||||
|
this.color,
|
||||||
|
this.text,
|
||||||
|
this.textStyle,
|
||||||
|
this.alignment = MainAxisAlignment.center,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = THelperFunctions.isDarkMode(context);
|
||||||
|
final loaderColor =
|
||||||
|
color ??
|
||||||
|
(isDarkMode
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).colorScheme.primary);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: alignment,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: width,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(loaderColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (text != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
text!,
|
||||||
|
style:
|
||||||
|
textStyle ??
|
||||||
|
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: isDarkMode ? Colors.white70 : Colors.black54,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A utility class providing different variations of custom loaders
|
||||||
|
class TLoader {
|
||||||
|
/// Returns a full-screen loader with optional text
|
||||||
|
static Widget fullScreenLoader({String? text}) {
|
||||||
|
return Center(child: DCircularLoader(text: text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a small loader for buttons or small components
|
||||||
|
static Widget smallLoader({Color? color, double size = 20}) {
|
||||||
|
return DCircularLoader(size: size, width: 1.5, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a text loader with a message
|
||||||
|
static Widget textLoader({required String text, Color? color}) {
|
||||||
|
return DCircularLoader(text: text, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a centered loader with configurable size
|
||||||
|
static Widget centeredLoader({double? size, Color? color}) {
|
||||||
|
return Center(child: DCircularLoader(size: size, color: color));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue