diff --git a/lib/features/app_layout/bloc/home_cubit.dart b/lib/features/app_layout/bloc/home_cubit.dart index 8fee3a4..73bbe88 100644 --- a/lib/features/app_layout/bloc/home_cubit.dart +++ b/lib/features/app_layout/bloc/home_cubit.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -42,6 +41,14 @@ class HomeCubit extends Cubit { } } + @override + Future close() { + _instance = null; + selectedSpace = null; + selectedRoom = null; + return super.close(); + } + static HomeCubit get(context) => BlocProvider.of(context); List? spaces; @@ -124,8 +131,8 @@ class HomeCubit extends Cubit { selectedSpace = spaces!.first : null; emitSafe(GetSpacesLoaded(spaces!)); - } on DioException catch (e) { - emitSafe(GetSpacesError(ServerFailure.fromDioError(e).errMessage)); + } on ServerFailure catch (failure) { + emitSafe(GetSpacesError(failure.errMessage)); } } @@ -138,17 +145,13 @@ class HomeCubit extends Cubit { } else { emitSafe(GetSpaceRoomsError("No rooms found")); } - } on DioException catch (e) { - emitSafe(GetSpacesError(ServerFailure.fromDioError(e).errMessage)); + } on ServerFailure catch (failure) { + emitSafe(GetSpacesError(failure.errMessage)); } } /////////////////////////////////////// Nav /////////////////////////////////////// - static clear() { - pageIndex = 0; - } - static int pageIndex = 0; static Map> appBarActions = { diff --git a/lib/features/app_layout/view/app_layout.dart b/lib/features/app_layout/view/app_layout.dart index 6fbdb42..81296cc 100644 --- a/lib/features/app_layout/view/app_layout.dart +++ b/lib/features/app_layout/view/app_layout.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:syncrow_app/features/app_layout/bloc/home_cubit.dart'; import 'package:syncrow_app/features/app_layout/view/widgets/app_body.dart'; import 'package:syncrow_app/features/app_layout/view/widgets/default_app_bar.dart'; import 'package:syncrow_app/features/app_layout/view/widgets/default_nav_bar.dart'; +import 'package:syncrow_app/features/auth/bloc/auth_cubit.dart'; +import 'package:syncrow_app/features/auth/model/token.dart'; import 'package:syncrow_app/navigation/routing_constants.dart'; import 'package:syncrow_app/utils/resource_manager/color_manager.dart'; @@ -14,53 +17,74 @@ class AppLayout extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => HomeCubit.getInstance(), - child: BlocConsumer( - listener: (context, state) { - if (state is GetSpacesError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.errMessage), - ), - ); - Navigator.of(context) - .popUntil((route) => route.settings.name == Routes.authLogin); - } - }, - builder: (context, state) { - return AnnotatedRegion( - value: SystemUiOverlayStyle( - statusBarColor: ColorsManager.primaryColor.withOpacity(0.5), - statusBarIconBrightness: Brightness.light, - ), - child: SafeArea( - child: Scaffold( - backgroundColor: ColorsManager.backgroundColor, - extendBodyBehindAppBar: true, - extendBody: true, - appBar: HomeCubit.getInstance().spaces != null - ? const DefaultAppBar() - : null, - body: const AppBody(), - bottomNavigationBar: const DefaultNavBar(), - // floatingActionButton: FloatingActionButton( - // onPressed: () { - // Navigator.push( - // context, - // CustomPageRoute( - // builder: (context) => - // const ThreeGangSwitchesView(), - // ), - // ); - // }, - // child: const Icon(Icons.arrow_forward_ios_sharp), - // ), - ), + return BlocConsumer( + listener: (context, state) { + if (state is AuthError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), ), ); - }, - ), + Navigator.of(context) + .popUntil((route) => route.settings.name == Routes.authLogin); + } + }, + builder: (context, state) { + return BlocProvider( + create: (context) => HomeCubit.getInstance(), + child: BlocConsumer( + listener: (context, state) async { + if (state is GetSpacesError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errMessage), + ), + ); + Navigator.of(context).popUntil( + (route) => route.settings.name == Routes.authLogin); + } + String? token = await const FlutterSecureStorage() + .read(key: Token.loginAccessTokenKey); + if (token == null) { + // ignore: use_build_context_synchronously + Navigator.of(context).popAndPushNamed(Routes.authLogin); + } + }, + builder: (context, state) { + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: ColorsManager.primaryColor.withOpacity(0.5), + statusBarIconBrightness: Brightness.light, + ), + child: SafeArea( + child: Scaffold( + backgroundColor: ColorsManager.backgroundColor, + extendBodyBehindAppBar: true, + extendBody: true, + appBar: HomeCubit.getInstance().spaces != null + ? const DefaultAppBar() + : null, + body: const AppBody(), + bottomNavigationBar: const DefaultNavBar(), + // floatingActionButton: FloatingActionButton( + // onPressed: () { + // Navigator.push( + // context, + // CustomPageRoute( + // builder: (context) => + // const ThreeGangSwitchesView(), + // ), + // ); + // }, + // child: const Icon(Icons.arrow_forward_ios_sharp), + // ), + ), + ), + ); + }, + ), + ); + }, ); } } diff --git a/lib/features/auth/bloc/auth_cubit.dart b/lib/features/auth/bloc/auth_cubit.dart index d966e90..2ef1173 100644 --- a/lib/features/auth/bloc/auth_cubit.dart +++ b/lib/features/auth/bloc/auth_cubit.dart @@ -1,13 +1,13 @@ -import 'package:dio/dio.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:syncrow_app/features/app_layout/bloc/home_cubit.dart'; import 'package:syncrow_app/features/auth/model/login_with_email_model.dart'; import 'package:syncrow_app/features/auth/model/token.dart'; import 'package:syncrow_app/features/auth/model/user_model.dart'; +import 'package:syncrow_app/navigation/navigation_service.dart'; +import 'package:syncrow_app/navigation/routing_constants.dart'; import 'package:syncrow_app/services/api/authentication_api.dart'; import 'package:syncrow_app/services/api/network_exception.dart'; @@ -43,7 +43,7 @@ class AuthCubit extends Cubit { static Token token = Token.emptyConstructor(); login() async { - emit(AuthLoading()); + emit(AuthLoginLoading()); try { token = await AuthenticationAPI.loginWithEmail( model: LoginWithEmailModel( @@ -60,24 +60,27 @@ class AuthCubit extends Cubit { key: UserModel.userUuidKey, value: Token.decodeToken(token.accessToken)['uuid'].toString()); user = UserModel.fromToken(token); - emit(AuthSuccess()); + emailController.clear(); + passwordController.clear(); + emit(AuthLoginSuccess()); } else { - emit(AuthError('Something went wrong')); + emit(AuthLoginError(message: 'Something went wrong')); } - } on DioException catch (e) { - emit(AuthError(ServerFailure.fromDioError(e).toString())); + } on ServerFailure catch (failure) { + emit(AuthError(message: failure.errMessage)); } } logout() async { - emit(AuthLoading()); + emit(AuthLogoutLoading()); try { FlutterSecureStorage storage = const FlutterSecureStorage(); await storage.delete(key: Token.loginAccessTokenKey); - HomeCubit.clear(); - emit(AuthLoggedOut()); - } on DioException catch (e) { - emit(AuthError(ServerFailure.fromDioError(e).errMessage)); + NavigationService.navigatorKey.currentState! + .popAndPushNamed(Routes.authLogin); + emit(AuthLogoutSuccess()); + } on ServerFailure catch (failure) { + emit(AuthError(message: failure.errMessage)); } } @@ -87,7 +90,7 @@ class AuthCubit extends Cubit { await const FlutterSecureStorage().read(key: Token.loginAccessTokenKey); if (value == null) { - emit(AuthTokenError("Token not found")); + emit(AuthTokenError(message: "Token not found")); return; } @@ -100,10 +103,17 @@ class AuthCubit extends Cubit { if (currentTime < exp) { emit(AuthTokenSuccess()); } else { - emit(AuthTokenError("Token expired")); + emit(AuthTokenError(message: "Token expired")); } } else { - emit(AuthTokenError("Something went wrong")); + emit(AuthTokenError(message: "Something went wrong")); } } + + static void logUserOut() async { + user = null; + token = Token.emptyConstructor(); + FlutterSecureStorage storage = const FlutterSecureStorage(); + await storage.delete(key: Token.loginAccessTokenKey); + } } diff --git a/lib/features/auth/view/widgets/login/login_form.dart b/lib/features/auth/view/widgets/login/login_form.dart index ed76b7b..a1f1792 100644 --- a/lib/features/auth/view/widgets/login/login_form.dart +++ b/lib/features/auth/view/widgets/login/login_form.dart @@ -32,21 +32,25 @@ class LoginForm extends StatelessWidget { validator: (value) { if (state is! AuthTokenError) { if (value != null) { - if (value.isEmpty) { + if (value.isNotEmpty) { + if (RegExp( + r'^[a-zA-Z0-9._-]+@{1}[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$') + .hasMatch(value)) { + return null; + } else { + return 'Please enter a valid email'; + } + } else { return 'Please enter your email'; } - //Regex for email validation - if (!RegExp( - r'^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$') - .hasMatch(value)) { - return 'Please enter a valid email'; - } + } else { + return 'Please enter your email'; } } return null; }, onTapOutside: (event) { - formKey.currentState!.validate(); + FocusScope.of(context).unfocus(); }, decoration: defaultInputDecoration(context, hint: "Example@email.com"), @@ -62,9 +66,19 @@ class LoginForm extends StatelessWidget { if (state is! AuthTokenError) { if (value != null) { if (value.isNotEmpty) { - if (value.length < 6) { - return 'Password must be at least 8 characters'; - } + return null; + //TODO: uncomment this when the backend is ready + // if (value.length > 8) { + // if (RegExp( + // r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$') + // .hasMatch(value)) { + // return null; + // } else { + // return 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character'; + // } + // } else { + // return 'Password must be at least 8 characters'; + // } } else { return 'Please enter your password'; } @@ -73,7 +87,7 @@ class LoginForm extends StatelessWidget { return null; }, onTapOutside: (event) { - formKey.currentState!.validate(); + FocusScope.of(context).unfocus(); }, obscureText: !AuthCubit.get(context).isPasswordVisible, decoration: defaultInputDecoration(context, @@ -88,8 +102,16 @@ class LoginForm extends StatelessWidget { children: [ Expanded( child: DefaultButton( - isDone: state is AuthSuccess, + isDone: state is AuthLoginSuccess, isLoading: state is AuthLoading, + // enabled: AuthCubit.get(context) + // .emailController + // .text + // .isNotEmpty && + // AuthCubit.get(context) + // .passwordController + // .text + // .isNotEmpty, customButtonStyle: ButtonStyle( backgroundColor: MaterialStateProperty.all( Colors.black.withOpacity(.25), @@ -103,8 +125,10 @@ class LoginForm extends StatelessWidget { ), onPressed: () { if (formKey.currentState!.validate()) { - AuthCubit.get(context).login(); - FocusScope.of(context).unfocus(); + if (state is! AuthLoginLoading) { + AuthCubit.get(context).login(); + FocusScope.of(context).unfocus(); + } } }, ), diff --git a/lib/features/devices/bloc/devices_cubit.dart b/lib/features/devices/bloc/devices_cubit.dart index 7b17513..d976f59 100644 --- a/lib/features/devices/bloc/devices_cubit.dart +++ b/lib/features/devices/bloc/devices_cubit.dart @@ -31,12 +31,9 @@ class DevicesCubit extends Cubit { } } } - bool _isClosed = false; static DevicesCubit? _instance; static DevicesCubit getInstance() { - print('device cubit instance found : ${_instance != null}'); - print('selected space : ${HomeCubit.getInstance().selectedSpace != null}'); return _instance ??= DevicesCubit._(); } @@ -44,7 +41,6 @@ class DevicesCubit extends Cubit { @override Future close() { - _isClosed = true; _instance = null; return super.close(); } @@ -272,56 +268,37 @@ class DevicesCubit extends Cubit { emitSafe(DeviceControlError('Failed to control the device')); } }); - } on DioException catch (e) { - emitSafe(DeviceControlError(ServerFailure.fromDioError(e).errMessage)); + } on ServerFailure catch (failure) { + emitSafe(DeviceControlError(failure.errMessage)); } } fetchGroups(int spaceId) async { - if (_isClosed) return; - - try { - emitSafe(DevicesCategoriesLoading()); - allCategories = await DevicesAPI.fetchGroups(spaceId); - emitSafe(DevicesCategoriesSuccess()); - } on DioException catch (error) { - emitSafe( - DevicesCategoriesError(ServerFailure.fromDioError(error).errMessage), - ); - } + emitSafe(DevicesCategoriesLoading()); + allCategories = await DevicesAPI.fetchGroups(spaceId); + emitSafe(DevicesCategoriesSuccess()); } fetchDevicesByRoomId(int? roomId) async { - if (_isClosed) return; if (roomId == null) return; - try { - emitSafe(GetDevicesLoading()); - int roomIndex = HomeCubit.getInstance() - .selectedSpace! - .rooms! - .indexWhere((element) => element.id == roomId); - HomeCubit.getInstance().selectedSpace!.rooms![roomIndex].devices = - await SpacesAPI.getDevicesByRoomId(roomId); - //get status for each device - for (var device in HomeCubit.getInstance() - .selectedSpace! - .rooms![roomIndex] - .devices!) { - getDevicesStatues(device.id!, roomIndex); - } - - emitSafe(GetDevicesSuccess()); - } on DioException catch (error) { - emitSafe( - GetDevicesError(ServerFailure.fromDioError(error).errMessage), - ); + emitSafe(GetDevicesLoading()); + int roomIndex = HomeCubit.getInstance() + .selectedSpace! + .rooms! + .indexWhere((element) => element.id == roomId); + HomeCubit.getInstance().selectedSpace!.rooms![roomIndex].devices = + await SpacesAPI.getDevicesByRoomId(roomId); + //get status for each device + for (var device + in HomeCubit.getInstance().selectedSpace!.rooms![roomIndex].devices!) { + getDevicesStatues(device.id!, roomIndex); } + + emitSafe(GetDevicesSuccess()); } getDevicesStatues(String deviceId, int roomIndex, {String? code}) async { - if (_isClosed) return; - try { emitSafe(GetDeviceStatusLoading(code: code)); int deviceIndex = HomeCubit.getInstance() @@ -345,9 +322,9 @@ class DevicesCubit extends Cubit { .devices![deviceIndex] .status = statuses; emitSafe(GetDeviceStatusSuccess(code: code)); - } on DioException catch (error) { + } on ServerFailure catch (failure) { emitSafe( - GetDeviceStatusError(ServerFailure.fromDioError(error).errMessage), + GetDeviceStatusError(failure.errMessage), ); } } diff --git a/lib/my_app.dart b/lib/my_app.dart index 1ec3b08..46ae999 100644 --- a/lib/my_app.dart +++ b/lib/my_app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncrow_app/features/auth/bloc/auth_cubit.dart'; +import 'package:syncrow_app/navigation/navigation_service.dart'; import 'package:syncrow_app/utils/resource_manager/color_manager.dart'; import 'package:syncrow_app/utils/resource_manager/constants.dart'; import 'package:syncrow_app/utils/resource_manager/theme_manager.dart'; @@ -21,6 +22,8 @@ class MyApp extends StatelessWidget { return BlocProvider( create: (context) => AuthCubit(), child: MaterialApp( + navigatorKey: NavigationService.navigatorKey, + scaffoldMessengerKey: NavigationService.snackbarKey, debugShowCheckedModeBanner: false, color: ColorsManager.primaryColor, title: 'Syncrow App', diff --git a/lib/navigation/navigation_service.dart b/lib/navigation/navigation_service.dart new file mode 100644 index 0000000..c0b1216 --- /dev/null +++ b/lib/navigation/navigation_service.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class NavigationService { + static GlobalKey navigatorKey = GlobalKey(); + static GlobalKey? snackbarKey = + GlobalKey(); +} diff --git a/lib/services/api/http_interceptor.dart b/lib/services/api/http_interceptor.dart index 3460bc4..38b43cb 100644 --- a/lib/services/api/http_interceptor.dart +++ b/lib/services/api/http_interceptor.dart @@ -1,46 +1,63 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:syncrow_app/features/auth/bloc/auth_cubit.dart'; import 'package:syncrow_app/features/auth/model/token.dart'; +import 'package:syncrow_app/navigation/navigation_service.dart'; +import 'dart:async'; +import 'dart:developer' as developer; +import 'package:syncrow_app/services/api/network_exception.dart'; +import 'package:syncrow_app/utils/helpers/snack_bar.dart'; class HTTPInterceptor extends InterceptorsWrapper { - // @override - // void onResponse(Response response, ResponseInterceptorHandler handler) async { - // // Pass the response to the next interceptor or response handler. - // return handler.next(response); - // } - // + @override + @override + void onResponse(Response response, ResponseInterceptorHandler handler) async { + return handler.next(response); + } + @override void onRequest( RequestOptions options, RequestInterceptorHandler handler) async { var storage = const FlutterSecureStorage(); var token = await storage.read(key: Token.loginAccessTokenKey); - - options.headers['Authorization'] = 'Bearer $token'; + // options.headers['Authorization'] = 'Bearer $token'; + options.headers['Authorization'] = 'Bearer ${'${token!}123'}'; super.onRequest(options, handler); } -// -// @override -// void onError(DioException err, ErrorInterceptorHandler handler) async { -// // TODO: Implement error handling logic. -// // This method is called when an error occurs during a request. -// super.onError(err, handler); -// } -// -// /// Validates the response and returns true if it is successful (status code 2xx). -// Future validateResponse(Response response) async { -// if (response.statusCode != null) { -// if (response.statusCode! >= 200 && response.statusCode! < 300) { -// // If the response status code is within the successful range (2xx), -// // return true indicating a successful response. -// return true; -// } else { -// // If the response status code is not within the successful range (2xx), -// // return false indicating an unsuccessful response. -// return false; -// } -// } else { -// // If the response status code is null, return false indicating an unsuccessful response. -// return false; -// } -// } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + developer.log('Error Message: ${err.message}'); + developer.log('Error res Code: ${err.response?.statusCode}'); + developer.log('Error res Data: ${err.response?.data}'); + developer.log('Error res status message: ${err.response?.statusMessage}'); + + ServerFailure failure = ServerFailure.fromDioError(err); + CustomSnackBar.displaySnackBar(failure.toString()); + var storage = const FlutterSecureStorage(); + var token = await storage.read(key: Token.loginAccessTokenKey); + if (err.response?.statusCode == 401 && token != null) { + await AuthCubit.get(NavigationService.navigatorKey.currentContext!) + .logout(); + super.onError(err, handler); + } + } + + /// Validates the response and returns true if it is successful (status code 2xx). + Future validateResponse(Response response) async { + if (response.statusCode != null) { + if (response.statusCode! >= 200 && response.statusCode! < 300) { + // If the response status code is within the successful range (2xx), + // return true indicating a successful response. + return true; + } else { + // If the response status code is not within the successful range (2xx), + // return false indicating an unsuccessful response. + return false; + } + } else { + // If the response status code is null, return false indicating an unsuccessful response. + return false; + } + } } diff --git a/lib/services/api/network_exception.dart b/lib/services/api/network_exception.dart index f8940de..2b61e28 100644 --- a/lib/services/api/network_exception.dart +++ b/lib/services/api/network_exception.dart @@ -32,9 +32,7 @@ class ServerFailure extends Failure { // var document = parser.parse(dioError.response!.data.toString()); // var message = document.body!.text; return ServerFailure.fromResponse(dioError.response!.statusCode!, - dioError.response!.data['message'].toString() - // message - ); + dioError.response!.data['message']); } case DioExceptionType.cancel: return ServerFailure("The request to ApiServer was canceled"); @@ -50,31 +48,27 @@ class ServerFailure extends Failure { } } - factory ServerFailure.fromResponse(int statusCode, dynamic response) { - if (statusCode == 401 || statusCode == 403) { - return ServerFailure(response); - } else if (statusCode == 400) { - //response is list of errors - List errors = []; - response.forEach((element) { - errors.add(element); - }); - return ServerFailure(errors.join('\n')); - } else if (statusCode == 404) { - return ServerFailure("Your request not found, Please try later!"); - } else if (statusCode == 500) { - return ServerFailure(response); - } else { - return ServerFailure("Opps there was an Error, Please try again!"); + factory ServerFailure.fromResponse(int? statusCode, dynamic response) { + switch (statusCode) { + case 401: + case 403: + return ServerFailure(response); + case 400: + List errors = []; + if (response['message'] is List) { + for (var error in response['message']) { + errors.add(error); + } + } else { + errors.add(response['message']); + } + return ServerFailure(errors.join('\n')); + case 404: + return ServerFailure("Your request not found, Please try later!"); + case 500: + return ServerFailure(response); + default: + return ServerFailure("Opps there was an Error, Please try again!"); } } } - -class ResponseFailure extends Failure { - ResponseFailure(super.errMessage); - - @override - String toString() { - return errMessage; - } -} diff --git a/lib/utils/helpers/snack_bar.dart b/lib/utils/helpers/snack_bar.dart new file mode 100644 index 0000000..61c99ee --- /dev/null +++ b/lib/utils/helpers/snack_bar.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_app/navigation/navigation_service.dart'; + +class CustomSnackBar { + static displaySnackBar(String message) { + final key = NavigationService.snackbarKey; + if (key != null) { + final snackBar = SnackBar(content: Text(message)); + key.currentState?.clearSnackBars(); + key.currentState?.showSnackBar(snackBar); + } + } + + static greenSnackBar(String message) { + final key = NavigationService.snackbarKey; + BuildContext? currentContext = key?.currentContext; + if (key != null && currentContext != null) { + final snackBar = SnackBar( + padding: const EdgeInsets.all(16), + backgroundColor: Colors.green, + content: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon( + Icons.check_circle, + color: Colors.white, + size: 32, + ), + const SizedBox( + width: 8, + ), + Text( + message, + style: Theme.of(currentContext).textTheme.bodySmall!.copyWith( + fontSize: 14, fontWeight: FontWeight.w500, color: Colors.green), + ) + ]), + ); + key.currentState?.showSnackBar(snackBar); + } + } +}