This commit is contained in:
Phuoc Nguyen
2025-11-10 14:21:27 +07:00
parent 2a71c65577
commit 36bdf6613b
33 changed files with 2206 additions and 252 deletions

View File

@@ -10,23 +10,11 @@ class ApiConstants {
// Base URLs
// ============================================================================
/// Base URL for development environment
static const String devBaseUrl = 'https://dev-api.worker.example.com';
/// Base URL for all APIs (Frappe/ERPNext)
static const String baseUrl = 'https://land.dbiz.com';
/// Base URL for staging environment
static const String stagingBaseUrl = 'https://staging-api.worker.example.com';
/// Base URL for production environment
static const String prodBaseUrl = 'https://api.worker.example.com';
/// Current base URL (should be configured based on build flavor)
static const String baseUrl = devBaseUrl; // TODO: Configure with flavors
/// API version prefix
static const String apiVersion = '/v1';
/// Full API base URL with version
static String get apiBaseUrl => '$baseUrl$apiVersion';
/// Full API base URL (no version prefix, using Frappe endpoints)
static String get apiBaseUrl => baseUrl;
// ============================================================================
// Timeout Configurations
@@ -347,6 +335,35 @@ class ApiConstants {
/// POST /promotions/{promotionId}/claim
static const String claimPromotion = '/promotions';
// ============================================================================
// Frappe/ERPNext API Endpoints
// ============================================================================
/// Frappe API method prefix
static const String frappeApiMethod = '/api/method';
/// Get Frappe session (public API, no auth required)
/// POST /api/method/dbiz_common.dbiz_common.api.auth.get_session
/// Returns: { "message": { "data": { "sid": "...", "csrf_token": "..." } }, "full_name": "..." }
static const String frappeGetSession = '/dbiz_common.dbiz_common.api.auth.get_session';
/// Login with phone (requires session sid and csrf_token)
/// POST /api/method/building_material.building_material.api.auth.login
/// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null }
/// Returns: { "message": { "data": { "sid": "...", "csrf_token": "..." } }, "full_name": "..." }
static const String frappeLogin = '/building_material.building_material.api.auth.login';
/// Frappe client get_list (requires sid and csrf_token)
/// POST /api/method/frappe.client.get_list
static const String frappeGetList = '/frappe.client.get_list';
/// Register user (requires session sid and csrf_token)
/// POST /api/method/building_material.building_material.api.user.register
static const String frappeRegister = '/building_material.building_material.api.user.register';
/// Frappe public API user ID
static const String frappePublicUserId = 'public_api@dbiz.com';
// ============================================================================
// Notification Endpoints
// ============================================================================
@@ -380,8 +397,8 @@ class ApiConstants {
///
/// Example:
/// ```dart
/// final url = ApiConstants.buildUrl('/products', {'page': '1', 'limit': '20'});
/// // Returns: https://api.worker.example.com/v1/products?page=1&limit=20
/// final url = ApiConstants.buildUrl('/api/method/frappe.client.get_list', {'doctype': 'Item'});
/// // Returns: https://land.dbiz.com/api/method/frappe.client.get_list?doctype=Item
/// ```
static String buildUrl(String endpoint, [Map<String, String>? queryParams]) {
final uri = Uri.parse('$apiBaseUrl$endpoint');
@@ -395,8 +412,8 @@ class ApiConstants {
///
/// Example:
/// ```dart
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
/// // Returns: https://api.worker.example.com/v1/products/123
/// final url = ApiConstants.buildUrlWithParams('/api/resource/Item/{id}', {'id': '123'});
/// // Returns: https://land.dbiz.com/api/resource/Item/123
/// ```
static String buildUrlWithParams(
String endpoint,

View File

@@ -0,0 +1,95 @@
/// Frappe Session Model
///
/// Data model for Frappe API session response.
/// Used for public API authentication to access blog content.
library;
import 'package:json_annotation/json_annotation.dart';
part 'frappe_session_model.g.dart';
/// Frappe Session Data
///
/// Contains session credentials from Frappe API.
@JsonSerializable()
class FrappeSessionData {
/// Session ID
final String sid;
/// CSRF Token
@JsonKey(name: 'csrf_token')
final String csrfToken;
const FrappeSessionData({
required this.sid,
required this.csrfToken,
});
factory FrappeSessionData.fromJson(Map<String, dynamic> json) =>
_$FrappeSessionDataFromJson(json);
Map<String, dynamic> toJson() => _$FrappeSessionDataToJson(this);
}
/// Frappe Session Message Wrapper
@JsonSerializable()
class FrappeSessionMessage {
/// Session data
final FrappeSessionData data;
const FrappeSessionMessage({
required this.data,
});
factory FrappeSessionMessage.fromJson(Map<String, dynamic> json) =>
_$FrappeSessionMessageFromJson(json);
Map<String, dynamic> toJson() => _$FrappeSessionMessageToJson(this);
}
/// Frappe Session Response
///
/// API response from get_session endpoint.
/// Example:
/// ```json
/// {
/// "message": {
/// "data": {
/// "sid": "edb6059ecf147f268176cd4aff8ca034a75ebb8ff23464f9913c9537",
/// "csrf_token": "d0077178c349f69bc1456401d9a3d90ef0f7b9df3e08cfd26794a53f"
/// }
/// },
/// "home_page": "/app",
/// "full_name": "PublicAPI"
/// }
/// ```
@JsonSerializable()
class FrappeSessionResponse {
/// Message containing session data
final FrappeSessionMessage message;
/// Home page path
@JsonKey(name: 'home_page')
final String homePage;
/// Full name of the API user
@JsonKey(name: 'full_name')
final String fullName;
const FrappeSessionResponse({
required this.message,
required this.homePage,
required this.fullName,
});
factory FrappeSessionResponse.fromJson(Map<String, dynamic> json) =>
_$FrappeSessionResponseFromJson(json);
Map<String, dynamic> toJson() => _$FrappeSessionResponseToJson(this);
/// Get session ID
String get sid => message.data.sid;
/// Get CSRF token
String get csrfToken => message.data.csrfToken;
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'frappe_session_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FrappeSessionData _$FrappeSessionDataFromJson(Map<String, dynamic> json) =>
$checkedCreate('FrappeSessionData', json, ($checkedConvert) {
final val = FrappeSessionData(
sid: $checkedConvert('sid', (v) => v as String),
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
);
return val;
}, fieldKeyMap: const {'csrfToken': 'csrf_token'});
Map<String, dynamic> _$FrappeSessionDataToJson(FrappeSessionData instance) =>
<String, dynamic>{'sid': instance.sid, 'csrf_token': instance.csrfToken};
FrappeSessionMessage _$FrappeSessionMessageFromJson(
Map<String, dynamic> json,
) => $checkedCreate('FrappeSessionMessage', json, ($checkedConvert) {
final val = FrappeSessionMessage(
data: $checkedConvert(
'data',
(v) => FrappeSessionData.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$FrappeSessionMessageToJson(
FrappeSessionMessage instance,
) => <String, dynamic>{'data': instance.data.toJson()};
FrappeSessionResponse _$FrappeSessionResponseFromJson(
Map<String, dynamic> json,
) => $checkedCreate(
'FrappeSessionResponse',
json,
($checkedConvert) {
final val = FrappeSessionResponse(
message: $checkedConvert(
'message',
(v) => FrappeSessionMessage.fromJson(v as Map<String, dynamic>),
),
homePage: $checkedConvert('home_page', (v) => v as String),
fullName: $checkedConvert('full_name', (v) => v as String),
);
return val;
},
fieldKeyMap: const {'homePage': 'home_page', 'fullName': 'full_name'},
);
Map<String, dynamic> _$FrappeSessionResponseToJson(
FrappeSessionResponse instance,
) => <String, dynamic>{
'message': instance.message.toJson(),
'home_page': instance.homePage,
'full_name': instance.fullName,
};

View File

@@ -15,7 +15,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
part 'api_interceptor.g.dart';
@@ -35,15 +34,16 @@ class AuthStorageKeys {
// Auth Interceptor
// ============================================================================
/// Interceptor for adding ERPNext session tokens to requests
/// Interceptor for adding Frappe/ERPNext session tokens to requests
///
/// Adds SID (Session ID) and CSRF token from Hive storage to request headers.
/// Adds Cookie (with SID) and X-Frappe-CSRF-Token from FlutterSecureStorage.
/// Uses the centralized FrappeAuthService for session management.
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource);
AuthInterceptor(this._prefs, this._dio, this._secureStorage);
final SharedPreferences _prefs;
final Dio _dio;
final AuthLocalDataSource _authLocalDataSource;
final FlutterSecureStorage _secureStorage;
@override
void onRequest(
@@ -52,13 +52,24 @@ class AuthInterceptor extends Interceptor {
) async {
// Check if this endpoint requires authentication
if (_requiresAuth(options.path)) {
// Get session data from secure storage (async)
final sid = await _authLocalDataSource.getSid();
final csrfToken = await _authLocalDataSource.getCsrfToken();
// Get session data from secure storage
final sid = await _secureStorage.read(key: 'frappe_sid');
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
final fullName = await _secureStorage.read(key: 'frappe_full_name');
final userId = await _secureStorage.read(key: 'frappe_user_id');
if (sid != null && csrfToken != null) {
// Add ERPNext session headers
options.headers['Cookie'] = 'sid=$sid';
// Build cookie header with all required fields
final cookieHeader = [
'sid=$sid',
'full_name=${fullName ?? "User"}',
'system_user=no',
'user_id=${userId != null ? Uri.encodeComponent(userId) : ApiConstants.frappePublicUserId}',
'user_image=',
].join('; ');
// Add Frappe session headers
options.headers['Cookie'] = cookieHeader;
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
}
@@ -276,9 +287,20 @@ class LoggingInterceptor extends Interceptor {
name: 'HTTP Response',
);
developer.log(
'Data: ${_truncateData(response.data, 500)}',
'Headers: ${response.headers.map}',
name: 'HTTP Response',
);
// Log full response data (not truncated for debugging)
final responseData = response.data;
if (responseData != null) {
if (responseData is String) {
developer.log('║ Response: $responseData', name: 'HTTP Response');
} else {
developer.log('║ Response: ${_truncateData(responseData, 2000)}', name: 'HTTP Response');
}
}
developer.log(
'╚══════════════════════════════════════════════════════════════',
name: 'HTTP Response',
@@ -534,14 +556,13 @@ Future<SharedPreferences> sharedPreferences(Ref ref) async {
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
final prefs = await ref.watch(sharedPreferencesProvider.future);
// Create AuthLocalDataSource with FlutterSecureStorage
// Use FlutterSecureStorage for Frappe session
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
final authLocalDataSource = AuthLocalDataSource(secureStorage);
return AuthInterceptor(prefs, dio, authLocalDataSource);
return AuthInterceptor(prefs, dio, secureStorage);
}
/// Provider for LoggingInterceptor

View File

@@ -114,7 +114,7 @@ final class AuthInterceptorProvider
}
}
String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71';
String _$authInterceptorHash() => r'1221aab024b7c4d9fd393f7681f3ba094286a375';
/// Provider for AuthInterceptor

View File

@@ -8,6 +8,7 @@
/// - Retry logic
library;
import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart';
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
@@ -382,19 +383,21 @@ Future<Dio> dio(Ref ref) async {
},
)
// Add interceptors in order
// 1. Logging interceptor (first to log everything)
// 1. Curl interceptor (first to log cURL commands)
..interceptors.add(CurlLoggerDioInterceptor())
// 2. Logging interceptor
..interceptors.add(ref.watch(loggingInterceptorProvider))
// 2. Auth interceptor (add tokens to requests)
// 3. Auth interceptor (add tokens to requests)
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
// 3. Cache interceptor
// 4. Cache interceptor
..interceptors.add(
DioCacheInterceptor(
options: await ref.watch(cacheOptionsProvider.future),
),
)
// 4. Retry interceptor
// 5. Retry interceptor
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
// 5. Error transformer (last to transform all errors)
// 6. Error transformer (last to transform all errors)
..interceptors.add(ref.watch(errorTransformerInterceptorProvider));
return dio;

View File

@@ -131,7 +131,7 @@ final class DioProvider
}
}
String _$dioHash() => r'f15495e99d11744c245e2be892657748aeeb8ae7';
String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c';
/// Provider for DioClient

View File

@@ -5,9 +5,11 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
import 'package:worker/features/auth/presentation/providers/auth_provider.dart';
import 'package:worker/features/account/presentation/pages/change_password_page.dart';
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
@@ -39,20 +41,41 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_crea
import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart';
import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart';
/// App Router
/// Router Provider
///
/// Handles navigation throughout the app using declarative routing.
/// Features:
/// - Named routes for type-safe navigation
/// - Authentication guards (TODO: implement when auth is ready)
/// - Deep linking support
/// - Transition animations
class AppRouter {
/// Router configuration
static final GoRouter router = GoRouter(
/// Provides GoRouter instance with auth state management
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
// Initial route
initialLocation: RouteNames.login,
// Redirect based on auth state
redirect: (context, state) {
final isLoggedIn = authState.value != null;
final isOnLoginPage = state.matchedLocation == RouteNames.login;
final isOnRegisterPage = state.matchedLocation == RouteNames.register;
final isOnBusinessUnitPage =
state.matchedLocation == RouteNames.businessUnitSelection;
final isOnOtpPage = state.matchedLocation == RouteNames.otpVerification;
final isOnAuthPage =
isOnLoginPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage;
// If not logged in and not on auth pages, redirect to login
if (!isLoggedIn && !isOnAuthPage) {
return RouteNames.login;
}
// If logged in and on login page, redirect to home
if (isLoggedIn && isOnLoginPage) {
return RouteNames.home;
}
// No redirect needed
return null;
},
// Route definitions
routes: [
// Authentication Routes
@@ -384,26 +407,10 @@ class AppRouter {
),
),
// Redirect logic for authentication (TODO: implement when auth is ready)
// redirect: (context, state) {
// final isLoggedIn = false; // TODO: Get from auth provider
// final isOnLoginPage = state.matchedLocation == RouteNames.login;
//
// if (!isLoggedIn && !isOnLoginPage) {
// return RouteNames.login;
// }
//
// if (isLoggedIn && isOnLoginPage) {
// return RouteNames.home;
// }
//
// return null;
// },
// Debug logging (disable in production)
debugLogDiagnostics: true,
);
}
});
/// Route Names
///

View File

@@ -0,0 +1,28 @@
/// Frappe Auth Provider
///
/// Riverpod provider for FrappeAuthService.
library;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/services/frappe_auth_service.dart';
part 'frappe_auth_provider.g.dart';
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
@riverpod
FrappeAuthService frappeAuthService(Ref ref) {
// Create a separate Dio instance for Frappe auth
// (not using the main dio client to avoid circular dependencies)
final dio = Dio();
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
return FrappeAuthService(dio, secureStorage);
}

View File

@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'frappe_auth_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
@ProviderFor(frappeAuthService)
const frappeAuthServiceProvider = FrappeAuthServiceProvider._();
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
final class FrappeAuthServiceProvider
extends
$FunctionalProvider<
FrappeAuthService,
FrappeAuthService,
FrappeAuthService
>
with $Provider<FrappeAuthService> {
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
const FrappeAuthServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'frappeAuthServiceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$frappeAuthServiceHash();
@$internal
@override
$ProviderElement<FrappeAuthService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
FrappeAuthService create(Ref ref) {
return frappeAuthService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FrappeAuthService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FrappeAuthService>(value),
);
}
}
String _$frappeAuthServiceHash() => r'73112c920895302df011517e81c97eef2b5df5ac';

View File

@@ -0,0 +1,245 @@
/// Frappe Authentication Service
///
/// Handles Frappe/ERPNext session management (sid and csrf_token).
/// Provides methods to get session, login, and manage session storage.
library;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/models/frappe_session_model.dart';
/// Frappe Auth Service
///
/// Manages Frappe session lifecycle:
/// 1. Get initial session (public API)
/// 2. Login with phone number
/// 3. Store sid and csrf_token in secure storage
/// 4. Provide session data for API requests
class FrappeAuthService {
FrappeAuthService(this._dio, this._secureStorage);
final Dio _dio;
final FlutterSecureStorage _secureStorage;
/// Storage keys for Frappe session
static const String _keyFrappeSid = 'frappe_sid';
static const String _keyFrappeCsrfToken = 'frappe_csrf_token';
static const String _keyFrappeFullName = 'frappe_full_name';
static const String _keyFrappeUserId = 'frappe_user_id';
/// Get Frappe session from API
///
/// This endpoint doesn't require authentication - it's public.
/// Returns initial session for subsequent API calls.
///
/// API: POST /api/method/dbiz_common.dbiz_common.api.auth.get_session
Future<FrappeSessionResponse> getSession() async {
try {
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetSession}';
final response = await _dio.post<Map<String, dynamic>>(
url,
data: '', // Empty data as per docs
);
if (response.data == null) {
throw Exception('Empty response from Frappe session API');
}
final sessionResponse = FrappeSessionResponse.fromJson(response.data!);
// Store session in secure storage
await _storeSession(
sid: sessionResponse.sid,
csrfToken: sessionResponse.csrfToken,
fullName: sessionResponse.fullName,
userId: ApiConstants.frappePublicUserId,
);
return sessionResponse;
} on DioException catch (e) {
throw Exception('Failed to get Frappe session: ${e.message}');
} catch (e) {
throw Exception('Unexpected error getting Frappe session: $e');
}
}
/// Login with phone number
///
/// Requires existing session (sid and csrf_token).
/// Returns new session with user's authentication.
///
/// API: POST /api/method/building_material.building_material.api.auth.login
/// Headers: Cookie (with sid), X-Frappe-Csrf-Token
/// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null }
///
/// Note: Password not used yet, but field reserved for future use
Future<FrappeSessionResponse> login(String phone, {String? password}) async {
try {
// Ensure we have a session first
final session = await getStoredSession();
if (session == null) {
await getSession();
final newSession = await getStoredSession();
if (newSession == null) {
throw Exception('Failed to initialize session');
}
}
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
// Build cookie header
final storedSession = await getStoredSession();
final cookieHeader = _buildCookieHeader(
sid: storedSession!['sid']!,
fullName: storedSession['fullName']!,
userId: storedSession['userId']!,
);
final response = await _dio.post<Map<String, dynamic>>(
url,
data: {
'username': phone,
'googleid': null,
'facebookid': null,
'zaloid': null,
// Password field reserved for future use
// 'password': password,
},
options: Options(
headers: {
'Cookie': cookieHeader,
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
'Content-Type': 'application/json',
},
),
);
if (response.data == null) {
throw Exception('Empty response from login API');
}
final loginResponse = FrappeSessionResponse.fromJson(response.data!);
// Store new session after login
await _storeSession(
sid: loginResponse.sid,
csrfToken: loginResponse.csrfToken,
fullName: loginResponse.fullName,
userId: phone, // Use phone as userId after login
);
return loginResponse;
} on DioException catch (e) {
throw Exception('Login failed: ${e.message}');
} catch (e) {
throw Exception('Unexpected error during login: $e');
}
}
/// Store session in secure storage
Future<void> _storeSession({
required String sid,
required String csrfToken,
required String fullName,
required String userId,
}) async {
await Future.wait([
_secureStorage.write(key: _keyFrappeSid, value: sid),
_secureStorage.write(key: _keyFrappeCsrfToken, value: csrfToken),
_secureStorage.write(key: _keyFrappeFullName, value: fullName),
_secureStorage.write(key: _keyFrappeUserId, value: userId),
]);
}
/// Get stored session from secure storage
Future<Map<String, String>?> getStoredSession() async {
final results = await Future.wait([
_secureStorage.read(key: _keyFrappeSid),
_secureStorage.read(key: _keyFrappeCsrfToken),
_secureStorage.read(key: _keyFrappeFullName),
_secureStorage.read(key: _keyFrappeUserId),
]);
final sid = results[0];
final csrfToken = results[1];
final fullName = results[2];
final userId = results[3];
// Return null if session is incomplete
if (sid == null || csrfToken == null) {
return null;
}
return {
'sid': sid,
'csrfToken': csrfToken,
'fullName': fullName ?? 'User',
'userId': userId ?? ApiConstants.frappePublicUserId,
};
}
/// Ensure valid session exists, fetch new one if needed
Future<Map<String, String>> ensureSession() async {
var session = await getStoredSession();
if (session == null) {
// No session in storage, get a new one
await getSession();
session = await getStoredSession();
if (session == null) {
throw Exception('Failed to get session');
}
}
return session;
}
/// Check if session exists in storage
Future<bool> hasSession() async {
final sid = await _secureStorage.read(key: _keyFrappeSid);
final csrfToken = await _secureStorage.read(key: _keyFrappeCsrfToken);
return sid != null && csrfToken != null;
}
/// Build cookie header string
String _buildCookieHeader({
required String sid,
required String fullName,
required String userId,
}) {
return [
'sid=$sid',
'full_name=$fullName',
'system_user=no',
'user_id=${Uri.encodeComponent(userId)}',
'user_image=',
].join('; ');
}
/// Get headers for Frappe API requests
Future<Map<String, String>> getHeaders() async {
final session = await ensureSession();
return {
'Cookie': _buildCookieHeader(
sid: session['sid']!,
fullName: session['fullName']!,
userId: session['userId']!,
),
'X-Frappe-Csrf-Token': session['csrfToken']!,
'Content-Type': 'application/json',
};
}
/// Clear stored session
Future<void> clearSession() async {
await Future.wait([
_secureStorage.delete(key: _keyFrappeSid),
_secureStorage.delete(key: _keyFrappeCsrfToken),
_secureStorage.delete(key: _keyFrappeFullName),
_secureStorage.delete(key: _keyFrappeUserId),
]);
}
}