From 1cb344738eafcb21dbfa7fb65c112c311a65fe06 Mon Sep 17 00:00:00 2001 From: mystery012728 Date: Tue, 27 Jan 2026 18:47:15 +0530 Subject: [PATCH] refresh token api integreted and isLogin created in local storages --- lib/checkout/view/checkout_view.dart | 44 ++-- .../pass_purchase_details_bottomsheet.dart | 238 ++++++++++++++++++ .../bloc/create_account_bloc.dart | 8 + lib/home/widgets/pass_card_list.dart | 26 +- lib/localPreference/local_database.dart | 22 +- lib/localPreference/local_preference.dart | 91 +++++-- lib/login/bloc/verify/verify_bloc.dart | 6 + lib/login/view/login_email_bottomsheet.dart | 56 ++--- lib/login/view/verify_otp_bottomsheet.dart | 110 ++++---- lib/networkApiServices/api_urls.dart | 2 + .../auth/models/auth_token_model.dart | 0 .../repository/auth_token_repository.dart | 0 .../network_api_services.dart | 235 +++++++++++------ 13 files changed, 619 insertions(+), 219 deletions(-) create mode 100644 lib/checkout/widget/pass_purchase_details_bottomsheet.dart create mode 100644 lib/networkApiServices/auth/models/auth_token_model.dart create mode 100644 lib/networkApiServices/auth/repository/auth_token_repository.dart diff --git a/lib/checkout/view/checkout_view.dart b/lib/checkout/view/checkout_view.dart index e621c8a..b64717a 100644 --- a/lib/checkout/view/checkout_view.dart +++ b/lib/checkout/view/checkout_view.dart @@ -8,6 +8,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../common_packages/common_app_texts.dart'; +import '../../localPreference/local_preference.dart'; +import '../../postcard/widgets/purchase_details_bottom_sheet.dart'; +import '../widget/pass_purchase_details_bottomsheet.dart'; class CheckoutView extends StatelessWidget { const CheckoutView({super.key}); @@ -345,22 +348,35 @@ class CheckoutView extends StatelessWidget { ], ), const Spacer(), - CustomFilledButton( - onTap: () { - showModalBottomSheet( - backgroundColor: Colors.white, - context: context, - isScrollControlled: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), - ), - builder: (_) => const LoginEmailBottomsheet(), + FutureBuilder( + future: LocalPreference.getLogin(), + builder: (context, snapshot) { + final isLoggedIn = snapshot.data ?? false; + + return CustomFilledButton( + onTap: () { + if (isLoggedIn) { + // Show purchase details bottom sheet if logged in + PassPurchaseBottomSheet.show(context); + } else { + // Show login bottom sheet if not logged in + showModalBottomSheet( + backgroundColor: Colors.white, + context: context, + isScrollControlled: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + builder: (_) => const LoginEmailBottomsheet(), + ); + } + }, + width: double.infinity, + label: isLoggedIn ? "Checkout" : "Login to Checkout", ); }, - width: double.infinity, - label: "Login to Checkout", ), SizedBox(height: 25.h), ], diff --git a/lib/checkout/widget/pass_purchase_details_bottomsheet.dart b/lib/checkout/widget/pass_purchase_details_bottomsheet.dart new file mode 100644 index 0000000..8bf37fd --- /dev/null +++ b/lib/checkout/widget/pass_purchase_details_bottomsheet.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class PassPurchaseBottomSheet { + static void show(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext modalContext) { + return _PassPurchaseContent(); + }, + ); + } + + static void close(BuildContext context) { + Navigator.of(context).pop(); + } +} + +class _PassPurchaseContent extends StatefulWidget { + @override + State<_PassPurchaseContent> createState() => _PassPurchaseContentState(); +} + +class _PassPurchaseContentState extends State<_PassPurchaseContent> { + bool isGift = false; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + top: 16, + left: 16, + right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 45, + height: 5, + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 12), + + // Title + Text( + "Purchase Details", + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + + // Option 1: Buy Pass for Myself + GestureDetector( + onTap: () => setState(() => isGift = false), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: !isGift + ? Border.all( + color: const Color(0xffF95F62), + width: 1.5, + ) + : null, + ), + child: Row( + children: [ + Radio( + value: false, + groupValue: isGift, + onChanged: (value) => setState(() => isGift = false), + activeColor: const Color(0xffF95F62), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Buy Pass for Myself", + style: TextStyle( + fontWeight: FontWeight.w600, + color: !isGift + ? const Color(0xffF95F62) + : const Color(0xff9E9E9E), + ), + ), + if (!isGift) ...[ + const SizedBox(height: 8), + const Text( + "Frank Adam", + style: TextStyle( + fontWeight: FontWeight.w500, + color: Color(0xff1A1A1A), + ), + ), + const Text( + "132 My Street, Kingston, NY\n12401", + style: TextStyle( + fontSize: 13, + color: Color(0xff5E5E5E), + ), + ), + ], + ], + ), + ), + if (!isGift) + ElevatedButton( + onPressed: () { + // Handle edit details + PassPurchaseBottomSheet.close(context); + // Navigate to edit details screen + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffF95F62), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + "Edit Details", + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // Option 2: Gift the Pass + GestureDetector( + onTap: () => setState(() => isGift = true), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: isGift + ? Border.all( + color: const Color(0xffF95F62), + width: 1.5, + ) + : null, + ), + child: Row( + children: [ + Radio( + value: true, + groupValue: isGift, + onChanged: (value) => setState(() => isGift = true), + activeColor: const Color(0xffF95F62), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Gift the pass", + style: TextStyle( + fontWeight: FontWeight.w600, + color: isGift + ? const Color(0xffF95F62) + : const Color(0xff9E9E9E), + ), + ), + if (isGift) + const SizedBox(height: 4), + if (isGift) + const Text( + "Gift the pass for someone else", + style: TextStyle( + fontSize: 13, + color: Color(0xff9E9E9E), + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 15), + + // Proceed Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + PassPurchaseBottomSheet.close(context); + // Handle proceed action + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffF95F62), + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), + ), + ), + child: Text( + "Proceed", + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 15), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/create_account/bloc/create_account_bloc.dart b/lib/create_account/bloc/create_account_bloc.dart index c0512a9..aa6c0e4 100644 --- a/lib/create_account/bloc/create_account_bloc.dart +++ b/lib/create_account/bloc/create_account_bloc.dart @@ -1,4 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../localPreference/local_preference.dart'; +import '../models/create_account_model.dart'; import '../repository/create_account_repository.dart'; import 'create_account_event.dart'; import 'create_account_state.dart'; @@ -28,6 +30,12 @@ class CreateAccountBloc extends Bloc { address2: event.address2, ); + final userModel = UserRegisteredModel.fromJson(response['data'] ?? {}); + await LocalPreference.setTokens( + accessToken: userModel.accessToken, + refreshToken: userModel.refreshToken, + refreshTokenMaxAge: userModel.refreshTokenMaxAge, + ); emit(CreateAccountSuccess( message: response['message'] ?? 'Account created successfully', userData: response['data'] ?? {}, diff --git a/lib/home/widgets/pass_card_list.dart b/lib/home/widgets/pass_card_list.dart index d4437ec..ec7d823 100644 --- a/lib/home/widgets/pass_card_list.dart +++ b/lib/home/widgets/pass_card_list.dart @@ -173,19 +173,19 @@ class _ChooseYourPassSectionState extends State { ), ), - const SizedBox(height: 16), - - // 🔒 STATIC TEXT (NOT REMOVED) - const Text( - "• Fusce tincidunt interdum ex, in tincidunt libero porttitor vel.\n" - "• Pellentesque vel nisl posuere, ullamcorper nibh.\n" - "• Fusce tincidunt interdum ex, in tincidunt libero porttitor vel.", - style: TextStyle( - fontSize: 12, - color: Color(0xff5B5F62), - height: 1.5, - ), - ), + // const SizedBox(height: 16), + // + // // 🔒 STATIC TEXT (NOT REMOVED) + // const Text( + // "• Fusce tincidunt interdum ex, in tincidunt libero porttitor vel.\n" + // "• Pellentesque vel nisl posuere, ullamcorper nibh.\n" + // "• Fusce tincidunt interdum ex, in tincidunt libero porttitor vel.", + // style: TextStyle( + // fontSize: 12, + // color: Color(0xff5B5F62), + // height: 1.5, + // ), + // ), const Spacer(), diff --git a/lib/localPreference/local_database.dart b/lib/localPreference/local_database.dart index 2daf24a..c8bd26b 100644 --- a/lib/localPreference/local_database.dart +++ b/lib/localPreference/local_database.dart @@ -41,12 +41,22 @@ class LocalDatabase { /// LOGIN TABLE await db.execute(''' - CREATE TABLE login_state ( - id INTEGER PRIMARY KEY, - is_login INTEGER NOT NULL - ) - '''); + CREATE TABLE login_state ( + id INTEGER PRIMARY KEY, + is_logged_in INTEGER NOT NULL + ) + '''); + + /// USER TOKENS TABLE + await db.execute(''' + CREATE TABLE user_tokens ( + id INTEGER PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + refresh_token_max_age INTEGER NOT NULL + ) +'''); }, ); } -} +} \ No newline at end of file diff --git a/lib/localPreference/local_preference.dart b/lib/localPreference/local_preference.dart index 938b385..79e1abb 100644 --- a/lib/localPreference/local_preference.dart +++ b/lib/localPreference/local_preference.dart @@ -91,36 +91,22 @@ class LocalPreference { await updateOnboardingPage(0); } - static Future initLoginState() async { - final db = await LocalDatabase().database; - - final result = await db.query('login_state'); - - if (result.isEmpty) { - await db.insert( - 'login_state', - { - 'id': 1, - 'is_login': 0, // false by default - }, - ); - } - } - - static Future setIsLogin(bool value) async { + /// Set login state + static Future setLogin(bool value) async { final db = await LocalDatabase().database; await db.insert( 'login_state', { 'id': 1, - 'is_login': value ? 1 : 0, + 'is_logged_in': value ? 1 : 0, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } - static Future isLogin() async { + /// Get login state + static Future getLogin() async { final db = await LocalDatabase().database; final result = await db.query( @@ -130,13 +116,72 @@ class LocalPreference { ); if (result.isNotEmpty) { - return (result.first['is_login'] as int) == 1; + return result.first['is_logged_in'] == 1; } return false; } - static Future logout() async { - await setIsLogin(false); + /// Set user tokens + static Future setTokens({ + required String accessToken, + required String refreshToken, + required int refreshTokenMaxAge, + }) async { + final db = await LocalDatabase().database; + + await db.insert( + 'user_tokens', + { + 'id': 1, + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'refresh_token_max_age': refreshTokenMaxAge, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } -} + /// Get access token + static Future getAccessToken() async { + final db = await LocalDatabase().database; + + final result = await db.query( + 'user_tokens', + where: 'id = ?', + whereArgs: [1], + ); + + if (result.isNotEmpty) { + return result.first['access_token'] as String?; + } + return null; + } + + /// Get refresh token + static Future getRefreshToken() async { + final db = await LocalDatabase().database; + + final result = await db.query( + 'user_tokens', + where: 'id = ?', + whereArgs: [1], + ); + + if (result.isNotEmpty) { + return result.first['refresh_token'] as String?; + } + return null; + } + + /// Clear tokens (for logout) + static Future clearTokens() async { + final db = await LocalDatabase().database; + + await db.delete( + 'user_tokens', + where: 'id = ?', + whereArgs: [1], + ); + } + +} \ No newline at end of file diff --git a/lib/login/bloc/verify/verify_bloc.dart b/lib/login/bloc/verify/verify_bloc.dart index 7385e78..6b473ac 100644 --- a/lib/login/bloc/verify/verify_bloc.dart +++ b/lib/login/bloc/verify/verify_bloc.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:citycards_customer/login/repository/login_repository.dart'; import '../../../create_account/models/create_account_model.dart'; +import '../../../localPreference/local_preference.dart'; import 'verify_event.dart'; import 'verify_state.dart'; @@ -26,6 +27,11 @@ class VerifyOtpBloc extends Bloc { ); final userModel = UserRegisteredModel.fromJson(response); + await LocalPreference.setTokens( + accessToken: userModel.accessToken, + refreshToken: userModel.refreshToken, + refreshTokenMaxAge: userModel.refreshTokenMaxAge, + ); emit(VerifyOtpSuccess(response: userModel)); } catch (e) { emit(VerifyOtpError(errorMessage: e.toString())); diff --git a/lib/login/view/login_email_bottomsheet.dart b/lib/login/view/login_email_bottomsheet.dart index 6015c8b..57cbecf 100644 --- a/lib/login/view/login_email_bottomsheet.dart +++ b/lib/login/view/login_email_bottomsheet.dart @@ -133,34 +133,34 @@ class _LoginEmailBottomsheetState extends State { ); }, ), - SizedBox(height: 20.h), - InkWell( - onTap: () { - Navigator.of(context).pushNamed(RouteConstants.createAcct); - }, - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: "Already have an account?", - style: TextStyle( - color: Colors.black.withOpacity(0.6), - fontSize: 12.sp, - fontWeight: FontWeight.w400, - ), - ), - TextSpan( - text: " Sign in", - style: TextStyle( - color: const Color(0xFFF95F62), - fontSize: 12.sp, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), + // SizedBox(height: 20.h), + // InkWell( + // onTap: () { + // Navigator.of(context).pushNamed(RouteConstants.createAcct); + // }, + // child: Text.rich( + // TextSpan( + // children: [ + // TextSpan( + // text: "Already have an account?", + // style: TextStyle( + // color: Colors.black.withOpacity(0.6), + // fontSize: 12.sp, + // fontWeight: FontWeight.w400, + // ), + // ), + // TextSpan( + // text: " Sign in", + // style: TextStyle( + // color: const Color(0xFFF95F62), + // fontSize: 12.sp, + // fontWeight: FontWeight.w600, + // ), + // ), + // ], + // ), + // ), + // ), SizedBox(height: 15.h), ], ), diff --git a/lib/login/view/verify_otp_bottomsheet.dart b/lib/login/view/verify_otp_bottomsheet.dart index c93f6e4..385896e 100644 --- a/lib/login/view/verify_otp_bottomsheet.dart +++ b/lib/login/view/verify_otp_bottomsheet.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../core/route_constants.dart'; +import '../../localPreference/local_preference.dart'; import '../bloc/verify/verify_bloc.dart'; import '../bloc/verify/verify_event.dart'; import '../bloc/verify/verify_state.dart'; @@ -27,11 +28,12 @@ class _VerifyOtpBottomsheetState extends State { @override Widget build(BuildContext context) { return BlocListener( - listener: (context, state) { + listener: (context, state) async { if (state is VerifyOtpSuccess) { Navigator.pop(context); // Close the bottom sheet if (state.response.userExists) { + await LocalPreference.setLogin(true); // User exists - navigate to home/dashboard // Navigator.of(context).pushReplacementNamed(RouteConstants.home); ScaffoldMessenger.of(context).showSnackBar( @@ -132,31 +134,31 @@ class _VerifyOtpBottomsheetState extends State { debugPrint("OTP entered: $code"); }, ), - SizedBox(height: 20.h), - BlocBuilder( - builder: (context, state) { - final isResending = state is ResendOtpLoading; - return InkWell( - onTap: isResending - ? null - : () { - context.read().add( - ResendOtpEvent(emailAddress: widget.emailAddress), - ); - }, - child: Text( - isResending ? "Resending..." : "Resend OTP", - style: TextStyle( - color: isResending - ? Colors.grey - : const Color(0xFFF95F62), - fontSize: 12.sp, - fontWeight: FontWeight.w600, - ), - ), - ); - }, - ), + // SizedBox(height: 20.h), + // BlocBuilder( + // builder: (context, state) { + // final isResending = state is ResendOtpLoading; + // return InkWell( + // onTap: isResending + // ? null + // : () { + // context.read().add( + // ResendOtpEvent(emailAddress: widget.emailAddress), + // ); + // }, + // child: Text( + // isResending ? "Resending..." : "Resend OTP", + // style: TextStyle( + // color: isResending + // ? Colors.grey + // : const Color(0xFFF95F62), + // fontSize: 12.sp, + // fontWeight: FontWeight.w600, + // ), + // ), + // ); + // }, + // ), SizedBox(height: 22.h), BlocBuilder( builder: (context, state) { @@ -187,34 +189,34 @@ class _VerifyOtpBottomsheetState extends State { ); }, ), - SizedBox(height: 20.h), - InkWell( - onTap: () { - Navigator.of(context).pushNamed(RouteConstants.createAcct); - }, - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: "Already have an account?", - style: TextStyle( - color: Colors.black.withOpacity(0.6), - fontSize: 12.sp, - fontWeight: FontWeight.w400, - ), - ), - TextSpan( - text: " Sign in", - style: TextStyle( - color: const Color(0xFFF95F62), - fontSize: 12.sp, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), + // SizedBox(height: 20.h), + // InkWell( + // onTap: () { + // Navigator.of(context).pushNamed(RouteConstants.createAcct); + // }, + // child: Text.rich( + // TextSpan( + // children: [ + // TextSpan( + // text: "Already have an account?", + // style: TextStyle( + // color: Colors.black.withOpacity(0.6), + // fontSize: 12.sp, + // fontWeight: FontWeight.w400, + // ), + // ), + // TextSpan( + // text: " Sign in", + // style: TextStyle( + // color: const Color(0xFFF95F62), + // fontSize: 12.sp, + // fontWeight: FontWeight.w600, + // ), + // ), + // ], + // ), + // ), + // ), SizedBox(height: 15.h), ], ), diff --git a/lib/networkApiServices/api_urls.dart b/lib/networkApiServices/api_urls.dart index c6339e4..5acaa44 100644 --- a/lib/networkApiServices/api_urls.dart +++ b/lib/networkApiServices/api_urls.dart @@ -2,6 +2,8 @@ class ApiUrls { static const baseUrl = "https://devapi.citycards.betadelivery.com"; + static const refreshToken = "$baseUrl/auth/refresh"; + static const cityList = "$baseUrl/mobile/city_list"; // static const upcomingCityList = "$baseUrl/mobile/upcoming_cities"; static const searchCityList = "$baseUrl/mobile/city-selection"; diff --git a/lib/networkApiServices/auth/models/auth_token_model.dart b/lib/networkApiServices/auth/models/auth_token_model.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/networkApiServices/auth/repository/auth_token_repository.dart b/lib/networkApiServices/auth/repository/auth_token_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/networkApiServices/network_api_services.dart b/lib/networkApiServices/network_api_services.dart index 0bde2c2..6b1eff2 100644 --- a/lib/networkApiServices/network_api_services.dart +++ b/lib/networkApiServices/network_api_services.dart @@ -1,10 +1,15 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import '../localPreference/local_preference.dart'; +import '../networkApiServices/api_urls.dart'; class NetworkApiService { static final NetworkApiService _instance = NetworkApiService._internal(); late Dio _dio; + bool _isRefreshing = false; + final List _retryQueue = []; + factory NetworkApiService() { return _instance; } @@ -20,80 +25,96 @@ class NetworkApiService { }, ), ); - // === RETRY INTERCEPTOR (for timeouts & connection errors) === - _dio.interceptors.add(InterceptorsWrapper( - onError: (DioException err, handler) async { - final options = err.requestOptions; - const maxRetries = 2; // Total attempts = 1 initial + 2 retry - final currentRetry = options.extra['retry'] as int? ?? 0; - final shouldRetry = currentRetry < maxRetries && - (err.type == DioExceptionType.connectionTimeout || - err.type == DioExceptionType.sendTimeout || - err.type == DioExceptionType.receiveTimeout ); - if (shouldRetry) { - if (kDebugMode) { - print('🔁 Retrying request (${currentRetry + 1}) to ${options.uri}'); - } - // Wait before retrying - // await Future.delayed(const Duration(seconds: 1)); - options.extra['retry'] = currentRetry + 1; - // Re-execute the request - try { - final response = await _dio.fetch(options); - return handler.resolve(response); - } on DioException catch (e) { - return handler.reject(e); - } - } - // Not retrying → propagate original error - return handler.reject(err); - }, - )); - // === MAIN INTERCEPTOR (logging, auth, etc.) === + // ================= RETRY INTERCEPTOR ================= _dio.interceptors.add( InterceptorsWrapper( - onRequest: (options, handler) { - // Add token if available (uncomment when needed) - // String? token = "your_token_here"; - // if (token != null) { - // options.headers['Authorization'] = 'Bearer $token'; - // } + onError: (err, handler) async { + final options = err.requestOptions; + const maxRetries = 2; + final currentRetry = options.extra['retry'] as int? ?? 0; - if (kDebugMode) { - print('📤 REQUEST[${options.method}] => URL: ${options.uri}'); + final shouldRetry = currentRetry < maxRetries && + (err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.sendTimeout || + err.type == DioExceptionType.receiveTimeout); + + if (shouldRetry) { + if (kDebugMode) { + print('🔁 Retrying request (${currentRetry + 1}) => ${options.uri}'); + } + + options.extra['retry'] = currentRetry + 1; + + try { + final response = await _dio.fetch(options); + return handler.resolve(response); + } on DioException catch (e) { + return handler.reject(e); + } } - return handler.next(options); - }, - onResponse: (response, handler) { - if (kDebugMode) { - print('📥 RESPONSE[${response.statusCode}] => DATA: ${response.data}'); - } - return handler.next(response); - }, - onError: (error, handler) { - if (kDebugMode) { - print('❌ ERROR[${error.response?.statusCode}] => MESSAGE: ${error.message}'); - } - return handler.next(error); + + return handler.reject(err); }, ), ); - // === DIO LOGGING INTERCEPTOR (debug only) === + + // ================= MAIN INTERCEPTOR ================= + // Use Dio's built-in QueuedInterceptor for better concurrency control + _dio.interceptors.add( + QueuedInterceptorsWrapper( + onRequest: (options, handler) async { + final token = await LocalPreference.getAccessToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + + onError: (error, handler) async { + if (error.response?.statusCode == 401) { + final requestOptions = error.requestOptions; + + try { + // QueuedInterceptor handles concurrency automatically + final refreshed = await _refreshToken(); + + if (refreshed) { + final newToken = await LocalPreference.getAccessToken(); + requestOptions.headers['Authorization'] = 'Bearer $newToken'; + + final response = await _dio.fetch(requestOptions); + return handler.resolve(response); + } else { + await _forceLogout(); + return handler.reject(error); + } + } catch (e) { + await _forceLogout(); + return handler.reject(error); + } + } + + handler.next(error); + }, + ), + ); + + // ================= LOGGING INTERCEPTOR ================= if (kDebugMode) { _dio.interceptors.add( LogInterceptor( request: true, requestHeader: true, requestBody: true, - responseHeader: false, responseBody: true, error: true, ), ); } } - // GET API Request + + // ================= GET ================= Future getApi({ required String url, Map? queryParameters, @@ -101,18 +122,18 @@ class NetworkApiService { CancelToken? cancelToken, }) async { try { - final response = await _dio.get( + return await _dio.get( url, queryParameters: queryParameters, options: options, cancelToken: cancelToken, ); - return response; } on DioException catch (e) { throw _handleError(e); } } - // POST API Request + + // ================= POST ================= Future postApi({ required String url, dynamic data, @@ -122,7 +143,7 @@ class NetworkApiService { ProgressCallback? onSendProgress, }) async { try { - final response = await _dio.post( + return await _dio.post( url, data: data, queryParameters: queryParameters, @@ -130,45 +151,97 @@ class NetworkApiService { cancelToken: cancelToken, onSendProgress: onSendProgress, ); - return response; } on DioException catch (e) { throw _handleError(e); } } - // Error Handler + + // ================= PUT (NEW) ================= + Future putApi({ + required String url, + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onSendProgress, + }) async { + try { + return await _dio.put( + url, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // ================= REFRESH TOKEN ================= + Future _refreshToken() async { + try { + final refreshToken = await LocalPreference.getRefreshToken(); + if (refreshToken == null) return false; + + final response = await _dio.post( + ApiUrls.refreshToken, + data: { + "refreshToken": refreshToken, + }, + options: Options( + headers: { + 'Authorization': null, + }, + ), + ); + + await LocalPreference.setTokens( + accessToken: response.data['accessToken'], + refreshToken: response.data['refreshToken'], + refreshTokenMaxAge: response.data['refreshTokenMaxAge'], + ); + + return true; + } catch (_) { + return false; + } + } + + // ================= LOGOUT ================= + Future _forceLogout() async { + await LocalPreference.clearTokens(); + await LocalPreference.setLogin(false); + await LocalPreference.resetOnboarding(); + // TODO: navigate to login screen + } + + // ================= ERROR HANDLER ================= String _handleError(DioException error) { - String errorDescription = ""; switch (error.type) { case DioExceptionType.connectionTimeout: - errorDescription = "Connection timeout. Please try again."; - break; + return "Connection timeout. Please try again."; case DioExceptionType.sendTimeout: - errorDescription = "Send timeout. Please try again."; - break; + return "Send timeout. Please try again."; case DioExceptionType.receiveTimeout: - errorDescription = "Receive timeout. Please try again."; - break; + return "Receive timeout. Please try again."; case DioExceptionType.badCertificate: - errorDescription = "Bad certificate."; - break; + return "Bad certificate."; case DioExceptionType.badResponse: - errorDescription = error.response?.data['message'] ?? - "Received invalid status code: ${error.response?.statusCode}"; - break; + return error.response?.data['message'] ?? + "Invalid status code: ${error.response?.statusCode}"; case DioExceptionType.cancel: - errorDescription = "Request was cancelled."; - break; + return "Request was cancelled."; case DioExceptionType.connectionError: - errorDescription = "No internet connection."; - break; + return "No internet connection."; case DioExceptionType.unknown: - errorDescription = "Something went wrong. Please try again."; - break; + return "Something went wrong. Please try again."; } - return errorDescription; } - // Update headers (e.g., add auth token) + + // ================= UPDATE HEADERS ================= void updateHeaders(Map headers) { _dio.options.headers.addAll(headers); } -} \ No newline at end of file +}