Refactor HTTPInterceptor and add CustomSnackBar helper

Refactor HTTPInterceptor to handle error responses and add a CustomSnackBar
helper to display snack bars. This will improve error handling and user
feedback in the application.
This commit is contained in:
Mohammad Salameh
2024-04-15 12:02:34 +03:00
parent 590c70a7d8
commit cfc395e210
10 changed files with 287 additions and 188 deletions

View File

@ -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<HomeState> {
}
}
@override
Future<void> close() {
_instance = null;
selectedSpace = null;
selectedRoom = null;
return super.close();
}
static HomeCubit get(context) => BlocProvider.of(context);
List<SpaceModel>? spaces;
@ -124,8 +131,8 @@ class HomeCubit extends Cubit<HomeState> {
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<HomeState> {
} 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<String, List<Widget>> appBarActions = {

View File

@ -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,18 +17,37 @@ class AppLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocConsumer<AuthCubit, AuthState>(
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<HomeCubit, HomeState>(
listener: (context, state) {
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);
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) {
@ -62,5 +84,7 @@ class AppLayout extends StatelessWidget {
},
),
);
},
);
}
}

View File

@ -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<AuthState> {
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<AuthState> {
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<AuthState> {
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<AuthState> {
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);
}
}

View File

@ -32,21 +32,25 @@ class LoginForm extends StatelessWidget {
validator: (value) {
if (state is! AuthTokenError) {
if (value != null) {
if (value.isEmpty) {
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}$')
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';
}
} 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,9 +125,11 @@ class LoginForm extends StatelessWidget {
),
onPressed: () {
if (formKey.currentState!.validate()) {
if (state is! AuthLoginLoading) {
AuthCubit.get(context).login();
FocusScope.of(context).unfocus();
}
}
},
),
),

View File

@ -31,12 +31,9 @@ class DevicesCubit extends Cubit<DevicesState> {
}
}
}
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<DevicesState> {
@override
Future<void> close() {
_isClosed = true;
_instance = null;
return super.close();
}
@ -272,30 +268,20 @@ class DevicesCubit extends Cubit<DevicesState> {
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),
);
}
}
fetchDevicesByRoomId(int? roomId) async {
if (_isClosed) return;
if (roomId == null) return;
try {
emitSafe(GetDevicesLoading());
int roomIndex = HomeCubit.getInstance()
.selectedSpace!
@ -304,24 +290,15 @@ class DevicesCubit extends Cubit<DevicesState> {
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!) {
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),
);
}
}
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<DevicesState> {
.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),
);
}
}

View File

@ -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',

View File

@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
class NavigationService {
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
static GlobalKey<ScaffoldMessengerState>? snackbarKey =
GlobalKey<ScaffoldMessengerState>();
}

View File

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

View File

@ -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) {
factory ServerFailure.fromResponse(int? statusCode, dynamic response) {
switch (statusCode) {
case 401:
case 403:
return ServerFailure(response);
} else if (statusCode == 400) {
//response is list of errors
case 400:
List<String> 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);
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;
}
}

View File

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