feat: Add Check Location feature with validation and UI enhancements

This commit is contained in:
vergiLgood1 2025-05-27 03:45:01 +07:00
parent b6a82438dd
commit 0da8d5621c
7 changed files with 776 additions and 7 deletions

View File

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

View File

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

View File

@ -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<double> scaleAnimation;
late Timer autoSlideTimer;
// Slide data
final List<Map<String, dynamic>> 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<double>(
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<void> 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
}
}

View File

@ -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<CheckLocationScreen> createState() => _CheckLocationScreenState();
}
class _CheckLocationScreenState extends State<CheckLocationScreen> {
// 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<Color>(
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),
),
);
}
}

View File

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

View File

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

View File

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