diff --git a/lib/main.dart b/lib/main.dart index d4ea699..75afcf1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,7 +22,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; late String selectedTheme; void main() async { - final widgetsBinding=WidgetsFlutterBinding.ensureInitialized(); + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); await Future.delayed(const Duration(milliseconds: 300)); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); await dotenv.load(fileName: '.env'); @@ -53,6 +53,7 @@ class _MyAppState extends ConsumerState with WidgetsBindingObserver { @override void initState() { WidgetsBinding.instance.addObserver(this); + // Const.accessToken = "${Const.accessToken!}|"; DependencyResolver.resolve(ref: ref); super.initState(); } diff --git a/lib/provider/projects_provider.dart b/lib/provider/projects_provider.dart index 85a7130..e438e33 100644 --- a/lib/provider/projects_provider.dart +++ b/lib/provider/projects_provider.dart @@ -102,10 +102,12 @@ class ProjectsProvider extends ChangeNotifier { .workspaceSlug; prov.getStates(slug: workspaceSlug, projID: currentProject['id']); + prov.getProjectMembers( slug: workspaceSlug, projID: currentProject['id'], ); + // return; ref.read(ProviderList.estimatesProvider).getEstimates( slug: workspaceSlug, projID: currentProject['id'], diff --git a/lib/screens/MainScreens/Home/Dashboard/dash_board_screen.dart b/lib/screens/MainScreens/Home/Dashboard/dash_board_screen.dart index 7ed3110..b0153ff 100644 --- a/lib/screens/MainScreens/Home/Dashboard/dash_board_screen.dart +++ b/lib/screens/MainScreens/Home/Dashboard/dash_board_screen.dart @@ -492,7 +492,7 @@ class _DashBoardScreenState extends ConsumerState { Container( margin: const EdgeInsets.all(8), padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + const EdgeInsets.symmetric(vertical: 10, horizontal: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(3), color: themeProvider.themeManager.toastErrorColor, @@ -503,6 +503,7 @@ class _DashBoardScreenState extends ConsumerState { flex: flexForUpcomingAndOverdueWidgets[0], child: CustomText( 'Overdue', + height: 1, color: themeProvider.themeManager.primaryTextColor, fontWeight: FontWeightt.Semibold, )), @@ -513,6 +514,7 @@ class _DashBoardScreenState extends ConsumerState { flex: flexForUpcomingAndOverdueWidgets[1], child: CustomText( 'Issue', + height: 1, color: themeProvider.themeManager.primaryTextColor, fontWeight: FontWeightt.Semibold, )), @@ -520,6 +522,7 @@ class _DashBoardScreenState extends ConsumerState { flex: flexForUpcomingAndOverdueWidgets[2], child: CustomText( 'Due Date', + height: 1, color: themeProvider.themeManager.primaryTextColor, fontWeight: FontWeightt.Semibold, ), diff --git a/lib/screens/MainScreens/Profile/ProfileSettings/profile_screen.dart b/lib/screens/MainScreens/Profile/ProfileSettings/profile_screen.dart index 51e57bc..e9950ff 100644 --- a/lib/screens/MainScreens/Profile/ProfileSettings/profile_screen.dart +++ b/lib/screens/MainScreens/Profile/ProfileSettings/profile_screen.dart @@ -461,10 +461,11 @@ class _ProfileScreenState extends ConsumerState { Container( width: MediaQuery.of(context).size.width - 105, padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 10), + vertical: 15, horizontal: 10), child: CustomText( menus[index]['menu'], type: FontStyle.Medium, + height: 1, color: themeProvider.themeManager.primaryColour, textAlign: TextAlign.start, ), diff --git a/lib/screens/MainScreens/Projects/ProjectDetail/IssuesTab/create_issue.dart b/lib/screens/MainScreens/Projects/ProjectDetail/IssuesTab/create_issue.dart index c3bd2ad..fbb3116 100644 --- a/lib/screens/MainScreens/Projects/ProjectDetail/IssuesTab/create_issue.dart +++ b/lib/screens/MainScreens/Projects/ProjectDetail/IssuesTab/create_issue.dart @@ -388,8 +388,11 @@ class _CreateIssueState extends ConsumerState .primaryTextColor, fontWeight: FontWeightt.Medium, ), - const Icon( + Icon( Icons.keyboard_arrow_down, + color: themeProvider + .themeManager + .primaryTextColor, ) ], ) diff --git a/lib/screens/MainScreens/Projects/ProjectDetail/Settings/states_pages.dart b/lib/screens/MainScreens/Projects/ProjectDetail/Settings/states_pages.dart index 3b10601..f2eedd0 100644 --- a/lib/screens/MainScreens/Projects/ProjectDetail/Settings/states_pages.dart +++ b/lib/screens/MainScreens/Projects/ProjectDetail/Settings/states_pages.dart @@ -142,6 +142,7 @@ class _StatesPageState extends ConsumerState { issuesProvider.statesData[states[index]][idx] ['name'], type: FontStyle.Medium, + height: 1, color: themeProvider.themeManager.primaryTextColor, ), diff --git a/lib/screens/MainScreens/Projects/project_screen.dart b/lib/screens/MainScreens/Projects/project_screen.dart index d9fc26e..44542d9 100644 --- a/lib/screens/MainScreens/Projects/project_screen.dart +++ b/lib/screens/MainScreens/Projects/project_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:plane/bottom_sheets/global_search_sheet.dart'; +import 'package:plane/config/const.dart'; import 'package:plane/provider/projects_provider.dart'; import 'package:plane/provider/theme_provider.dart'; import 'package:plane/screens/MainScreens/Projects/ProjectDetail/project_detail.dart'; @@ -440,6 +441,7 @@ class _ProjectScreenState extends ConsumerState ? Container() : ListTile( onTap: () { + Const.accessToken = "${Const.accessToken!}|"; if (projectProvider.currentProject != projectProvider.projects[index]) { ref.read(ProviderList.issuesProvider).clearData(); diff --git a/lib/services/dio_service.dart b/lib/services/dio_service.dart index 9a33e26..7774dca 100644 --- a/lib/services/dio_service.dart +++ b/lib/services/dio_service.dart @@ -4,17 +4,20 @@ import 'dart:io'; // Package imports: import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; import 'package:plane/config/const.dart'; -import 'package:plane/screens/on_boarding/on_boarding_screen.dart'; -import 'package:plane/services/shared_preference_service.dart'; +import 'package:plane/services/interceptors/token_refresh_handler.dart'; +import 'package:plane/services/log.dart'; import 'package:plane/utils/enums.dart'; import 'package:retry/retry.dart'; class DioConfig { // Static Dio created to directly access Dio client static Dio dio = Dio(); - static Dio getDio({ + static bool refreshingToken = false; + static RequestInterceptorHandler? nextHandler; + static RequestOptions? nextOptions; + + Dio getDio({ dynamic data, bool hasAuth = true, bool hasBody = true, @@ -41,52 +44,19 @@ class DioConfig { ); dio.options = options; - dio.interceptors.add( - // InterceptorsWrapper(onRequest: - // (RequestOptions options, RequestInterceptorHandler handler) async { - // if (hasBody) options.data = data; - // return handler.next(options); - // }), - InterceptorsWrapper( - onRequest: - (RequestOptions options, RequestInterceptorHandler handler) async { - if (hasBody) options.data = data; - return handler.next(options); - }, - onError: (DioException error, ErrorInterceptorHandler handler) async { - // bool isConnected = await ConnectionService.checkConnectivity(); - // if (!isConnected) { - // Navigator.push( - // Const.globalKey.currentContext!, - // MaterialPageRoute( - // builder: (ctx) => Scaffold( - // body: errorState( - // context: Const.globalKey.currentContext!, - // )))); - // } - if (error.response?.statusCode == 401) { - await SharedPrefrenceServices.instance.clear(); - Const.accessToken = ''; - Const.userId = null; - Navigator.push(Const.globalKey.currentContext!, - MaterialPageRoute(builder: (ctx) => const OnBoardingScreen())); - } - // Retrieve the error response data - final errorResponse = error.response?.data; - - // Create a new DioError instance with the error response data - final DioException newError = DioException( - response: error.response, - requestOptions: error.requestOptions, - error: errorResponse, - ); - - // Return the new DioError instance - handler.reject(newError); - }, - ) - // DioFirebasePerformanceInterceptor(), - ); + if (dio.interceptors.length == 1) { + Log.info('Interceptor added'); + dio.interceptors.addAll([ + ReGenerateToken(dio), + // InterceptorsWrapper( + // onRequest: (RequestOptions options, + // RequestInterceptorHandler handler) async { + // if (hasBody) options.data = data; + // return handler.reject(DioException(requestOptions: options)); + // }, + // ), + ]); + } return dio; } diff --git a/lib/services/interceptors/token_refresh_handler.dart b/lib/services/interceptors/token_refresh_handler.dart new file mode 100644 index 0000000..ec5981f --- /dev/null +++ b/lib/services/interceptors/token_refresh_handler.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:plane/config/const.dart'; +import 'package:plane/screens/on_boarding/on_boarding_screen.dart'; +import 'package:plane/services/jwt_decoder.dart'; +import 'package:plane/services/log.dart'; +import 'package:plane/services/shared_preference_service.dart'; + + +class ReGenerateToken extends Interceptor { + ReGenerateToken(this.dio); + final Dio dio; + int retryCount = 0; + bool isRefreshing = false; + List failedApis = []; + + @override + void onRequest( + RequestOptions options, RequestInterceptorHandler handler) async { + if ((JwtDecoder.tryDecode(Const.accessToken!) == null || + JwtDecoder.isExpired(Const.accessToken!)) && + !isRefreshing) { + try { + isRefreshing = true; + Log.warn('TOKEN EXPIRED'); + await generateToken(dio); + options.headers['Authorization'] = 'Bearer ${Const.accessToken!}'; + return handler.next(options); + } catch (error) { + Log.error('GENERATE TOKEN FAILED'); + isRefreshing = false; + await SharedPrefrenceServices.instance.clear(); + Const.accessToken = ''; + Const.userId = null; + Navigator.push(Const.globalKey.currentContext!, + MaterialPageRoute(builder: (ctx) => const OnBoardingScreen())); + return handler.reject(DioException(requestOptions: options)); + } + } else { + if (isRefreshing) { + Timer.periodic(const Duration(seconds: 6), (timer) async { + if (timer.tick == 1) { + timer.cancel(); + return handler.reject(DioException(requestOptions: options)); + } + if (!isRefreshing) { + timer.cancel(); + options.headers['Authorization'] = 'Bearer ${Const.accessToken!}'; + return handler.next(options); + } + }); + } else { + return handler.next(options); + } + } + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + Log.debug( + 'RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}', + ); + super.onResponse(response, handler); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + Log.error( + 'ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}', + ); + super.onError(err, handler); + } + + Future generateToken(Dio dio) async { + await Future.delayed(const Duration(seconds: 1)); + Log.info('TOKEN GENERATED'); + Const.accessToken = Const.accessToken!.split('|').first; + dio.options.headers['Authorization'] = 'Bearer ${Const.accessToken!}'; + isRefreshing = false; + // try { + // final response = await DioConfig().dioServe( + // hasAuth: false, + // url: '${APIs.baseApi}/api/token/refresh/', + // hasBody: true, + // data: {"refresh": Const.refreshToken}, + // httpMethod: HttpMethod.post, + // ); + // SharedPrefrenceServices.setTokens( + // accessToken: response.data['access'], + // refreshToken: Const.refreshToken!, + // ); + // dio.options.headers['Authorization'] = 'Bearer ${Const.accessToken!}'; + // } on DioException catch (error) { + // log(error.toString()); + // rethrow; + // } + } + + Future _retry( + {required Dio dio, required RequestOptions requestOptions}) async { + String endpoint = requestOptions.uri.toString(); + final Response response; + try { + switch (requestOptions.method) { + case 'GET': + response = await dio.get(endpoint); + break; + case 'POST': + response = await dio.post(endpoint); + break; + case 'PUT': + response = await dio.put(endpoint); + break; + case 'DELETE': + response = await dio.delete(endpoint); + break; + case 'PATCH': + response = await dio.patch(endpoint); + break; + default: + response = await dio.get(endpoint); + break; + } + return response; + } on DioException catch (_) { + rethrow; + } + } +} diff --git a/lib/services/jwt_decoder.dart b/lib/services/jwt_decoder.dart new file mode 100644 index 0000000..77e90e0 --- /dev/null +++ b/lib/services/jwt_decoder.dart @@ -0,0 +1,99 @@ +library jwt_decoder; + +import 'dart:convert'; + +class JwtDecoder { + /// Decode a string JWT token into a `Map` + /// containing the decoded JSON payload. + /// + /// Note: header and signature are not returned by this method. + /// + /// Throws [FormatException] if parameter is not a valid JWT token. + static Map decode(String token) { + final splitToken = token.split("."); // Split the token by '.' + if (splitToken.length != 3) { + throw const FormatException('Invalid token'); + } + try { + final payloadBase64 = splitToken[1]; // Payload is always the index 1 + // Base64 should be multiple of 4. Normalize the payload before decode it + final normalizedPayload = base64.normalize(payloadBase64); + // Decode payload, the result is a String + final payloadString = utf8.decode(base64.decode(normalizedPayload)); + // Parse the String to a Map + final decodedPayload = jsonDecode(payloadString); + + // Return the decoded payload + return decodedPayload; + } catch (error) { + throw const FormatException('Invalid payload'); + } + } + + /// Decode a string JWT token into a `Map` + /// containing the decoded JSON payload. + /// + /// Note: header and signature are not returned by this method. + /// + /// Returns null if the token is not valid + static Map? tryDecode(String token) { + try { + return decode(token); + } catch (error) { + return null; + } + } + + /// Tells whether a token is expired. + /// + /// Returns false if the token is valid, true if it is expired. + /// + /// Throws [FormatException] if parameter is not a valid JWT token. + static bool isExpired(String token) { + final expirationDate = getExpirationDate(token); + if (expirationDate == null) { + return false; + } + // If the current date is after the expiration date, the token is already expired + return DateTime.now().isAfter(expirationDate); + } + + static DateTime? _getDate({required String token, required String claim}) { + final decodedToken = decode(token); + final expiration = decodedToken[claim] as int?; + if (expiration == null) { + return null; + } + return DateTime.fromMillisecondsSinceEpoch(expiration * 1000); + } + + /// Returns token expiration date + /// + /// Throws [FormatException] if parameter is not a valid JWT token. + static DateTime? getExpirationDate(String token) { + return _getDate(token: token, claim: 'exp'); + } + + /// Returns token issuing date (iat) + /// + /// Throws [FormatException] if parameter is not a valid JWT token. + static Duration? getTokenTime(String token) { + final issuedAtDate = _getDate(token: token, claim: 'iat'); + if (issuedAtDate == null) { + return null; + } + return DateTime.now().difference(issuedAtDate); + } + + /// Returns remaining time until expiry date. + /// + /// Throws [FormatException] if parameter is not a valid JWT token. + static Duration? getRemainingTime(String token) { + final expirationDate = getExpirationDate(token); + if (expirationDate == null) { + return null; + } + + return expirationDate.difference(DateTime.now()); + } +} \ No newline at end of file diff --git a/lib/services/log.dart b/lib/services/log.dart new file mode 100644 index 0000000..f36304a --- /dev/null +++ b/lib/services/log.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +class Log { + Log() { + _logger = Logger( + printer: PrettyPrinter( + methodCount: 2, // number of method calls to be displayed + errorMethodCount: + 8, // number of method calls if stacktrace is provided + lineLength: 120, // width of the output + colors: true, // Colorful log messages + printEmojis: true, // Print an emoji for each log message + printTime: false // Should each log print contain a timestamp + ), + level: kDebugMode ? Level.debug : Level.info, + ); + } + static final shared = Log(); + late Logger _logger; + + static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (kDebugMode) { + Log.shared._logger.i(msg, error: error, stackTrace: stackTrace); + } + } + + static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (kDebugMode) { + Log.shared._logger.d(msg, error: error, stackTrace: stackTrace); + } + } + + static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (kDebugMode) { + Log.shared._logger.w(msg, error: error, stackTrace: stackTrace); + } + } + + static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (kDebugMode) { + Log.shared._logger.t(msg, error: error, stackTrace: stackTrace); + } + } + + static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (kDebugMode) { + Log.shared._logger.e(msg, error: error, stackTrace: stackTrace); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index dddbf11..28349d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: package_config: ^2.1.0 path_provider: ^2.0.15 percent_indicator: ^4.2.3 - rename: ^2.1.1 + rename: ^3.0.0 retry: ^3.1.1 shared_preferences: ^2.1.1 syncfusion_flutter_charts: ^21.2.9 @@ -74,6 +74,7 @@ dependencies: dartz: ^0.10.1 upgrader: ^8.1.0 flutter_native_splash: ^2.3.2 + logger: ^2.0.2+1 dev_dependencies: build_runner: ^2.1.7