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_storage/get_storage.dart';
|
||||
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||
import 'package:sigap/app.dart';
|
||||
import 'package:sigap/navigation_menu.dart';
|
||||
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
|
||||
import 'package:sigap/src/utils/theme/theme.dart';
|
||||
|
@ -48,22 +49,6 @@ Future<void> main() async {
|
|||
|
||||
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: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/personalization/presentasion/bindings/personalization_bindings.dart';
|
||||
|
||||
class ControllerBindings extends Bindings {
|
||||
@override
|
||||
|
@ -13,5 +14,8 @@ class ControllerBindings extends Bindings {
|
|||
// Auth Bindings
|
||||
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/role-selection/role_signup_pageview.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';
|
||||
|
||||
class AppPages {
|
||||
|
@ -67,6 +69,20 @@ class AppPages {
|
|||
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 _locationService = LocationService.instance;
|
||||
final _biometricService = Get.find<BiometricService>();
|
||||
final _logger = Logger();
|
||||
|
||||
// Getters that use the Supabase service
|
||||
User? get authUser => SupabaseService.instance.currentUser;
|
||||
|
@ -41,26 +42,23 @@ class AuthenticationRepository extends GetxController {
|
|||
|
||||
// Check for biometric login on app start
|
||||
Future<bool> attemptBiometricLogin() async {
|
||||
if (!await _biometricService.isBiometricLoginEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String? refreshToken = await _biometricService.attemptBiometricLogin();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the refresh token to recover the session
|
||||
final response = await _supabase.auth.refreshSession(refreshToken);
|
||||
if (response.session != null) {
|
||||
Get.offAllNamed(AppRoutes.explore);
|
||||
return true;
|
||||
if (!await _biometricService.isBiometricLoginEnabled()) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
String? sessionToken = await _biometricService.attemptBiometricLogin();
|
||||
if (sessionToken == null || sessionToken.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the session token to restore the session
|
||||
final response = await restoreSession(sessionToken);
|
||||
return response;
|
||||
} catch (e) {
|
||||
// If refresh token is invalid or expired, disable biometric login
|
||||
// If token is invalid or expired, disable biometric login
|
||||
await _biometricService.disableBiometricLogin();
|
||||
_logger.e('Error during biometric login: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -827,4 +825,23 @@ class AuthenticationRepository extends GetxController {
|
|||
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: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/utils/constants/app_routes.dart';
|
||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||
|
@ -10,6 +11,7 @@ class SignInController extends GetxController {
|
|||
|
||||
final _logger = Logger();
|
||||
final _authRepo = Get.find<AuthenticationRepository>();
|
||||
final _biometricService = Get.find<BiometricService>();
|
||||
|
||||
// Form controllers
|
||||
final email = TextEditingController();
|
||||
|
@ -22,6 +24,13 @@ class SignInController extends GetxController {
|
|||
// States
|
||||
final RxBool isLoading = RxBool(false);
|
||||
final RxBool isPasswordVisible = RxBool(false);
|
||||
final RxBool isBiometricAvailable = RxBool(false);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_checkBiometricAvailability();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
|
@ -30,6 +39,48 @@ class SignInController extends GetxController {
|
|||
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
|
||||
void togglePasswordVisibility() {
|
||||
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),
|
||||
|
||||
// Or divider
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import 'package:get/get.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/profile_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);
|
||||
}
|
||||
|
||||
// Register repositories
|
||||
_registerRepositories();
|
||||
|
||||
// Register profile controllers
|
||||
_registerProfileControllers();
|
||||
|
||||
|
@ -32,22 +26,11 @@ class PersonalizationBindings extends Bindings {
|
|||
_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() {
|
||||
// Register profile-related controllers
|
||||
Get.lazyPut<ProfileController>(() => ProfileController(), fenix: true);
|
||||
|
||||
Get.lazyPut<OfficerProfileController>(
|
||||
() => OfficerProfileController(),
|
||||
Get.lazyPut<OfficerController>(() => OfficerController(),
|
||||
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/profile_controller.dart';
|
||||
|
||||
class OfficerProfileController extends BaseProfileController {
|
||||
class OfficerController extends BaseProfileController {
|
||||
// Use find to get previously registered repository
|
||||
final _officerRepository = Get.find<OfficerRepository>();
|
||||
|
||||
|
|
|
@ -23,6 +23,6 @@ class ProfileBinding extends Bindings {
|
|||
|
||||
// Register controllers
|
||||
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';
|
||||
|
||||
class ProfileController extends BaseProfileController {
|
||||
// Repositories
|
||||
final _userRepository = Get.find<UserRepository>();
|
||||
final _profileRepository = Get.find<ProfileRepository>();
|
||||
final _officerRepository = Get.find<OfficerRepository>();
|
||||
// Repositories using Get.find() instead of direct initialization
|
||||
final UserRepository _userRepository = Get.find<UserRepository>();
|
||||
final ProfileRepository _profileRepository = Get.find<ProfileRepository>();
|
||||
final OfficerRepository _officerRepository = Get.find<OfficerRepository>();
|
||||
|
||||
// Observable state variables
|
||||
final Rx<UserModel?> user = Rx<UserModel?>(null);
|
||||
|
@ -21,6 +21,7 @@ class ProfileController extends BaseProfileController {
|
|||
final RxBool isOfficer = false.obs;
|
||||
final RxBool isInitialized = false.obs;
|
||||
final RxBool isEditMode = false.obs;
|
||||
final RxBool isFetching = false.obs; // Track initial data loading
|
||||
|
||||
// Form controllers for edit mode
|
||||
late TextEditingController firstNameController;
|
||||
|
@ -76,6 +77,7 @@ class ProfileController extends BaseProfileController {
|
|||
// Fetch the user profile data
|
||||
Future<void> fetchUserProfile() async {
|
||||
try {
|
||||
isFetching.value = true; // Start fetching
|
||||
setLoading(true);
|
||||
clearError();
|
||||
|
||||
|
@ -98,6 +100,7 @@ class ProfileController extends BaseProfileController {
|
|||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetching.value = false; // Done fetching
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -214,6 +217,7 @@ class ProfileController extends BaseProfileController {
|
|||
// Fetch profile by user ID
|
||||
Future<void> fetchProfileByUserId(String userId) async {
|
||||
try {
|
||||
isFetching.value = true;
|
||||
setLoading(true);
|
||||
clearError();
|
||||
|
||||
|
@ -237,6 +241,8 @@ class ProfileController extends BaseProfileController {
|
|||
officer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized.value = true;
|
||||
} catch (e) {
|
||||
setError('Failed to load profile: ${e.toString()}');
|
||||
showError(
|
||||
|
@ -245,6 +251,7 @@ class ProfileController extends BaseProfileController {
|
|||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
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/utils/popups/loaders.dart';
|
||||
|
||||
class SecurityController extends BaseSettingsController {
|
||||
final _logger = Logger();
|
||||
final _biometricService = Get.find<BiometricService>();
|
||||
final _authRepo = Get.find<AuthenticationRepository>();
|
||||
|
||||
// Security settings
|
||||
final RxBool requireBiometric = true.obs;
|
||||
final RxBool requireBiometric = false.obs;
|
||||
final RxBool enable2FA = false.obs;
|
||||
final RxBool sendEmailAlerts = true.obs;
|
||||
final RxBool sendPushNotification = false.obs;
|
||||
|
@ -21,7 +29,64 @@ class SecurityController extends BaseSettingsController {
|
|||
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) {
|
||||
switch (setting) {
|
||||
case 'require_biometric':
|
||||
|
@ -123,6 +188,10 @@ class SecurityController extends BaseSettingsController {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load biometric setting from actual service
|
||||
requireBiometric.value =
|
||||
await _biometricService.isBiometricLoginEnabled();
|
||||
|
||||
// TODO: Load security settings from persistent storage
|
||||
final prefs = Get.find<dynamic>(); // Replace with your storage solution
|
||||
// requireBiometric.value = prefs.getBool('require_biometric') ?? true;
|
||||
|
@ -133,6 +202,7 @@ class SecurityController extends BaseSettingsController {
|
|||
// passcodeLastChanged.value = prefs.getString('passcode_last_changed') ?? '';
|
||||
// backupCodes.value = prefs.getStringList('backup_codes') ?? [];
|
||||
} catch (e) {
|
||||
_logger.e('Failed to load security settings: $e');
|
||||
setError('Failed to load security settings: ${e.toString()}');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
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/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/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';
|
||||
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
|
@ -10,6 +13,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
final ProfileModel? profile;
|
||||
final OfficerModel? officer;
|
||||
final bool isCurrentUser;
|
||||
final String? userId;
|
||||
|
||||
const ProfileScreen({
|
||||
super.key,
|
||||
|
@ -17,6 +21,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
this.profile,
|
||||
this.officer,
|
||||
this.isCurrentUser = false,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -24,14 +29,21 @@ class ProfileScreen extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
|
||||
// For demo purposes, using dummy data if no data is provided
|
||||
final displayName =
|
||||
profile?.fullName ?? user?.profile?.fullName ?? 'Anita Rose';
|
||||
final location = _getLocationString();
|
||||
final avatarUrl = profile?.avatar ?? user?.profile?.avatar;
|
||||
final bio =
|
||||
profile?.bio ??
|
||||
'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';
|
||||
// Get the profile controller
|
||||
final controller = Get.find<ProfileController>();
|
||||
|
||||
// If userId is provided, fetch that specific profile
|
||||
if (userId != null && controller.user.value?.id != userId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
controller.fetchProfileByUserId(userId!);
|
||||
});
|
||||
}
|
||||
// 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(
|
||||
backgroundColor: const Color(
|
||||
|
@ -45,177 +57,266 @@ class ProfileScreen extends StatelessWidget {
|
|||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
CircleAvatar(
|
||||
backgroundColor: theme.primaryColor.withOpacity(0.1),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.person_outline, color: theme.primaryColor),
|
||||
onPressed: () {
|
||||
// Show profile options
|
||||
},
|
||||
if (isCurrentUser)
|
||||
Obx(
|
||||
() => CircleAvatar(
|
||||
backgroundColor: theme.primaryColor.withOpacity(0.1),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
controller.isEditMode.value
|
||||
? Icons.close
|
||||
: Icons.edit_outlined,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
onPressed: () {
|
||||
controller.toggleEditMode();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Top section with avatar, name, and location
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: screenHeight * 0.05),
|
||||
// Profile Avatar
|
||||
Center(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
backgroundImage:
|
||||
avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child:
|
||||
avatarUrl == null
|
||||
? const Icon(
|
||||
Icons.person,
|
||||
size: 50,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
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: 16),
|
||||
// Name
|
||||
Text(
|
||||
displayName,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
controller.errorMessage.value,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Location with icon
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 16,
|
||||
color: theme.hintColor,
|
||||
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: [
|
||||
// Top section with avatar, name, and location
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: screenHeight * 0.05),
|
||||
// Profile Avatar
|
||||
Center(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
backgroundImage:
|
||||
avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child:
|
||||
avatarUrl == null
|
||||
? const Icon(
|
||||
Icons.person,
|
||||
size: 50,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
location,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Name
|
||||
Text(
|
||||
displayName,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Location with icon
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 16,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Bottom sheet with profile details
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: screenHeight * 0.32, // Adjust position to show top content
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(25),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
locationString,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
],
|
||||
),
|
||||
|
||||
// Bottom sheet with profile details
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: screenHeight * 0.32, // Adjust position to show top content
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(25),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
// Center gray line indicator for draggable bottom sheet look
|
||||
Center(
|
||||
child: Container(
|
||||
height: 4,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
// Center gray line indicator for draggable bottom sheet look
|
||||
Center(
|
||||
child: Container(
|
||||
height: 4,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// About Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('About', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
bio,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.5,
|
||||
color: theme.textTheme.bodyMedium?.color
|
||||
?.withOpacity(0.8),
|
||||
// About Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('About', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
bioText,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.5,
|
||||
color: theme.textTheme.bodyMedium?.color
|
||||
?.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// User-specific information
|
||||
officer != null
|
||||
? OfficerProfileDetail(officer: officer!)
|
||||
: UserProfileDetail(user: user, profile: profile),
|
||||
],
|
||||
// User/Officer-specific information
|
||||
isOfficerUser
|
||||
? OfficerProfileDetail(officer: officerData!)
|
||||
: 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) {
|
||||
return officer!.placeOfBirth!;
|
||||
} else if (profile?.placeOfBirth != null) {
|
||||
return profile!.placeOfBirth!;
|
||||
} else if (profile?.placeOfBirth != null &&
|
||||
profile!.placeOfBirth!.isNotEmpty) {
|
||||
return profile.placeOfBirth!;
|
||||
}
|
||||
|
||||
// Fallback to address if available
|
||||
if (profile?.address != null) {
|
||||
final address = profile!.address!;
|
||||
final fullAddress = address['full_address'];
|
||||
final city = address['city'];
|
||||
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';
|
||||
} else if (city != null) {
|
||||
return city;
|
||||
return city.toString();
|
||||
} 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),
|
||||
|
||||
// Officer information
|
||||
_buildInfoRow(
|
||||
context,
|
||||
'NRP',
|
||||
officer.nrp?.toString() ?? 'Not Available',
|
||||
),
|
||||
// Officer information rows
|
||||
if (officer.nrp != null)
|
||||
_buildInfoRow(context, 'NRP',
|
||||
officer.nrp.toString()),
|
||||
|
||||
if (officer.rank != null)
|
||||
if (officer.rank != null && officer.rank!.isNotEmpty)
|
||||
_buildInfoRow(context, 'Rank', officer.rank!),
|
||||
|
||||
if (officer.position != null)
|
||||
if (officer.position != null && officer.position!.isNotEmpty)
|
||||
_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!),
|
||||
|
||||
if (officer.dateOfBirth != null)
|
||||
|
@ -47,12 +46,15 @@ class OfficerProfileDetail extends StatelessWidget {
|
|||
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!),
|
||||
|
||||
if (officer.unitId != null)
|
||||
if (officer.unitId != null && officer.unitId!.isNotEmpty)
|
||||
_buildInfoRow(context, 'Unit ID', officer.unitId!),
|
||||
|
||||
if (officer.patrolUnitId != null && officer.patrolUnitId!.isNotEmpty)
|
||||
_buildInfoRow(context, 'Patrol Unit', officer.patrolUnitId!),
|
||||
|
||||
if (officer.validUntil != null)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
|
@ -61,7 +63,14 @@ class OfficerProfileDetail extends StatelessWidget {
|
|||
),
|
||||
|
||||
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
|
||||
if (officer.isBanned) ...[
|
||||
|
@ -77,7 +86,12 @@ class OfficerProfileDetail extends StatelessWidget {
|
|||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildInfoRow(context, 'Status', 'Banned', valueColor: Colors.red),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
'Status',
|
||||
'Account Suspended',
|
||||
valueColor: Colors.red,
|
||||
),
|
||||
|
||||
_buildInfoRow(
|
||||
context,
|
||||
|
@ -88,10 +102,34 @@ class OfficerProfileDetail extends StatelessWidget {
|
|||
if (officer.bannedUntil != null)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
'Banned Until',
|
||||
'Suspended Until',
|
||||
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) {
|
||||
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(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||
child: Column(
|
||||
|
@ -22,15 +42,22 @@ class UserProfileDetail extends StatelessWidget {
|
|||
Text('Personal Information', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// User information
|
||||
if (profile?.nik != null)
|
||||
// NIK (National ID)
|
||||
if (profile?.nik != null && profile!.nik!.isNotEmpty)
|
||||
_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!),
|
||||
|
||||
// Birth Date
|
||||
if (profile?.birthDate != null)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
|
@ -38,9 +65,21 @@ class UserProfileDetail extends StatelessWidget {
|
|||
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!),
|
||||
|
||||
// Address
|
||||
if (profile?.address != null &&
|
||||
_getDisplayAddress(profile!.address!).isNotEmpty)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
'Address',
|
||||
_getDisplayAddress(profile!.address!),
|
||||
),
|
||||
|
||||
// Last Sign In
|
||||
if (user?.lastSignInAt != null)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
|
@ -48,8 +87,21 @@ class UserProfileDetail extends StatelessWidget {
|
|||
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)
|
||||
_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: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/pages/profile/profile_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/terms_services_screen.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 {
|
||||
const SettingsScreen({super.key});
|
||||
|
@ -24,11 +26,18 @@ class _SettingsScreenState extends State<SettingsScreen>
|
|||
late TabController _tabController;
|
||||
// Use find instead of implicit creation with Get.put
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
// Get the profile controller to access user data
|
||||
final ProfileController _profileController = Get.find<ProfileController>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
|
||||
// Fetch user profile data if it's not already loaded
|
||||
if (!_profileController.isInitialized.value) {
|
||||
_profileController.fetchUserProfile();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -49,47 +58,93 @@ class _SettingsScreenState extends State<SettingsScreen>
|
|||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Profile Section - Now clickable
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Navigate to the ProfileScreen when tapped
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(context) => const ProfileScreen(isCurrentUser: true),
|
||||
// Profile Section with real data
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: theme.hintColor,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 35,
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
}
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Anita Rose', style: theme.textTheme.titleLarge),
|
||||
Text('anitarose', style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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: () {
|
||||
// Navigate to the ProfileScreen when tapped
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(context) => const ProfileScreen(isCurrentUser: true),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: theme.hintColor),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: theme.hintColor,
|
||||
backgroundImage:
|
||||
avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child:
|
||||
avatarUrl == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 35,
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName, style: theme.textTheme.titleLarge),
|
||||
Text(username, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: theme.hintColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
// Tab Bar
|
||||
Container(
|
||||
|
@ -132,6 +187,7 @@ class _SettingsScreenState extends State<SettingsScreen>
|
|||
|
||||
return Container(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: TSizes.defaultSpace / 2),
|
||||
child: ListView(
|
||||
children: [
|
||||
_buildSettingsItem(
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
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/shared/widgets/loaders/custom_circular_loader.dart';
|
||||
|
||||
class SecuritySettingsScreen extends StatefulWidget {
|
||||
const SecuritySettingsScreen({super.key});
|
||||
|
@ -9,74 +12,90 @@ class SecuritySettingsScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||
bool requireBiometric = true;
|
||||
bool enable2FA = false;
|
||||
bool sendEmailAlerts = true;
|
||||
bool sendPushNotification = false;
|
||||
// Get the controller
|
||||
final SecurityController controller = Get.put(SecurityController());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseDetailScreen(
|
||||
title: 'Security Settings',
|
||||
content: ListView(
|
||||
children: [
|
||||
_buildSectionHeader('App Lock'),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.fingerprint_outlined,
|
||||
title: 'Require biometric',
|
||||
value: requireBiometric,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
requireBiometric = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.lock_outline,
|
||||
title: 'Change passcode',
|
||||
onTap: () {},
|
||||
),
|
||||
content: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: DCircularLoader());
|
||||
}
|
||||
|
||||
_buildSectionHeader('Two-Factor Authentication'),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.security_outlined,
|
||||
title: 'Enable 2FA',
|
||||
value: enable2FA,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
enable2FA = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.backup_outlined,
|
||||
title: 'Manage backup codes',
|
||||
onTap: () {},
|
||||
),
|
||||
return ListView(
|
||||
children: [
|
||||
_buildSectionHeader('App Lock'),
|
||||
Obx(
|
||||
() => _buildSwitchItem(
|
||||
icon: Icons.fingerprint_outlined,
|
||||
title: 'Biometric Authentication',
|
||||
subtitle: 'Use your fingerprint or face to sign in',
|
||||
value: controller.requireBiometric.value,
|
||||
onChanged: (value) async {
|
||||
// Show loading
|
||||
final wasLoading = controller.isLoading.value;
|
||||
if (!wasLoading) controller.setLoading(true);
|
||||
|
||||
_buildSectionHeader('Login Alerts'),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.email_outlined,
|
||||
title: 'Send email alerts',
|
||||
value: sendEmailAlerts,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
sendEmailAlerts = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.notifications_outlined,
|
||||
title: 'Send push notification',
|
||||
value: sendPushNotification,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
sendPushNotification = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Toggle biometric
|
||||
await controller.toggleBiometricAuthentication(value);
|
||||
|
||||
// Hide loading if we showed it
|
||||
if (!wasLoading) controller.setLoading(false);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.lock_outline,
|
||||
title: 'Change passcode',
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
_buildSectionHeader('Two-Factor Authentication'),
|
||||
Obx(
|
||||
() => _buildSwitchItem(
|
||||
icon: Icons.security_outlined,
|
||||
title: 'Enable 2FA',
|
||||
value: controller.enable2FA.value,
|
||||
onChanged: (value) {
|
||||
controller.toggleSecuritySetting('enable_2fa', value);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.backup_outlined,
|
||||
title: 'Manage backup codes',
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
_buildSectionHeader('Login Alerts'),
|
||||
Obx(
|
||||
() => _buildSwitchItem(
|
||||
icon: Icons.email_outlined,
|
||||
title: 'Send email alerts',
|
||||
value: controller.sendEmailAlerts.value,
|
||||
onChanged: (value) {
|
||||
controller.toggleSecuritySetting('send_email_alerts', value);
|
||||
},
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => _buildSwitchItem(
|
||||
icon: Icons.notifications_outlined,
|
||||
title: 'Send push notification',
|
||||
value: controller.sendPushNotification.value,
|
||||
onChanged: (value) {
|
||||
controller.toggleSecuritySetting(
|
||||
'send_push_notification',
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -120,6 +139,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
Widget _buildSwitchItem({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
|
@ -131,7 +151,21 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
children: [
|
||||
Icon(icon, size: 24, color: theme.iconTheme.color),
|
||||
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(
|
||||
value: value,
|
||||
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