diff --git a/src/mobile/lib/main.dart b/src/mobile/lib/main.dart index 0d5f9e93d9..25c69b1036 100644 --- a/src/mobile/lib/main.dart +++ b/src/mobile/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:airqo/core/utils/logging_bloc_observer.dart'; import 'package:airqo/src/app/auth/bloc/ForgotPasswordBloc/forgot_password_bloc.dart'; import 'package:airqo/src/app/auth/bloc/auth_bloc.dart'; -import 'package:airqo/src/app/auth/pages/welcome_screen.dart'; import 'package:airqo/src/app/auth/repository/auth_repository_impl.dart'; import 'package:airqo/src/app/auth/repository/auth_repository.dart'; import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; @@ -237,6 +236,8 @@ class Decider extends StatefulWidget { } class _DeciderState extends State with WidgetsBindingObserver { + bool _hasRequestedUserLoad = false; + @override void initState() { super.initState(); @@ -275,14 +276,11 @@ class _DeciderState extends State with WidgetsBindingObserver { Future _checkTokenExpiryOnResume() async { final authBloc = context.read(); - // Only refresh when the user is authenticated; guests have no token, - // and if already expired we don't need to fire again. final currentState = authBloc.state; if (currentState is! AuthLoaded) return; final token = await AuthHelper.refreshTokenIfNeeded(); if (token == null && mounted) { - // Route through GlobalAuthManager so the de-duplication guard applies. GlobalAuthManager.instance.notifySessionExpired(); } } @@ -297,6 +295,7 @@ class _DeciderState extends State with WidgetsBindingObserver { BlocConsumer( listener: (context, authState) { if (authState is SessionExpiredState) { + _hasRequestedUserLoad = false; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( @@ -304,6 +303,15 @@ class _DeciderState extends State with WidgetsBindingObserver { duration: Duration(seconds: 4), ), ); + } else if (authState is AuthLoaded) { + if (!_hasRequestedUserLoad) { + _hasRequestedUserLoad = true; + context.read().add(LoadUser()); + } + } else if (authState is GuestUser || + authState is AuthLoadingError || + authState is OAuthCancelled) { + _hasRequestedUserLoad = false; } }, builder: (context, authState) { @@ -316,7 +324,7 @@ class _DeciderState extends State with WidgetsBindingObserver { } if (authState is SessionExpiredState) { - return const WelcomeScreen(); + return NavPage(); } if (authState is GuestUser) { @@ -324,12 +332,11 @@ class _DeciderState extends State with WidgetsBindingObserver { } if (authState is AuthLoaded) { - context.read().add(LoadUser()); return NavPage(); } if (authState is OAuthCancelled) { - return const WelcomeScreen(); + return NavPage(); } if (authState is AuthLoadingError) { diff --git a/src/mobile/lib/src/app/auth/bloc/ForgotPasswordBloc/forgot_password_bloc.dart b/src/mobile/lib/src/app/auth/bloc/ForgotPasswordBloc/forgot_password_bloc.dart index 9dec23dbc0..4a5ebbb0f1 100644 --- a/src/mobile/lib/src/app/auth/bloc/ForgotPasswordBloc/forgot_password_bloc.dart +++ b/src/mobile/lib/src/app/auth/bloc/ForgotPasswordBloc/forgot_password_bloc.dart @@ -3,16 +3,16 @@ import '../../repository/auth_repository.dart'; import 'forgot_password_event.dart'; import 'forgot_password_state.dart'; - class PasswordResetBloc extends Bloc { final AuthRepository authRepository; - PasswordResetBloc({required this.authRepository}) : super(PasswordResetInitial()) { + PasswordResetBloc({required this.authRepository}) + : super(PasswordResetInitial()) { on((event, emit) async { try { emit(PasswordResetLoading(email: event.email)); await authRepository.requestPasswordReset(event.email); - emit(PasswordResetSuccess(message: '',email: event.email )); + emit(PasswordResetSuccess(message: '', email: event.email)); } catch (e) { String errorMessage = _getErrorMessage(e.toString()); emit(PasswordResetError(message: errorMessage)); @@ -29,7 +29,9 @@ class PasswordResetBloc extends Bloc { ); emit(PasswordResetSuccess(message: message)); } catch (error) { - emit(PasswordResetError(message: 'Failed to update password. \nPlease re-check the code you entered')); + emit(PasswordResetError( + message: + 'Failed to update password. \nPlease re-check the code you entered')); } }); @@ -43,18 +45,18 @@ class PasswordResetBloc extends Bloc { emit(PasswordResetLoading(email: email)); try { - if (email == null || email.isEmpty) { emit(const PasswordResetError( - message: "Session expired. Please start the password reset process again.", + message: + "Session expired. Please start the password reset process again.", )); return; } - final token = await authRepository.validatePinFormat(event.pin, email); - + final token = await authRepository.validateResetCodeFormat(event.pin); + emit(PasswordResetVerified( - message: "PIN successfully verified. Proceed to reset password.", + message: "Code format accepted. Proceed to reset password.", token: token, )); } catch (e) { @@ -66,54 +68,55 @@ class PasswordResetBloc extends Bloc { String _getErrorMessage(String error) { String cleanError = error.replaceAll('Exception: ', ''); - - if (cleanError.toLowerCase().contains('user not found') || + + if (cleanError.toLowerCase().contains('user not found') || cleanError.toLowerCase().contains('email not found') || cleanError.toLowerCase().contains('no user found')) { return 'No account found with this email address.'; } - + if (cleanError.toLowerCase().contains('invalid email') || cleanError.toLowerCase().contains('email format')) { return 'Please enter a valid email address.'; } - + if (cleanError.toLowerCase().contains('network') || cleanError.toLowerCase().contains('connection') || cleanError.toLowerCase().contains('timeout')) { return 'Network error. Please check your connection and try again.'; } - + if (cleanError.toLowerCase().contains('server error') || cleanError.toLowerCase().contains('internal server error') || cleanError.toLowerCase().contains('500')) { return 'Server error. Please try again later.'; } - + if (cleanError.toLowerCase().contains('too many requests') || cleanError.toLowerCase().contains('rate limit')) { return 'Too many requests. Please wait a moment and try again.'; } - + if (cleanError.toLowerCase().contains('unauthorized') || cleanError.toLowerCase().contains('401')) { return 'Authentication error. Please try again.'; } - + if (cleanError.toLowerCase().contains('forbidden') || cleanError.toLowerCase().contains('403')) { return 'Access denied. Please contact support.'; } - + if (cleanError.toLowerCase().contains('bad request') || cleanError.toLowerCase().contains('400')) { return 'Invalid request. Please check your email and try again.'; } - - if (cleanError.isEmpty || cleanError.toLowerCase().contains('something went wrong')) { + + if (cleanError.isEmpty || + cleanError.toLowerCase().contains('something went wrong')) { return 'Unable to send reset code. Please try again.'; } - + return cleanError; } } diff --git a/src/mobile/lib/src/app/auth/bloc/auth_bloc.dart b/src/mobile/lib/src/app/auth/bloc/auth_bloc.dart index bbd9b9eb0c..ee788eaf82 100644 --- a/src/mobile/lib/src/app/auth/bloc/auth_bloc.dart +++ b/src/mobile/lib/src/app/auth/bloc/auth_bloc.dart @@ -23,7 +23,7 @@ class AuthBloc extends Bloc with UiLoggy { AuthBloc({ required this.authRepository, required this.socialAuthRepository, - }) : super(AuthInitial()) { + }) : super(GuestUser()) { on(_onAppStarted); on(_onLoginUser); @@ -48,18 +48,19 @@ class AuthBloc extends Bloc with UiLoggy { } Future _onAppStarted(AppStarted event, Emitter emit) async { - emit(AuthLoading()); try { final token = await SecureStorageRepository.instance .getSecureData(SecureStorageKeys.authToken); if (token != null && token.isNotEmpty) { + emit(AuthLoading()); final validToken = await AuthHelper.refreshTokenIfNeeded(); if (validToken == null) { loggy.warning( 'Token found on app start but silent refresh failed — treating as session expiry'); await _clearAuthData(); emit(SessionExpiredState()); + emit(GuestUser()); } else { final userId = await AuthHelper.getCurrentUserId(suppressGuestWarning: true); @@ -68,12 +69,16 @@ class AuthBloc extends Bloc with UiLoggy { } emit(AuthLoaded(AuthPurpose.login)); } - } else { - emit(GuestUser()); } } catch (e) { debugPrint("Error checking auth state: $e"); - emit(AuthLoadingError("Failed to check authentication state.")); + try { + await _clearAuthData(); + } catch (clearError) { + loggy.error( + "Failed to clear auth data after startup error: $clearError"); + } + emit(GuestUser()); } } @@ -179,10 +184,12 @@ class AuthBloc extends Bloc with UiLoggy { loggy.info('Session expired - clearing auth tokens and cached data'); await _clearAuthData(); emit(SessionExpiredState()); + emit(GuestUser()); } catch (e) { debugPrint("Session expiry cleanup error: $e"); loggy.error("Session expiry cleanup error: $e"); emit(SessionExpiredState()); + emit(GuestUser()); } } diff --git a/src/mobile/lib/src/app/auth/pages/password_reset/password_reset.dart b/src/mobile/lib/src/app/auth/pages/password_reset/password_reset.dart index 9f96dd6f72..53905c001c 100644 --- a/src/mobile/lib/src/app/auth/pages/password_reset/password_reset.dart +++ b/src/mobile/lib/src/app/auth/pages/password_reset/password_reset.dart @@ -22,12 +22,11 @@ class PasswordResetPage extends StatefulWidget { } class _PasswordResetPage extends State { - String? error; late PasswordResetBloc passwordResetBloc; - late TextEditingController passwordConfirmController = TextEditingController(); + late TextEditingController passwordConfirmController = + TextEditingController(); late TextEditingController passwordController = TextEditingController(); - late TextEditingController resetController= TextEditingController(); late GlobalKey formKey = GlobalKey(); bool _isPasswordVisible = false; bool _isConfirmPasswordVisible = false; @@ -37,11 +36,9 @@ class _PasswordResetPage extends State { super.initState(); passwordConfirmController = TextEditingController(); passwordController = TextEditingController(); - resetController= TextEditingController(); try { passwordResetBloc = context.read(); - } catch (e) { logError('Failed to initialize PasswordResetBloc: $e'); } @@ -51,20 +48,22 @@ class _PasswordResetPage extends State { void dispose() { passwordConfirmController.dispose(); passwordController.dispose(); - resetController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final RegExp passwordReg = RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@#?!$%^&*,.]{6,}$'); + final RegExp passwordReg = RegExp( + r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#?!$%^&*,.])[A-Za-z\d@#?!$%^&*,.]{10,}$', + ); return BlocListener( listener: (context, state) { if (state is PasswordResetSuccess) { + passwordConfirmController.clear(); + passwordController.clear(); Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ResetSuccessPage())); + MaterialPageRoute(builder: (context) => ResetSuccessPage())); } else if (state is PasswordResetError) { setState(() { error = state.message.replaceAll("Exception: ", ""); @@ -78,8 +77,7 @@ class _PasswordResetPage extends State { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, - color: Theme.of(context).textTheme.headlineLarge?.color - ), + color: Theme.of(context).textTheme.headlineLarge?.color), ), centerTitle: true, ), @@ -89,100 +87,103 @@ class _PasswordResetPage extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(left: 32, right: 32, top: 8), - child: SizedBox( - child: Column( - children: [ - SizedBox( - height: 20, - ), - TranslatedText("Please enter your new password below. Make sure it's something secure that you can remember.", - style: TextStyle( + padding: const EdgeInsets.only(left: 32, right: 32, top: 8), + child: SizedBox( + child: Column( + children: [ + SizedBox( + height: 20, + ), + TranslatedText( + "Please enter your new password below. Make sure it's something secure that you can remember.", + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: Theme.of(context).textTheme.titleMedium?.color + color: + Theme.of(context).textTheme.titleMedium?.color), + ), + SizedBox(height: 16), + FormFieldWidget( + prefixIcon: Container( + padding: const EdgeInsets.all(13.5), + child: SvgPicture.asset( + "assets/icons/password.svg", + height: 10, ), ), - - SizedBox(height: 16), - FormFieldWidget( - - prefixIcon: Container( - padding: const EdgeInsets.all(13.5), - child: SvgPicture.asset( - "assets/icons/password.svg", - height: 10, - ), - ), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility - : Icons.visibility_off, - color: AppColors.primaryColor, - ), - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, - ), - validator: (value) { - - if (value == null || value.isEmpty) { - return "This field cannot be blank."; - } - - if(!passwordReg.hasMatch(value)){ - return 'Password must be at least 6 characters,\ninclude at least one letter and one number.'; - } - return null; - }, - hintText: "Enter your password", - label: "Password", - isPassword: !_isPasswordVisible, - controller: passwordController, onChanged: (value) { },), - SizedBox(height: 16), - FormFieldWidget( - prefixIcon: Container( - padding: const EdgeInsets.all(13.5), - child: SvgPicture.asset( - "assets/icons/password.svg", - height: 10, - ), - ), - suffixIcon: IconButton( - icon: Icon( - _isConfirmPasswordVisible - ? Icons.visibility - : Icons.visibility_off, - color: AppColors.primaryColor, - ), - onPressed: () { - setState(() { - _isConfirmPasswordVisible = !_isConfirmPasswordVisible; - }); - }, - ), - validator: (value) { - if(value != passwordController.text){ - return "Passwords do not match"; - } - - if (value == null || value.isEmpty) { - return "This field cannot be blank."; - } - return null; - }, - hintText: "Re-enter your new password", - label: "Confirm Password", - isPassword: !_isConfirmPasswordVisible, - controller: passwordConfirmController, onChanged: (value) { },), - SizedBox(height: 16), - ], - ), + suffixIcon: IconButton( + icon: Icon( + _isPasswordVisible + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.primaryColor, + ), + onPressed: () { + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return "This field cannot be blank."; + } + + if (!passwordReg.hasMatch(value)) { + return 'Password must be at least 10 characters,\ninclude uppercase, lowercase, number, and special character.'; + } + return null; + }, + hintText: "Enter your password", + label: "Password", + isPassword: !_isPasswordVisible, + controller: passwordController, + onChanged: (value) {}, + ), + SizedBox(height: 16), + FormFieldWidget( + prefixIcon: Container( + padding: const EdgeInsets.all(13.5), + child: SvgPicture.asset( + "assets/icons/password.svg", + height: 10, + ), + ), + suffixIcon: IconButton( + icon: Icon( + _isConfirmPasswordVisible + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.primaryColor, + ), + onPressed: () { + setState(() { + _isConfirmPasswordVisible = + !_isConfirmPasswordVisible; + }); + }, + ), + validator: (value) { + if (value != passwordController.text) { + return "Passwords do not match"; + } + + if (value == null || value.isEmpty) { + return "This field cannot be blank."; + } + return null; + }, + hintText: "Re-enter your new password", + label: "Confirm Password", + isPassword: !_isConfirmPasswordVisible, + controller: passwordConfirmController, + onChanged: (value) {}, + ), + SizedBox(height: 16), + ], ), ), + ), if (error != null) Padding( padding: const EdgeInsets.only(left: 32.0, top: 8), @@ -199,22 +200,16 @@ class _PasswordResetPage extends State { onTap: loading ? null : () { - - - - final currentForm = formKey.currentState; - if (currentForm != null && - currentForm.validate()) { - passwordResetBloc.add(UpdatePassword( - confirmPassword: passwordConfirmController.text.trim(), - token: widget.token, - password:passwordController.text.trim())); - - } - resetController.clear(); - passwordConfirmController.clear(); - passwordController.clear(); - }, + final currentForm = formKey.currentState; + if (currentForm != null && + currentForm.validate()) { + passwordResetBloc.add(UpdatePassword( + confirmPassword: + passwordConfirmController.text.trim(), + token: widget.token, + password: passwordController.text.trim())); + } + }, child: Container( height: 56, decoration: BoxDecoration( @@ -224,12 +219,12 @@ class _PasswordResetPage extends State { child: loading ? Spinner() : TranslatedText( - "Reset Password", - style: TextStyle( - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), + "Reset Password", + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), ), ), ); @@ -237,7 +232,6 @@ class _PasswordResetPage extends State { ), ), SizedBox(height: 16), - ], ), ), diff --git a/src/mobile/lib/src/app/auth/pages/password_reset/reset_link_sent.dart b/src/mobile/lib/src/app/auth/pages/password_reset/reset_link_sent.dart index 8e51128c44..504f465749 100644 --- a/src/mobile/lib/src/app/auth/pages/password_reset/reset_link_sent.dart +++ b/src/mobile/lib/src/app/auth/pages/password_reset/reset_link_sent.dart @@ -42,6 +42,7 @@ class _ResetLinkSentPageState extends State { final screenWidth = mediaQuery.size.width; final isSmallScreen = screenWidth < 360; final isLargeScreen = screenWidth > 400; + const resetCodeLength = 6; return BlocListener( listener: (context, state) { @@ -132,12 +133,10 @@ class _ResetLinkSentPageState extends State { LayoutBuilder( builder: (context, constraints) { final totalWidth = constraints.maxWidth; - final horizontalPadding = - 32.0; - final numberOfFields = 5; - final totalAvailableWidth = totalWidth - - horizontalPadding - - 4; + final horizontalPadding = 32.0; + const numberOfFields = resetCodeLength; + final totalAvailableWidth = + totalWidth - horizontalPadding - 4; final spacing = 4.0 * (numberOfFields - 1); final fieldWidth = (totalAvailableWidth - spacing) / numberOfFields; @@ -184,7 +183,7 @@ class _ResetLinkSentPageState extends State { ? null : () { final pin = _pinController.text.trim(); - if (pin.length == 5) { + if (pin.length == resetCodeLength) { setState(() => isLoading = true); context .read() @@ -193,7 +192,7 @@ class _ResetLinkSentPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text( - 'Please enter a valid 5-digit code.'), + 'Please enter a valid 6-digit code.'), backgroundColor: AppColors.primaryColor, ), ); diff --git a/src/mobile/lib/src/app/auth/repository/auth_repository.dart b/src/mobile/lib/src/app/auth/repository/auth_repository.dart index 7d8a56893b..a2f14481e8 100644 --- a/src/mobile/lib/src/app/auth/repository/auth_repository.dart +++ b/src/mobile/lib/src/app/auth/repository/auth_repository.dart @@ -11,6 +11,6 @@ abstract class AuthRepository with UiLoggy { required String confirmPassword, }); Future verifyEmailCode(String token, String email); - Future validatePinFormat(String pin, String email); + Future validateResetCodeFormat(String pin); Future deleteUserAccount(); } diff --git a/src/mobile/lib/src/app/auth/repository/auth_repository_impl.dart b/src/mobile/lib/src/app/auth/repository/auth_repository_impl.dart index fc912ced61..8262faf69f 100644 --- a/src/mobile/lib/src/app/auth/repository/auth_repository_impl.dart +++ b/src/mobile/lib/src/app/auth/repository/auth_repository_impl.dart @@ -339,9 +339,9 @@ class AuthImpl extends AuthRepository implements SocialAuthRepository { } @override - Future validatePinFormat(String pin, String email) async { - if (RegExp(r'^\d{5}$').hasMatch(pin)) return pin; - throw Exception('Invalid PIN. Please enter a 5-digit numeric PIN.'); + Future validateResetCodeFormat(String pin) async { + if (RegExp(r'^\d{6}$').hasMatch(pin)) return pin; + throw Exception('Invalid PIN. Please enter a 6-digit numeric PIN.'); } @override diff --git a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart index 5de4cf0f7b..822626efe7 100644 --- a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart +++ b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart @@ -9,7 +9,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'dart:async'; import '../../auth/bloc/auth_bloc.dart'; -import '../../profile/bloc/user_bloc.dart'; import '../bloc/dashboard/dashboard_bloc.dart'; import '../widgets/dashboard_app_bar.dart'; import '../widgets/dashboard_header.dart'; @@ -31,21 +30,16 @@ class _DashboardPageState extends State with UiLoggy { DashboardView currentView = DashboardView.nearYou; String? selectedCountry; String? userCountry; - // Background refresher that triggers silently Timer? _backgroundRefreshTimer; @override void initState() { super.initState(); - // Immediate load data (will use cache if available) context.read().add(LoadDashboard()); - context.read().add(LoadUser()); _getUserCountry(); - // Both logged-in and guest users now default to Near You view - _backgroundRefreshTimer = Timer.periodic(Duration(minutes: 30), (_) { _silentBackgroundRefresh(); }); @@ -68,9 +62,11 @@ class _DashboardPageState extends State with UiLoggy { if (!mounted) return; final country = await LocationServiceManager().getUserCountry(); if (country != null && mounted) { - final match = CountryRepository.countries.where( - (c) => c.countryName.toLowerCase() == country.toLowerCase(), - ).firstOrNull; + final match = CountryRepository.countries + .where( + (c) => c.countryName.toLowerCase() == country.toLowerCase(), + ) + .firstOrNull; final canonicalName = match?.countryName; setState(() { userCountry = canonicalName ?? country; @@ -211,8 +207,9 @@ class _DashboardPageState extends State with UiLoggy { Set? activeCountries; if (state is DashboardLoaded && state.response.measurements != null) { - activeCountries = CountryRepository.extractActiveCountryNames( - state.response.measurements!); + activeCountries = + CountryRepository.extractActiveCountryNames( + state.response.measurements!); } return ViewSelector( diff --git a/src/mobile/test/app/dashboard/widgets/nearby_view_test.dart b/src/mobile/test/app/dashboard/widgets/nearby_view_test.dart index 524a9929f6..32a4efb476 100644 --- a/src/mobile/test/app/dashboard/widgets/nearby_view_test.dart +++ b/src/mobile/test/app/dashboard/widgets/nearby_view_test.dart @@ -10,20 +10,16 @@ import 'package:airqo/src/app/dashboard/widgets/nearby_measurement_card.dart'; import 'package:airqo/src/app/dashboard/widgets/nearby_view_empty_state.dart'; import 'package:airqo/src/app/dashboard/bloc/dashboard/dashboard_bloc.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; -import 'package:airqo/src/app/shared/services/cache_manager.dart'; -// Generate mocks -@GenerateMocks([DashboardBloc, CacheManager]) +@GenerateMocks([DashboardBloc]) import 'nearby_view_test.mocks.dart'; void main() { group('NearbyView Widget Tests', () { late MockDashboardBloc mockDashboardBloc; - late MockCacheManager mockCacheManager; setUp(() { mockDashboardBloc = MockDashboardBloc(); - mockCacheManager = MockCacheManager(); }); Widget createTestWidget() { @@ -44,15 +40,12 @@ void main() { testWidgets('shows loading indicator when getting location', (WidgetTester tester) async { - // Arrange when(mockDashboardBloc.state).thenReturn(DashboardLoading()); when(mockDashboardBloc.stream).thenAnswer((_) => Stream.empty()); - // Act await tester.pumpWidget(createTestWidget()); await tester.pump(); - // Assert expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.text('Getting your location...'), findsOneWidget); }); @@ -60,36 +53,25 @@ void main() { testWidgets( 'shows location services disabled message when services are off', (WidgetTester tester) async { - // Arrange when(mockDashboardBloc.state).thenReturn(DashboardLoaded( AirQualityResponse(success: true, measurements: []), )); when(mockDashboardBloc.stream).thenAnswer((_) => Stream.empty()); - // Mock Geolocator to return false for location services - // Note: This would require additional setup for mocking static methods - - // Act await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(const Duration(seconds: 3)); - - // The widget will try to get location, this test is more complex - // and would need proper mocking of Geolocator static methods }); testWidgets('shows empty state when location permission is denied', (WidgetTester tester) async { - // Arrange when(mockDashboardBloc.state).thenReturn(DashboardLoaded( AirQualityResponse(success: true, measurements: []), )); when(mockDashboardBloc.stream).thenAnswer((_) => Stream.empty()); - // Act await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(const Duration(seconds: 3)); - // Assert - Will likely show empty state due to permission issues in test environment expect(find.byType(NearbyViewEmptyState), findsAtLeastNWidgets(0)); }); diff --git a/src/mobile/test/app/shared/services/cache_manager_test.dart b/src/mobile/test/app/shared/services/cache_manager_test.dart index 078f41043c..ac015f8a22 100644 --- a/src/mobile/test/app/shared/services/cache_manager_test.dart +++ b/src/mobile/test/app/shared/services/cache_manager_test.dart @@ -1,27 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:mockito/annotations.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:battery_plus/battery_plus.dart'; import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'dart:convert'; -// Generate mocks -@GenerateMocks([Box, Connectivity, Battery]) -import 'cache_manager_test.mocks.dart'; - void main() { group('CacheManager', () { late CacheManager cacheManager; - late MockBox mockBox; - late MockConnectivity mockConnectivity; - late MockBattery mockBattery; setUp(() { - mockBox = MockBox(); - mockConnectivity = MockConnectivity(); - mockBattery = MockBattery(); cacheManager = CacheManager(); }); @@ -109,7 +94,7 @@ void main() { // Act - Test JSON serialization final jsonData = cachedData.toJson((data) => data); - + // Assert serialization expect(jsonData['data'], equals(originalData)); expect(jsonData['timestamp'], isA()); @@ -118,7 +103,7 @@ void main() { // Act - Test deserialization final restored = CachedData>.fromJson( jsonData, - (json) => json as Map, + (json) => json, ); // Assert deserialization @@ -251,7 +236,8 @@ void main() { expect(CacheBoxName.airQuality.toString(), contains('airQuality')); expect(CacheBoxName.forecast.toString(), contains('forecast')); expect(CacheBoxName.location.toString(), contains('location')); - expect(CacheBoxName.userPreferences.toString(), contains('userPreferences')); + expect(CacheBoxName.userPreferences.toString(), + contains('userPreferences')); }); }); @@ -282,33 +268,30 @@ void main() { }); group('Connection Type Logic', () { - test('getInterval returns correct durations for different connection types', () { + test( + 'getInterval returns correct durations for different connection types', + () { const policy = RefreshPolicy.airQuality; // WiFi - expect( - policy.getInterval(ConnectionType.wifi, false), - equals(Duration(hours: 1)) - ); + expect(policy.getInterval(ConnectionType.wifi, false), + equals(Duration(hours: 1))); // Mobile - expect( - policy.getInterval(ConnectionType.mobile, false), - equals(Duration(hours: 2)) - ); + expect(policy.getInterval(ConnectionType.mobile, false), + equals(Duration(hours: 2))); // None - expect( - policy.getInterval(ConnectionType.none, false), - equals(Duration(days: 1)) - ); + expect(policy.getInterval(ConnectionType.none, false), + equals(Duration(days: 1))); }); test('low battery factor is applied correctly', () { const policy = RefreshPolicy.airQuality; final normalInterval = policy.getInterval(ConnectionType.wifi, false); - final lowBatteryInterval = policy.getInterval(ConnectionType.wifi, true); + final lowBatteryInterval = + policy.getInterval(ConnectionType.wifi, true); expect(lowBatteryInterval, equals(normalInterval * 2.0)); }); @@ -317,7 +300,8 @@ void main() { const policy = RefreshPolicy.userPreferences; // No lowBatteryFactor final normalInterval = policy.getInterval(ConnectionType.wifi, false); - final lowBatteryInterval = policy.getInterval(ConnectionType.wifi, true); + final lowBatteryInterval = + policy.getInterval(ConnectionType.wifi, true); expect(lowBatteryInterval, equals(normalInterval)); }); @@ -460,7 +444,8 @@ void main() { expect(cachedData.data['metadata']['version'], equals('1.0')); expect(cachedData.data['data']['measurements'], hasLength(1)); - expect(cachedData.data['data']['measurements'][0]['readings']['pm25'], equals(25.5)); + expect(cachedData.data['data']['measurements'][0]['readings']['pm25'], + equals(25.5)); }); test('handles string data', () { @@ -491,13 +476,13 @@ void main() { test('CacheManager maintains singleton behavior', () { final instance1 = CacheManager(); final instance2 = CacheManager(); - + expect(identical(instance1, instance2), isTrue); }); test('multiple calls return same instance', () { final instances = List.generate(5, (_) => CacheManager()); - + for (int i = 1; i < instances.length; i++) { expect(identical(instances[0], instances[i]), isTrue); } @@ -557,8 +542,9 @@ void main() { ); expect(cachedData.isValid, isFalse); - expect(cachedData.isStale(Duration(hours: 1)), isTrue); // Invalid data is always stale + expect(cachedData.isStale(Duration(hours: 1)), + isTrue); // Invalid data is always stale }); }); }); -} \ No newline at end of file +} diff --git a/src/mobile/test/widget_test.dart b/src/mobile/test/widget_test.dart index 455742477d..3da0e64403 100644 --- a/src/mobile/test/widget_test.dart +++ b/src/mobile/test/widget_test.dart @@ -1,5 +1,6 @@ import 'package:airqo/src/app/auth/models/input_model.dart'; import 'package:airqo/src/app/auth/repository/auth_repository.dart'; +import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; import 'package:airqo/src/app/dashboard/repository/dashboard_repository.dart'; import 'package:airqo/src/app/dashboard/repository/forecast_repository.dart'; import 'package:airqo/src/app/learn/repository/kya_repository.dart'; @@ -17,7 +18,6 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:loggy/loggy.dart'; - class FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { @override @@ -36,16 +36,15 @@ class FakePathProviderPlatform extends PathProviderPlatform } } -// Test implementations of repositories class TestAuthRepository extends AuthRepository { @override - Future loginWithEmailAndPassword(String username, String password) async { + Future loginWithEmailAndPassword( + String username, String password) async { return 'test_token'; } @override - Future registerWithEmailAndPassword(RegisterInputModel model) async { - } + Future registerWithEmailAndPassword(RegisterInputModel model) async {} @override Future requestPasswordReset(String email) async { @@ -62,13 +61,23 @@ class TestAuthRepository extends AuthRepository { } @override - Future verifyEmailCode(String token, String email) async { - } + Future verifyEmailCode(String token, String email) async {} @override - Future verifyResetPin(String pin, String email) async { + Future validateResetCodeFormat(String pin) async { + if (!RegExp(r'^\d{6}$').hasMatch(pin)) { + throw Exception('Invalid PIN. Please enter a 6-digit numeric PIN.'); + } return 'Reset pin verified'; } + + @override + Future deleteUserAccount() async {} +} + +class TestSocialAuthRepository extends SocialAuthRepository { + @override + Future loginWithProvider(String provider) async {} } void main() { @@ -76,29 +85,27 @@ void main() { setUpAll(() async { PathProviderPlatform.instance = FakePathProviderPlatform(); - + try { final path = '.'; Hive.init(path); } catch (e) { - print('Hive initialization: $e'); + debugPrint('Hive initialization: $e'); } - + try { await CacheManager().initialize(); } catch (e) { - print('CacheManager initialization: $e'); + debugPrint('CacheManager initialization: $e'); } - - // Load test environment variables + dotenv.testLoad(mergeWith: { 'AIRQO_MOBILE_TOKEN': 'test_token', 'API_KEY': 'test_api_key', 'BASE_URL': 'https://test.example.com', 'GOOGLE_MAPS_API_KEY': 'test_google_maps_key', }); - - // Initialize logging + Loggy.initLoggy( logPrinter: const PrettyPrinter( showColors: false, @@ -107,25 +114,23 @@ void main() { }); tearDown(() async { - // Clean up Hive boxes after each test try { await Hive.deleteFromDisk(); } catch (e) { - // Ignore cleanup errors + // Hive may not initialize in widget-test environments. } }); group('AirqoMobile Widget Tests', () { - testWidgets('App loads without crashing with test repositories', + testWidgets('App loads without crashing with test repositories', (WidgetTester tester) async { - // Use a shorter timeout for async operations await tester.runAsync(() async { - // Build our app with test repositories await tester.pumpWidget( MaterialApp( home: Builder( builder: (context) => AirqoMobile( authRepository: TestAuthRepository(), + socialAuthRepository: TestSocialAuthRepository(), userRepository: UserImpl(), kyaRepository: KyaImpl(), themeRepository: ThemeImpl(), @@ -142,16 +147,16 @@ void main() { await Future.delayed(const Duration(milliseconds: 100)); await tester.pump(); - expect(find.byType(AirqoMobile), findsOneWidget); - expect(find.byType(MaterialApp), findsWidgets); + expect(find.byType(MaterialApp), findsWidgets); }); }); - testWidgets('Simple widget test - App structure exists', + testWidgets('Simple widget test - App structure exists', (WidgetTester tester) async { final widget = AirqoMobile( authRepository: TestAuthRepository(), + socialAuthRepository: TestSocialAuthRepository(), userRepository: UserImpl(), kyaRepository: KyaImpl(), themeRepository: ThemeImpl(), @@ -171,7 +176,7 @@ void main() { expect(widget.dashboardRepository, isA()); }); - testWidgets('Minimal app test - Just verify widget creation', + testWidgets('Minimal app test - Just verify widget creation', (WidgetTester tester) async { await tester.pumpWidget( Directionality( @@ -190,4 +195,4 @@ void main() { expect(find.text('Test'), findsOneWidget); }); }); -} \ No newline at end of file +}