Refactor onboarding and authentication UI components

- Updated password field to use a consistent dark color.
- Enhanced onboarding controller with a new text page controller for synchronized page views.
- Improved location warning screen to utilize a centralized error snackbar.
- Refined onboarding screen layout for better responsiveness and added text synchronization.
- Revamped role selection screen with improved UI and dynamic role card rendering.
- Updated welcome screen text for clarity on location services.
- Adjusted custom text field to align with new color scheme.
- Simplified color constants for a more cohesive theme.
- Introduced a new circular full-screen loader for better loading experience.
This commit is contained in:
vergiLgood1 2025-05-26 00:38:56 +07:00
parent 1a6eefe6e3
commit 6a4813c15e
14 changed files with 758 additions and 411 deletions

View File

@ -138,7 +138,7 @@ class AuthenticationRepository extends GetxController {
bool biometricSuccess = await attemptBiometricLogin();
if (!biometricSuccess) {
if (isFirstTime) {
_navigateToRoute(AppRoutes.signIn);
_navigateToRoute(AppRoutes.onboarding);
} else {
_navigateToRoute(AppRoutes.onboarding);
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signin/signin_controller.dart';
@ -9,6 +8,7 @@ import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/helpers/helper_functions.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class SignInScreen extends StatelessWidget {
@ -20,23 +20,13 @@ class SignInScreen extends StatelessWidget {
final formKey = GlobalKey<FormState>();
// Get the controller - use Get.put to ensure it's initialized
final controller = Get.put(SignInController());
// Check if dark mode is enabled
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final controller = Get.find<SignInController>();
// Set system overlay style based on theme
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
isDarkMode ? Brightness.light : Brightness.dark,
),
);
// Check if dark mode is enabled
final isDarkMode = THelperFunctions.isDarkMode(context);
return Scaffold(
// Use dynamic background color from theme
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: SingleChildScrollView(
child: Padding(

View File

@ -33,7 +33,7 @@ class FormRegistrationScreen extends StatelessWidget {
);
return Scaffold(
backgroundColor: dark ? TColors.darkContainer : TColors.lightContainer,
backgroundColor: dark ? TColors.dark : TColors.lightContainer,
appBar: _buildAppBar(context, dark),
body: Obx(() {
// Show loading state while controller initializes

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
@ -25,14 +24,6 @@ class SignupWithRoleScreen extends StatelessWidget {
final theme = Theme.of(context);
final isDark = THelperFunctions.isDarkMode(context);
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
return Scaffold(
body: Obx(
() => NestedScrollView(
@ -223,7 +214,7 @@ class SignupWithRoleScreen extends StatelessWidget {
return Container(
decoration: BoxDecoration(
color: isDark ? TColors.darkContainer : TColors.lightContainer,
color: isDark ? TColors.dark : TColors.lightContainer,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
@ -245,7 +236,7 @@ class SignupWithRoleScreen extends StatelessWidget {
child: Container(
height: 60,
decoration: BoxDecoration(
color: isDark ? TColors.darkContainer : TColors.lightContainer,
color: isDark ? TColors.dark : TColors.lightContainer,
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
),
child: Row(
@ -511,7 +502,7 @@ class SignupWithRoleScreen extends StatelessWidget {
text: 'Google',
iconImage: TImages.googleIcon,
onPressed: () => controller.signInWithGoogle(),
backgroundColor: isDark ? TColors.darkContainer : Colors.white,
backgroundColor: isDark ? TColors.dark : Colors.white,
foregroundColor: isDark ? TColors.white : TColors.dark,
borderColor: isDark ? Colors.transparent : Colors.grey.shade300,
),
@ -523,7 +514,7 @@ class SignupWithRoleScreen extends StatelessWidget {
text: 'Facebook',
iconImage: TImages.facebookIcon,
onPressed: () => controller.signInWithFacebook(),
backgroundColor: isDark ? TColors.darkContainer : Colors.white,
backgroundColor: isDark ? TColors.dark : Colors.white,
foregroundColor: isDark ? TColors.white : TColors.dark,
borderColor: isDark ? Colors.transparent : Colors.grey.shade300,
),

View File

@ -82,7 +82,7 @@ class PasswordField extends StatelessWidget {
),
filled: true,
fillColor:
isDark ? TColors.darkContainer : TColors.lightContainer,
isDark ? TColors.dark : TColors.lightContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),

View File

@ -1,13 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/features/onboarding/data/dummy/onboarding_dummy.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/popups/full_screen_loader.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/popups/circular_full_screen_loader.dart';
class OnboardingController extends GetxController
with GetSingleTickerProviderStateMixin {
@ -23,6 +20,8 @@ class OnboardingController extends GetxController
// Observable variables
final RxInt currentIndex = 0.obs;
final PageController pageController = PageController(initialPage: 0);
// Tambahkan controller untuk PageView teks
final PageController textPageController = PageController(initialPage: 0);
final RxBool isLocationChecking = false.obs;
// Animation controllers
@ -57,6 +56,7 @@ class OnboardingController extends GetxController
@override
void onClose() {
pageController.dispose();
textPageController.dispose(); // dispose controller baru
animationController.dispose();
super.onClose();
}
@ -66,6 +66,7 @@ class OnboardingController extends GetxController
currentIndex.value = index;
animationController.reset();
animationController.forward();
// Sinkronisasi textPageController dilakukan di widget, tidak perlu di sini
}
// Method to go to next page
@ -97,53 +98,40 @@ class OnboardingController extends GetxController
isLocationChecking.value = true;
try {
TFullScreenLoader.openLoadingDialog(
'Checking location...',
TImages.loader,
);
TCircularFullScreenLoader.openLoadingDialog(text: 'Checking location...');
// Verify location is valid (in Jember and not mocked)
final isLocationValid =
await _locationService.isLocationValidForFeature();
TFullScreenLoader.stopLoading();
Logger().i('isFirstTime before: ${storage.read('isFirstTime')}');
TCircularFullScreenLoader.stopLoading();
storage.write('isFirstTime', false);
Logger().i('isFirstTime after: ${storage.read('isFirstTime')}');
if (isLocationValid) {
// If location is valid, proceed to role selection
Get.offAllNamed(AppRoutes.signupWithRole);
// TLoaders.successSnackBar(
// title: 'Location Valid',
// message: 'Checking location was successful',
// );
Get.offAllNamed(AppRoutes.roleSelection);
// Store isfirstTime to false in storage
} else {
// If location is invalid, show warning screen
// TLoaders.errorSnackBar(
// title: 'Location Invalid',
// message:
// 'Please enable location services or ensure you are in Jember area',
// );
Get.offAllNamed(AppRoutes.locationWarning);
TLoaders.errorSnackBar(
title: 'Location Invalid',
message:
'Please enable location services or ensure you are in Jember area',
);
}
} catch (e) {
// If there's an error, show the location warning screen
// TFullScreenLoader.stopLoading();
Get.offAllNamed(AppRoutes.locationWarning);
} finally {
isLocationChecking.value = false;
// TFullScreenLoader.stopLoading();
// TCircularFullScreenLoader.stopLoading();
}
}

View File

@ -5,6 +5,7 @@ import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class LocationWarningScreen extends StatelessWidget {
const LocationWarningScreen({super.key});
@ -76,14 +77,12 @@ class LocationWarningScreen extends StatelessWidget {
if (isValid) {
Get.offAllNamed(AppRoutes.roleSelection);
} else {
Get.snackbar(
'Location Issue',
'Your location is still not valid. Please ensure you are in Jember with location services enabled.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: theme.colorScheme.error.withOpacity(
0.1,
),
colorText: theme.colorScheme.error,
TLoaders.errorSnackBar(
title: 'Location Verification Failed',
message:
isMocked
? 'Please disable mock location apps to continue.'
: 'Ensure you are within Jember region and location services are enabled.',
);
}
},

View File

@ -14,6 +14,9 @@ class OnboardingScreen extends StatelessWidget {
// Get the controller
final controller = Get.find<OnboardingController>();
// Tambahkan controller untuk PageView teks
final textPageController = controller.textPageController;
// Get screen dimensions for responsive design
final size = MediaQuery.of(context).size;
final isSmallScreen = size.height < 700;
@ -22,105 +25,22 @@ class OnboardingScreen extends StatelessWidget {
bool isDark = THelperFunctions.isDarkMode(context);
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Skip button
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(TSizes.md),
child: TextButton(
onPressed: controller.skipOnboarding,
child: Text(
'Skip',
style: Theme.of(context).textTheme.bodyMedium,
),
),
body: Stack(
children: [
// Top bar: indicator & skip
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
),
),
// Page view for slides
Expanded(
flex: 3,
child: PageView.builder(
controller: controller.pageController,
itemCount: contents.length,
onPageChanged: controller.onPageChanged,
itemBuilder: (_, i) {
return Padding(
padding: const EdgeInsets.all(TSizes.lg),
child: Column(
children: [
// Animated image
Expanded(
flex: isSmallScreen ? 3 : 4,
child: FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: SvgPicture.asset(
contents[i].image,
fit: BoxFit.contain,
height: isSmallScreen ? 200 : 300,
// Make SVG adapt to dark Theme.of(context) if needed
colorFilter:
isDark
? const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
)
: null,
),
),
),
),
SizedBox(height: isSmallScreen ? TSizes.md : TSizes.xl),
// Animated title
FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Text(
contents[i].title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
SizedBox(height: isSmallScreen ? TSizes.sm : TSizes.lg),
// Animated description
FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Text(
contents[i].description,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
],
),
);
},
),
),
// Indicator and buttons
Expanded(
flex: 1,
child: Column(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Dot indicators
Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
contents.length,
(index) => buildDot(
@ -132,37 +52,151 @@ class OnboardingScreen extends StatelessWidget {
),
),
),
const Spacer(),
// Next button
Padding(
padding: const EdgeInsets.symmetric(
horizontal: TSizes.lg,
vertical: TSizes.md,
),
child: Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(right: TSizes.sm),
child: FloatingActionButton(
onPressed: controller.nextPage,
backgroundColor: Theme.of(context).primaryColor,
elevation: TSizes.cardElevation,
child: Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onPrimary,
size: TSizes.iconLg - 2,
),
),
TextButton(
onPressed: controller.skipOnboarding,
child: Text(
'Skip',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
),
// Main content
Column(
children: [
// Spacer for top bar
SizedBox(height: size.height * 0.08),
// Image in the center of gray area
Expanded(
flex: 5,
child: PageView.builder(
controller: controller.pageController,
itemCount: contents.length,
onPageChanged: (i) {
controller.onPageChanged(i);
// Sinkronkan page pada textPageController
textPageController.jumpToPage(i);
},
itemBuilder: (_, i) {
return Center(
child: FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: SvgPicture.asset(
contents[i].image,
fit: BoxFit.contain,
height: isSmallScreen ? 200 : 300,
colorFilter:
isDark
? ColorFilter.mode(
Theme.of(context).colorScheme.onSurface,
BlendMode.srcIn,
)
: null,
),
),
),
);
},
),
),
// White rounded bottom sheet tanpa tombol Next
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
),
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
child: SizedBox(
height: isSmallScreen ? 172 : 212, // dikurangi tinggi tombol
child: PageView.builder(
controller: textPageController,
physics: const NeverScrollableScrollPhysics(),
itemCount: contents.length,
itemBuilder: (_, i) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Title
FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Text(
contents[i].title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
),
const SizedBox(height: 12),
// Description
FadeTransition(
opacity: controller.fadeAnimation,
child: SlideTransition(
position: controller.slideAnimation,
child: Text(
contents[i].description,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
// Hapus SizedBox(height: 28) dan tombol Next dari sini
],
);
},
),
),
),
],
),
// Tombol Next di posisi paling bawah
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: const EdgeInsets.fromLTRB(
24,
0,
24,
16,
), // kurangi bottom padding agar tidak terlalu mepet
child: SafeArea(
top: false,
minimum: const EdgeInsets.only(
bottom: 8,
), // beri ruang ekstra agar tidak tertutup gesture bar
child: SizedBox(
width: double.infinity,
height: 56, // tambah tinggi agar text tidak terpotong
child: ElevatedButton(
onPressed: controller.nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: Theme.of(context).textTheme.titleMedium,
),
child: const Text('Next', overflow: TextOverflow.visible),
),
),
),
),
),
],
),
);
}
@ -176,12 +210,12 @@ class OnboardingScreen extends StatelessWidget {
) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(right: TSizes.sm),
height: TSizes.xs * 2,
width: currentIndex == index ? TSizes.md + 8 : TSizes.xs * 2,
margin: const EdgeInsets.only(right: 6),
height: 6,
width: currentIndex == index ? 22 : 10,
decoration: BoxDecoration(
color: currentIndex == index ? activeColor : inactiveColor,
borderRadius: BorderRadius.circular(TSizes.xs),
borderRadius: BorderRadius.circular(8),
),
);
}

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/features/onboarding/presentasion/controllers/role_selection_controller.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/widgets/role_card.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/helpers/helper_functions.dart';
import 'package:sigap/src/utils/loaders/shimmer.dart';
class RoleSelectionScreen extends StatelessWidget {
@ -13,142 +14,475 @@ class RoleSelectionScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<RoleSelectionController>();
final isDark = THelperFunctions.isDarkMode(Get.context!);
// Get theme
final theme = Theme.of(context);
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: theme.appBarTheme.backgroundColor,
elevation: 0,
title: Text(
'Choose Role',
style: Theme.of(context).appBarTheme.titleTextStyle,
),
centerTitle: true,
),
backgroundColor:
isDark ? const Color(0xFF18191A) : const Color(0xFFF7F7F5),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const AuthHeader(
title: 'Select Your Role',
subtitle: 'Choose the role that best describes your position',
),
// Role list
Expanded(
child: Obx(() {
// Check if roles are loading
if (controller.isRolesLoading.value) {
// Show shimmer placeholder cards
return ListView.builder(
itemCount: 2,
itemBuilder:
(_, __) => Padding(
padding: const EdgeInsets.only(
bottom: TSizes.spaceBtwItems,
child: Column(
children: [
Expanded(
child: Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF232425) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
isDark
? const Color(0xFF343536)
: const Color(0xFFE5E5E5),
width: 1,
),
boxShadow:
isDark
? []
: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
border: Border.all(color: theme.dividerColor),
],
),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
// Top illustration that changes based on selection
Expanded(
flex: 3,
child: Obx(
() => _buildTopIllustration(controller, isDark),
),
),
const SizedBox(height: 40),
Text(
'Choose your role',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color:
isDark ? Colors.white : const Color(0xFF2F2F2F),
letterSpacing: -0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Obx(
() => Text(
_getSubtitleText(controller, isDark),
style: TextStyle(
fontSize: 16,
color:
isDark
? Colors.white70
: const Color(0xFF6B6B6B),
height: 1.5,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 48),
Obx(() {
if (controller.isRolesLoading.value) {
return _buildShimmerCards(isDark);
} else {
return _buildRoleCards(controller, isDark);
}
}),
const SizedBox(height: 48),
Obx(
() => SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed:
controller.selectedRole.value != null
? controller.continueWithRole
: null,
style: ElevatedButton.styleFrom(
backgroundColor:
isDark
? Colors.white
: const Color(0xFF2F2F2F),
foregroundColor:
isDark ? Colors.black : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(TSizes.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon shimmer
const TShimmerEffect(
width: 40,
height: 40,
radius: TSizes.borderRadiusSm,
),
const SizedBox(width: TSizes.md),
// Text shimmer
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Role title shimmer
const TShimmerEffect(
width: 150,
height: 24,
radius: TSizes.borderRadiusXs,
),
const SizedBox(height: TSizes.sm),
// Role description shimmer
const TShimmerEffect(
width: double.infinity,
height: 16,
radius: TSizes.borderRadiusXs,
),
const SizedBox(height: TSizes.xs),
const TShimmerEffect(
width: 200,
height: 16,
radius: TSizes.borderRadiusXs,
),
],
elevation: 0,
disabledBackgroundColor:
isDark
? const Color(0xFF343536)
: const Color(0xFFF1F1F1),
disabledForegroundColor: const Color(0xFFB0B0B0),
padding: const EdgeInsets.symmetric(
vertical: 0,
horizontal: 0,
), // reset padding
minimumSize: const Size(0, 48), // ensure height
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child:
controller.isLoading.value
? SizedBox(
child: CircularProgressIndicator(
color:
isDark
? Colors.black
: Colors.white,
strokeWidth: 2,
),
)
: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Get started',
style: TextStyle(
fontSize: TSizes.fontSizeMd,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
),
],
),
),
),
),
);
} else {
// Show dynamic cards from database
return ListView.builder(
itemCount: controller.roles.length,
itemBuilder: (context, index) {
final role = controller.roles[index];
return Obx(
() => RoleCard(
role: role,
isSelected:
controller.selectedRole.value?.id == role.id,
onTap: () => controller.selectRole(role),
),
);
},
);
}
}),
),
// Continue button
Obx(
() => AuthButton(
text: 'Continue',
onPressed: controller.continueWithRole,
isLoading: controller.isLoading.value,
),
),
],
),
),
),
),
],
),
),
);
}
Widget _buildTopIllustration(
RoleSelectionController controller,
bool isDark,
) {
// Default illustration when no role is selected
if (controller.selectedRole.value == null) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF232425) : const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? const Color(0xFF343536) : const Color(0xFFEEEEEE),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SvgPicture.asset(
TImages.homeOffice,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
colorFilter:
isDark
? const ColorFilter.mode(Colors.white70, BlendMode.srcIn)
: null,
),
),
);
}
// Change illustration based on selected role (viewer/officer only)
final name = controller.selectedRole.value?.name.toLowerCase() ?? '';
String asset;
bool isSvg = false;
if (name.contains('officer')) {
asset = isDark ? TImages.customerSupportDark : TImages.customerSupport;
} else {
asset = isDark ? TImages.communication : TImages.communication;
}
if (asset.toLowerCase().endsWith('.svg')) {
isSvg = true;
}
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF232425) : const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? const Color(0xFF343536) : const Color(0xFFEEEEEE),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child:
isSvg
? SvgPicture.asset(
asset,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
colorFilter:
isDark
? const ColorFilter.mode(
Colors.white70,
BlendMode.srcIn,
)
: null,
)
: Image.asset(
asset,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
color: isDark ? Colors.white70 : null,
colorBlendMode: isDark ? BlendMode.srcIn : null,
),
),
);
}
String _getSubtitleText(RoleSelectionController controller, bool isDark) {
if (controller.selectedRole.value == null) {
return isDark
? 'Select your role to start your journey.\nYou can always change it later.'
: 'Select your role to get started with\nyour learning journey.';
}
final selectedRole = controller.selectedRole.value!.name.toLowerCase();
if (selectedRole.contains('officer')) {
return 'You will manage crime related tasks, handle reports, and ensure the safety of the community.';
} else {
return 'You have access to panic button for our early warning system. ';
}
}
Widget _buildRoleCards(RoleSelectionController controller, bool isDark) {
return Row(
children: [
// First role card
Expanded(
child: Obx(
() => _buildRoleCard(
title:
controller.roles.isNotEmpty
? controller.roles[0].name
: 'Officer',
isSelected:
controller.selectedRole.value?.id ==
(controller.roles.isNotEmpty ? controller.roles[0].id : null),
onTap:
() =>
controller.roles.isNotEmpty
? controller.selectRole(controller.roles[0])
: null,
illustration: _buildCardOfficerIllustration(isDark),
isDark: isDark,
),
),
),
const SizedBox(width: 16),
// Second role card
Expanded(
child: Obx(
() => _buildRoleCard(
title:
controller.roles.length > 1
? controller.roles[1].name
: 'Viewer',
isSelected:
controller.selectedRole.value?.id ==
(controller.roles.length > 1 ? controller.roles[1].id : null),
onTap:
() =>
controller.roles.length > 1
? controller.selectRole(controller.roles[1])
: null,
illustration: _buildCardViewerIllustration(isDark),
isDark: isDark,
),
),
),
],
);
}
Widget _buildRoleCard({
required String title,
required bool isSelected,
required VoidCallback? onTap,
required Widget illustration,
required bool isDark,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 140,
decoration: BoxDecoration(
color:
isSelected
? (isDark ? const Color(0xFF232425) : const Color(0xFFF8F9FA))
: (isDark ? const Color(0xFF18191A) : Colors.white),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
isSelected
? (isDark ? Colors.white : const Color(0xFF2F2F2F))
: (isDark
? const Color(0xFF343536)
: const Color(0xFFE5E5E5)),
width: isSelected ? 2 : 1,
),
boxShadow:
isSelected && !isDark
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
illustration,
const SizedBox(height: 16),
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : const Color(0xFF2F2F2F),
letterSpacing: 0.1,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
Widget _buildCardViewerIllustration(bool isDark) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF232425) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: isDark ? const Color(0xFF343536) : const Color(0xFFE0E0E0),
width: 1,
),
),
child: Center(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isDark ? Colors.white : const Color(0xFF2F2F2F),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
TablerIcons.user_star,
color: isDark ? Colors.black : Colors.white,
size: 14,
),
),
),
);
}
Widget _buildCardOfficerIllustration(bool isDark) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF232425) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: isDark ? const Color(0xFF343536) : const Color(0xFFE0E0E0),
width: 1,
),
),
child: Center(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isDark ? Colors.white : const Color(0xFF2F2F2F),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
TablerIcons.user,
color: isDark ? Colors.black : Colors.white,
size: 14,
),
),
),
);
}
Widget _buildShimmerCards(bool isDark) {
return Row(
children: [
Expanded(
child: Container(
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isDark ? const Color(0xFF232425) : const Color(0xFFF5F5F5),
border: Border.all(
color:
isDark ? const Color(0xFF343536) : const Color(0xFFE5E5E5),
width: 1,
),
),
child: const TShimmerEffect(
width: double.infinity,
height: 140,
radius: 8,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isDark ? const Color(0xFF232425) : const Color(0xFFF5F5F5),
border: Border.all(
color:
isDark ? const Color(0xFF343536) : const Color(0xFFE5E5E5),
width: 1,
),
),
child: const TShimmerEffect(
width: double.infinity,
height: 140,
radius: 8,
),
),
),
],
);
}
}

View File

@ -76,7 +76,7 @@ class WelcomeScreen extends StatelessWidget {
frameRate: FrameRate(60),
),
),
// Bottom flexible space
const Spacer(flex: 1),
@ -87,18 +87,12 @@ class WelcomeScreen extends StatelessWidget {
child: ElevatedButton(
onPressed: controller.getStarted,
style: theme.elevatedButtonTheme.style,
child: Text(
'Get Started',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
child: Text('Get Started'),
),
),
SizedBox(height: isSmallScreen ? TSizes.lg : TSizes.xl),
Text(
'By continuing, you agree to our Terms of Service and Privacy Policy',
'By continuing, We will check your location for our security purposes. Please ensure your location services are enabled.',
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium,
),

View File

@ -55,7 +55,7 @@ class CustomTextField extends StatelessWidget {
// Determine the effective fill color
final Color effectiveFillColor =
fillColor ?? (isDark ? TColors.darkContainer : TColors.lightContainer);
fillColor ?? (isDark ? TColors.dark : TColors.lightContainer);
// Get the common input decoration for both cases
final inputDecoration = _getInputDecoration(

View File

@ -1,66 +1,38 @@
import 'package:flutter/material.dart';
class TColors {
// App theme colors
static const Color primary = Color(
0xFF3B82F6,
); // From lightThemeColors primary
static const Color secondary = Color(
0xFFF1F5F9,
); // From lightThemeColors secondary
static const Color accent = Color(0xFFF1F5F9); // From lightThemeColors accent
// Monochrome Notion-like theme colors
static const Color primary = Color(0xFF2F2F2F); // Notion dark text
static const Color secondary = Color(0xFFF5F5F5); // Notion light bg
static const Color accent = Color(0xFFFAFAFA);
// Text colors
static const Color textPrimary = Color(
0xFF0B0C1E,
); // From lightThemeColors foreground
static const Color textSecondary = Color(
0xFF707C91,
); // From lightThemeColors mutedForeground
static const Color textPrimary = Color(0xFF2F2F2F);
static const Color textSecondary = Color(0xFF6B6B6B);
static const Color textWhite = Colors.white;
// Background colors
static const Color light = Color(
0xFFFFFFFF,
); // From lightThemeColors background
static const Color dark = Color(
0xFF0B0C1E,
); // From darkThemeColors background
static const Color primaryBackground = Color(
0xFFFFFFFF,
); // From lightThemeColors background
static const Color light = Color(0xFFF7F7F5); // Notion off-white
static const Color dark = Color(0xFF18191A); // Notion dark mode bg
static const Color primaryBackground = Color(0xFFF7F7F5);
// Background Container colors
static const Color lightContainer = Color(
0xFFFFFFFF,
); // From lightThemeColors card
static Color darkContainer = Color(0xFF0B0C1E); // From darkThemeColors card
static const Color lightContainer = Color(0xFFFFFFFF);
static const Color darkContainer = Color(0xFF232425);
// Button colors
static const Color buttonPrimary = Color(
0xFF3B82F6,
); // From lightThemeColors primary
static const Color buttonSecondary = Color(
0xFFF1F5F9,
); // From lightThemeColors secondary
static const Color buttonDisabled = Color(
0xFF707C91,
); // From lightThemeColors mutedForeground
static const Color buttonPrimary = Color(0xFF2F2F2F);
static const Color buttonSecondary = Color(0xFFF5F5F5);
static const Color buttonDisabled = Color(0xFFB0B0B0);
// Border colors
static const Color borderPrimary = Color(
0xFFE2E8F0,
); // From lightThemeColors border
static const Color borderSecondary = Color(
0xFFE2E8F0,
); // From lightThemeColors input
static const Color borderPrimary = Color(0xFFE5E5E5);
static const Color borderSecondary = Color(0xFFE5E5E5);
// Error and validation colors
static const Color error = Color(
0xFFEF4444,
); // From lightThemeColors destructive
static const Color success = Color(0xFF38B2AC); // From darkThemeColors chart2
static const Color warning = Color(0xFFF59E0B); // From darkThemeColors chart3
static const Color error = Color(0xFFEF4444);
static const Color success = Color(0xFF38B2AC);
static const Color warning = Color(0xFFF59E0B);
// Neutral Shades
static const Color black = Color(0xFF232323);
@ -74,58 +46,34 @@ class TColors {
// Additional colors
static const Color transparent = Colors.transparent;
// Theme color maps for reference
// Theme color maps for reference (monochrome)
static const Map<String, Color> darkThemeColors = {
'background': Color(0xFF0B0C1E),
'foreground': Color(0xFFF8FAFC),
'card': Color(0xFF0B0C1E),
'cardForeground': Color(0xFFF8FAFC),
'popover': Color(0xFF0B0C1E),
'popoverForeground': Color(0xFFF8FAFC),
'primary': Color(0xFF2563EB),
'primaryForeground': Color(0xFF121826),
'secondary': Color(0xFF1E293B),
'secondaryForeground': Color(0xFFF8FAFC),
'muted': Color(0xFF1E293B),
'mutedForeground': Color(0xFFA0AEC0),
'accent': Color(0xFF1E293B),
'accentForeground': Color(0xFFF8FAFC),
'destructive': Color(0xFF752727),
'destructiveForeground': Color(0xFFF8FAFC),
'border': Color(0xFF1E293B),
'input': Color(0xFF1E293B),
'ring': Color(0xFF3B5FE8),
'chart1': Color(0xFF3B82F6),
'chart2': Color(0xFF38B2AC),
'chart3': Color(0xFFF59E0B),
'chart4': Color(0xFFC084FC),
'chart5': Color(0xFFEF476F),
'background': Color(0xFF18191A),
'foreground': Colors.white,
'card': Color(0xFF232425),
'cardForeground': Colors.white,
'primary': Color(0xFF2F2F2F),
'secondary': Color(0xFF232425),
'muted': Color(0xFF343536),
'mutedForeground': Color(0xFFB0B0B0),
'accent': Color(0xFF343536),
'destructive': Color(0xFFEF4444),
'border': Color(0xFF343536),
'input': Color(0xFF343536),
};
static const Map<String, Color> lightThemeColors = {
'background': Color(0xFFFFFFFF),
'foreground': Color(0xFF0B0C1E),
'background': Color(0xFFF7F7F5),
'foreground': Color(0xFF2F2F2F),
'card': Color(0xFFFFFFFF),
'cardForeground': Color(0xFF0B0C1E),
'popover': Color(0xFFFFFFFF),
'popoverForeground': Color(0xFF0B0C1E),
'primary': Color(0xFF3B82F6),
'primaryForeground': Color(0xFFF8FAFC),
'secondary': Color(0xFFF1F5F9),
'secondaryForeground': Color(0xFF121826),
'muted': Color(0xFFF1F5F9),
'mutedForeground': Color(0xFF707C91),
'accent': Color(0xFFF1F5F9),
'accentForeground': Color(0xFF121826),
'cardForeground': Color(0xFF2F2F2F),
'primary': Color(0xFF2F2F2F),
'secondary': Color(0xFFF5F5F5),
'muted': Color(0xFFF1F1F1),
'mutedForeground': Color(0xFFB0B0B0),
'accent': Color(0xFFFAFAFA),
'destructive': Color(0xFFEF4444),
'destructiveForeground': Color(0xFFF8FAFC),
'border': Color(0xFFE2E8F0),
'input': Color(0xFFE2E8F0),
'ring': Color(0xFF3B82F6),
'chart1': Color(0xFFED6A43),
'chart2': Color(0xFF2A9D8F),
'chart3': Color(0xFF2A495B),
'chart4': Color(0xFFF4C15D),
'chart5': Color(0xFFF98F3D),
'border': Color(0xFFE5E5E5),
'input': Color(0xFFE5E5E5),
};
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/loaders/circular_loader.dart';
import '../constants/colors.dart';
import '../helpers/helper_functions.dart';
/// A utility class for managing a full-screen loading dialog.
class TCircularFullScreenLoader {
/// Show a full-screen loader with optional [text] below the spinner.
static void openLoadingDialog({String? text}) {
final isDark = THelperFunctions.isDarkMode(Get.context!);
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: Get.overlayContext!,
barrierDismissible: false,
builder:
(_) => PopScope(
canPop: false,
child: Container(
color: isDark ? TColors.black : TColors.white,
width: double.infinity,
height: double.infinity,
child: Center(
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TCircularLoader(
foregroundColor: TColors.primary,
backgroundColor:
isDark ? TColors.black : TColors.white,
),
if (text != null && text.isNotEmpty) ...[
const SizedBox(height: 24),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
text,
style: TextStyle(
fontSize: 16,
color: isDark ? Colors.white : Colors.black,
fontWeight: FontWeight.w500,
decoration:
TextDecoration.none, // Hapus underline
),
textAlign: TextAlign.center,
softWrap: true,
overflow: TextOverflow.visible,
),
),
],
],
),
),
),
),
),
);
});
}
/// Stop the currently open loading dialog.
static stopLoading() {
Navigator.of(Get.overlayContext!).pop();
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../constants/colors.dart';
import '../helpers/helper_functions.dart';
import '../loaders/animation_loader.dart';