From 0da8d5621c6dc2e30c209c58805ab9934de8e13f Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Tue, 27 May 2025 03:45:01 +0700 Subject: [PATCH] feat: Add Check Location feature with validation and UI enhancements --- .../lib/src/cores/routes/app_pages.dart | 8 +- .../authentication_repository.dart | 15 +- .../check_location_controller.dart | 188 +++++++++ .../pages/check-location/check_location.dart | 396 ++++++++++++++++++ .../presentasion/widgets/info_popup.dart | 70 ++++ .../presentasion/widgets/sliding_card.dart | 104 +++++ .../lib/src/utils/constants/app_routes.dart | 2 +- 7 files changed, 776 insertions(+), 7 deletions(-) create mode 100644 sigap-mobile/lib/src/features/onboarding/presentasion/controllers/check_location_controller.dart create mode 100644 sigap-mobile/lib/src/features/onboarding/presentasion/pages/check-location/check_location.dart create mode 100644 sigap-mobile/lib/src/features/onboarding/presentasion/widgets/info_popup.dart create mode 100644 sigap-mobile/lib/src/features/onboarding/presentasion/widgets/sliding_card.dart diff --git a/sigap-mobile/lib/src/cores/routes/app_pages.dart b/sigap-mobile/lib/src/cores/routes/app_pages.dart index 6f1039c..b9e4042 100644 --- a/sigap-mobile/lib/src/cores/routes/app_pages.dart +++ b/sigap-mobile/lib/src/cores/routes/app_pages.dart @@ -6,10 +6,9 @@ import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen. import 'package:sigap/src/features/auth/presentasion/pages/signup/main/registraion_form_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/main/signup_with_role_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/liveness_detection_screen.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/captured_selfie_view.dart'; +import 'package:sigap/src/features/onboarding/presentasion/pages/check-location/check_location.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart'; -import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_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/utils/constants/app_routes.dart'; @@ -51,6 +50,11 @@ class AppPages { page: () => const ForgotPasswordScreen(), ), + GetPage( + name: AppRoutes.checkLocation, + page: () => const CheckLocationScreen(), + ), + GetPage( name: AppRoutes.locationWarning, page: () => const LocationWarningScreen(), diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index 27bfe1d..3a88d44 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -86,11 +86,18 @@ class AuthenticationRepository extends GetxController { session?.user.userMetadata?['profile_status'] == 'completed'; // Cek lokasi terlebih dahulu - if (await _locationService.isLocationValidForFeature() == false) { - _navigateToRoute(AppRoutes.locationWarning); - return; + if (!isFirstTime) { + bool isLocationValid = + await _locationService.isLocationValidForFeature(); + if (!isLocationValid) { + Logger().w('Location is invalid, redirecting to location warning'); + _navigateToRoute(AppRoutes.locationWarning); + return; + } } + Logger().d('Available session: $session'); + if (session != null) { if (!isEmailVerified) { _navigateToRoute(AppRoutes.emailVerification); @@ -109,7 +116,7 @@ class AuthenticationRepository extends GetxController { } } catch (e) { Logger().e('Error in screenRedirect: $e'); - _navigateToRoute(AppRoutes.signIn); + _navigateToRoute(AppRoutes.checkLocation); } finally { _isRedirecting = false; Logger().d('Screen redirect completed'); diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/check_location_controller.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/check_location_controller.dart new file mode 100644 index 0000000..20fcfdd --- /dev/null +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/check_location_controller.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; +import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart'; +import 'package:sigap/src/utils/constants/image_strings.dart'; + +class CheckLocationController extends GetxController + with GetSingleTickerProviderStateMixin { + final VoidCallback? onSuccess; + final LocationService _locationService = LocationService.instance; + + // Reactive variables + final RxInt currentPage = 0.obs; + final RxBool isLoading = false.obs; + final RxBool isLocationValid = false.obs; + final RxString errorMessage = ''.obs; + final RxBool showUIElements = true.obs; + final RxBool bottomSheetShown = false.obs; + + // Controllers + late PageController pageController; + late AnimationController animController; + late Animation scaleAnimation; + late Timer autoSlideTimer; + + // Slide data + final List> slides = [ + { + 'image': TImages.womanHuggingEarth, + 'title': 'Location Verification', + 'subtitle': + 'We need to verify your location as this app is specifically designed for the Jember region', + }, + { + 'image': TImages.communication, + 'title': 'Region-Specific Services', + 'subtitle': + 'Our emergency services and features are only available within Jember boundaries', + }, + { + 'image': TImages.callingHelp, + 'title': 'Your Safety Matters', + 'subtitle': + 'Location validation ensures you receive appropriate assistance when needed', + }, + ]; + + CheckLocationController({this.onSuccess}); + + @override + void onInit() { + super.onInit(); + + // Initialize controllers + pageController = PageController(viewportFraction: 0.8); + + // Setup animation controllers + animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + scaleAnimation = Tween( + begin: 1.0, + end: 60.0, + ).animate(CurvedAnimation(parent: animController, curve: Curves.easeInOut)); + + // Add listeners + animController.addStatusListener(_handleAnimationStatusChanged); + scaleAnimation.addListener(_handleScaleAnimationChanged); + + // Start auto slide timer + autoSlideTimer = Timer.periodic(Duration(seconds: 3), _handleAutoSlide); + + // Show bottom sheet after a delay + Future.delayed(Duration(seconds: 1), showBottomSheet); + } + + @override + void onClose() { + autoSlideTimer.cancel(); + pageController.dispose(); + animController.dispose(); + super.onClose(); + } + + // Navigate to communication slide (index 1) before zooming + void _navigateToCommunicationSlide() { + if (currentPage.value != 1) { + pageController.animateToPage( + 1, // Index for TImages.communication + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + void _handleAnimationStatusChanged(AnimationStatus status) { + if (status == AnimationStatus.completed) { + // Instead of calling onSuccess callback directly, navigate to RoleSelectionScreen + Get.off( + () => RoleSignupPageView(), + transition: Transition.fadeIn, + duration: const Duration(milliseconds: 300), + ); + + // Call the onSuccess callback after navigation if provided + if (onSuccess != null) { + Timer(Duration(milliseconds: 100), onSuccess!); + } + } + } + + void _handleScaleAnimationChanged() { + if (scaleAnimation.value > 1.2) { + showUIElements.value = false; + } + } + + void _handleAutoSlide(Timer timer) { + if (pageController.hasClients) { + final nextPage = (currentPage.value + 1) % slides.length; + pageController.animateToPage( + nextPage, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + } + + void onPageChanged(int index) { + currentPage.value = index; + } + + Future checkLocation() async { + isLoading.value = true; + try { + final result = await _locationService.validateLocationForPanicButton(); + + if (result['valid']) { + isLoading.value = false; + isLocationValid.value = true; + + // Navigate to communication slide before zooming + _navigateToCommunicationSlide(); + + // Give time to see the success message before animating + await Future.delayed(Duration(milliseconds: 800)); + animController + .forward(); // Start zoom-in animation which will trigger the navigation + } else { + isLoading.value = false; + isLocationValid.value = false; + errorMessage.value = result['message'] ?? 'Location is invalid'; + + // Force page to show error slide + pageController.jumpToPage(slides.length - 1); + } + } catch (e) { + isLoading.value = false; + isLocationValid.value = false; + errorMessage.value = 'Failed to check location. Please try again.'; + } + } + + void handleButtonPress() { + if (isLocationValid.value) { + // Navigate to communication slide before zooming + _navigateToCommunicationSlide(); + + // Give a brief moment for the page transition to complete + Future.delayed(Duration(milliseconds: 300), () { + animController + .forward(); // This will trigger the navigation when animation completes + }); + } else { + isLoading.value = true; + checkLocation(); + } + } + + void showBottomSheet() { + bottomSheetShown.value = true; + // Actual bottom sheet will be shown from the UI + } +} diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/pages/check-location/check_location.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/pages/check-location/check_location.dart new file mode 100644 index 0000000..97641db --- /dev/null +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/pages/check-location/check_location.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/onboarding/presentasion/controllers/check_location_controller.dart'; + +class CheckLocationScreen extends StatefulWidget { + final VoidCallback? onSuccess; + + const CheckLocationScreen({super.key, this.onSuccess}); + + @override + State createState() => _CheckLocationScreenState(); +} + +class _CheckLocationScreenState extends State { + // Controller + late CheckLocationController controller; + + @override + void initState() { + super.initState(); + controller = Get.put(CheckLocationController(onSuccess: widget.onSuccess)); + } + + // Method to show bottom sheet + void _showBottomSheet() { + if (!mounted) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isDismissible: true, + isScrollControlled: true, + builder: (context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 60, + height: 5, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: + theme.brightness == Brightness.light + ? Colors.grey[300] + : Colors.grey[700], + borderRadius: BorderRadius.circular(3), + ), + ), + Text( + 'Location Validation Required', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'This application is designed specifically for users in the Jember region. We need to validate your location to ensure all services function properly.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.touch_app, color: theme.primaryColor), + const SizedBox(width: 8), + Text( + 'Press "Check Location" button to proceed', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 30), + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + minimumSize: Size(200, 50), + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: Text('Got it'), + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + // Show bottom sheet when controller indicates + // Using ever instead of direct call to avoid rebuilding issues + ever(controller.bottomSheetShown, (shown) { + if (shown && mounted) { + _showBottomSheet(); + } + }); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: SafeArea( + child: Column( + children: [ + // Info icon - Fixed Obx usage + Obx( + () => + controller.showUIElements.value + ? Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: IconButton( + onPressed: _showBottomSheet, + icon: Icon( + Icons.info_outline, + color: theme.primaryColor, + size: 24, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Card carousel with zooming effect - Modify to center the current card during zoom + Expanded( + child: AnimatedBuilder( + animation: controller.scaleAnimation, + builder: (context, child) { + // Calculate current page as an integer for clean zooming on the correct card + final currentPageIndex = controller.currentPage.value; + + // Ensure we're centered on current page during animation + if (controller.isLocationValid.value && + controller.scaleAnimation.value > 1.0 && + controller.pageController.hasClients && + controller.pageController.page?.round() != + currentPageIndex) { + controller.pageController.jumpToPage(currentPageIndex); + } + + return Transform.scale( + scale: + controller.isLocationValid.value + ? controller.scaleAnimation.value + : 1.0, + child: child, + ); + }, + child: PageView.builder( + controller: controller.pageController, + onPageChanged: controller.onPageChanged, + itemCount: controller.slides.length, + physics: + controller.isLocationValid.value + ? const NeverScrollableScrollPhysics() // Lock scrolling during animation + : null, + itemBuilder: (context, index) { + return _buildCarouselCard(index, theme); + }, + ), + ), + ), + + // Page indicators - Fixed Obx usage + Obx( + () => + controller.showUIElements.value + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + controller.slides.length, + (index) => Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + controller.currentPage.value == index + ? theme.primaryColor + : theme.disabledColor.withOpacity(0.3), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 24), + + // Title and subtitle - Fixed Obx usage + Obx( + () => + controller.showUIElements.value + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + children: [ + Text( + controller.slides[controller + .currentPage + .value]['title'], + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + controller.slides[controller + .currentPage + .value]['subtitle'], + style: theme.textTheme.bodyMedium?.copyWith( + color: + theme.brightness == Brightness.light + ? Colors.grey[600] + : Colors.grey[300], + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 32), + + // Loading indicator - Fixed Obx usage + // Obx( + // () => + // (controller.showUIElements.value && + // controller.isLoading.value) + // ? Padding( + // padding: const EdgeInsets.symmetric(vertical: 8.0), + // child: CircularProgressIndicator( + // color: theme.primaryColor, + // ), + // ) + // : const SizedBox.shrink(), + // ), + + // Error message - Fixed Obx usage + Obx( + () => + (controller.showUIElements.value && + !controller.isLoading.value && + !controller.isLocationValid.value && + controller.errorMessage.value.isNotEmpty) + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + controller.errorMessage.value, + style: TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ) + : const SizedBox.shrink(), + ), + + // Success message - Fixed Obx usage + Obx( + () => + (controller.showUIElements.value && + !controller.isLoading.value && + controller.isLocationValid.value) + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "Location validated successfully!", + style: TextStyle(color: Colors.green), + textAlign: TextAlign.center, + ), + ) + : const SizedBox.shrink(), + ), + + // Button - Fixed Obx usage + Obx( + () => + controller.showUIElements.value + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32.0, + vertical: 16.0, + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + controller.isLoading.value + ? null + : controller.handleButtonPress, + child: + controller.isLoading.value + ? Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: + AlwaysStoppedAnimation( + Theme.of( + context, + ).colorScheme.onPrimary, + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Checking Location...', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ) + : Text( + controller.isLocationValid.value + ? 'Next' + : 'Check Location', + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _buildCarouselCard(int index, ThemeData theme) { + final slide = controller.slides[index]; + + // Fix: Removed nested Obx and directly use controller values + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: EdgeInsets.symmetric( + horizontal: 10, + vertical: index == controller.currentPage.value ? 10 : 30, + ), + decoration: BoxDecoration( + color: + theme.brightness == Brightness.light + ? Colors.grey[200] + : Colors.grey[800], + borderRadius: BorderRadius.circular(24), + boxShadow: + index == controller.currentPage.value + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ] + : [], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: SvgPicture.asset(slide['image'], fit: BoxFit.contain), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/widgets/info_popup.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/widgets/info_popup.dart new file mode 100644 index 0000000..6cf5c7b --- /dev/null +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/widgets/info_popup.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +class InfoPopup extends StatelessWidget { + final String title; + final String subtitle; + final IconData iconData; + final VoidCallback? onTap; + + const InfoPopup({ + super.key, + required this.title, + required this.subtitle, + this.iconData = Icons.arrow_forward, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Material( + color: Colors.transparent, + child: GestureDetector( + onTap: onTap, + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: theme.primaryColor, + shape: BoxShape.circle, + ), + child: Icon(iconData, color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/widgets/sliding_card.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/widgets/sliding_card.dart new file mode 100644 index 0000000..5a7400a --- /dev/null +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/widgets/sliding_card.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class SlidingCard extends StatelessWidget { + final String imagePath; + final String title; + final String subtitle; + final double scale; + final bool isError; + final String errorMessage; + + const SlidingCard({ + super.key, + required this.imagePath, + required this.title, + required this.subtitle, + this.scale = 1.0, + this.isError = false, + this.errorMessage = '', + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: Transform.scale( + scale: scale, + child: Container( + margin: EdgeInsets.symmetric(vertical: 20), + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(24), + ), + child: Stack( + alignment: Alignment.center, + children: [ + SvgPicture.asset( + imagePath, + width: double.infinity, + height: double.infinity, + fit: BoxFit.contain, + ), + if (isError) + Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(20), + ), + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Colors.white, + size: 40, + ), + SizedBox(height: 16), + Text( + errorMessage, + style: TextStyle( + color: Colors.white, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + isError ? errorMessage : subtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: isError ? Colors.red : Colors.black54, + ), + ), + ), + ], + ); + } +} diff --git a/sigap-mobile/lib/src/utils/constants/app_routes.dart b/sigap-mobile/lib/src/utils/constants/app_routes.dart index 59c1487..8d3dcd5 100644 --- a/sigap-mobile/lib/src/utils/constants/app_routes.dart +++ b/sigap-mobile/lib/src/utils/constants/app_routes.dart @@ -23,5 +23,5 @@ class AppRoutes { static const String selfieVerification = '/selfie-verification'; static const String livenessDetection = '/liveness-detection'; static const String capturedSelfie = '/captured-selfie'; - + static const String checkLocation = '/check-location'; }