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:
vergiLgood1 2025-05-27 08:39:46 +07:00
parent c6931619de
commit 0f3cb701c4
18 changed files with 1399 additions and 349 deletions

View File

@ -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(),
);
}
}

View File

@ -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();
}
}

View File

@ -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),
),
];
}

View File

@ -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';
}
}
}

View File

@ -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;

View File

@ -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

View File

@ -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,
);
}

View File

@ -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>();

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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
}
}

View File

@ -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(' ');
}
}

View File

@ -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),
],
),
),
);
}
}

View File

@ -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(' ');
}
}

View File

@ -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(

View File

@ -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,

View File

@ -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));
}
}