feat: add entities for staff personal info, reports, shifts, and user sessions
- Implemented StaffPersonalInfo entity for staff profile data. - Created ReportSummary entity for summarizing report metrics. - Added SpendReport and SpendDataPoint entities for spend reporting. - Introduced AssignedShift, CancelledShift, CompletedShift, OpenShift, PendingAssignment, ShiftDetail, TodayShift entities for shift management. - Developed ClientSession and StaffSession entities for user session management.
This commit is contained in:
@@ -16,8 +16,13 @@ export 'src/routing/routing.dart';
|
||||
export 'src/services/api_service/api_service.dart';
|
||||
export 'src/services/api_service/dio_client.dart';
|
||||
|
||||
// API Mixins
|
||||
export 'src/services/api_service/mixins/api_error_handler.dart';
|
||||
export 'src/services/api_service/mixins/session_handler_mixin.dart';
|
||||
|
||||
// Core API Services
|
||||
export 'src/services/api_service/core_api_services/core_api_endpoints.dart';
|
||||
export 'src/services/api_service/core_api_services/v2_api_endpoints.dart';
|
||||
export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart';
|
||||
export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart';
|
||||
export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart';
|
||||
|
||||
@@ -13,4 +13,10 @@ class AppConfig {
|
||||
static const String coreApiBaseUrl = String.fromEnvironment(
|
||||
'CORE_API_BASE_URL',
|
||||
);
|
||||
|
||||
/// The base URL for the V2 Unified API gateway.
|
||||
static const String v2ApiBaseUrl = String.fromEnvironment(
|
||||
'V2_API_BASE_URL',
|
||||
defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,25 @@ class ApiService implements BaseApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a DELETE request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> delete(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.delete<dynamic>(
|
||||
endpoint,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts [ApiResponse] from a successful [Response].
|
||||
ApiResponse _handleResponse(Response<dynamic> response) {
|
||||
return ApiResponse(
|
||||
@@ -96,6 +115,9 @@ class ApiService implements BaseApiService {
|
||||
}
|
||||
|
||||
/// Extracts [ApiResponse] from a [DioException].
|
||||
///
|
||||
/// Supports both legacy error format and V2 API error envelope
|
||||
/// (`{ code, message, details, requestId }`).
|
||||
ApiResponse _handleError(DioException e) {
|
||||
if (e.response?.data is Map<String, dynamic>) {
|
||||
final Map<String, dynamic> body =
|
||||
@@ -106,7 +128,7 @@ class ApiService implements BaseApiService {
|
||||
e.response?.statusCode?.toString() ??
|
||||
'error',
|
||||
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
|
||||
data: body['data'],
|
||||
data: body['data'] ?? body['details'],
|
||||
errors: _parseErrors(body['errors']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
/// Constants for V2 Unified API endpoints.
|
||||
///
|
||||
/// All mobile read/write operations go through the V2 gateway which proxies
|
||||
/// to the internal Query API and Command API services.
|
||||
class V2ApiEndpoints {
|
||||
V2ApiEndpoints._();
|
||||
|
||||
/// The base URL for the V2 Unified API gateway.
|
||||
static const String baseUrl = AppConfig.v2ApiBaseUrl;
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Client email/password sign-in.
|
||||
static const String clientSignIn = '$baseUrl/auth/client/sign-in';
|
||||
|
||||
/// Client business registration.
|
||||
static const String clientSignUp = '$baseUrl/auth/client/sign-up';
|
||||
|
||||
/// Client sign-out.
|
||||
static const String clientSignOut = '$baseUrl/auth/client/sign-out';
|
||||
|
||||
/// Start staff phone verification (SMS).
|
||||
static const String staffPhoneStart = '$baseUrl/auth/staff/phone/start';
|
||||
|
||||
/// Complete staff phone verification.
|
||||
static const String staffPhoneVerify = '$baseUrl/auth/staff/phone/verify';
|
||||
|
||||
/// Generic sign-out.
|
||||
static const String signOut = '$baseUrl/auth/sign-out';
|
||||
|
||||
/// Get current session data.
|
||||
static const String session = '$baseUrl/auth/session';
|
||||
|
||||
// ── Staff Read ────────────────────────────────────────────────────────
|
||||
|
||||
/// Staff session data.
|
||||
static const String staffSession = '$baseUrl/staff/session';
|
||||
|
||||
/// Staff dashboard overview.
|
||||
static const String staffDashboard = '$baseUrl/staff/dashboard';
|
||||
|
||||
/// Staff profile completion status.
|
||||
static const String staffProfileCompletion =
|
||||
'$baseUrl/staff/profile-completion';
|
||||
|
||||
/// Staff availability schedule.
|
||||
static const String staffAvailability = '$baseUrl/staff/availability';
|
||||
|
||||
/// Today's shifts for clock-in.
|
||||
static const String staffClockInShiftsToday =
|
||||
'$baseUrl/staff/clock-in/shifts/today';
|
||||
|
||||
/// Current clock-in status.
|
||||
static const String staffClockInStatus = '$baseUrl/staff/clock-in/status';
|
||||
|
||||
/// Payments summary.
|
||||
static const String staffPaymentsSummary = '$baseUrl/staff/payments/summary';
|
||||
|
||||
/// Payments history.
|
||||
static const String staffPaymentsHistory = '$baseUrl/staff/payments/history';
|
||||
|
||||
/// Payments chart data.
|
||||
static const String staffPaymentsChart = '$baseUrl/staff/payments/chart';
|
||||
|
||||
/// Assigned shifts.
|
||||
static const String staffShiftsAssigned = '$baseUrl/staff/shifts/assigned';
|
||||
|
||||
/// Open shifts available to apply.
|
||||
static const String staffShiftsOpen = '$baseUrl/staff/shifts/open';
|
||||
|
||||
/// Pending shift assignments.
|
||||
static const String staffShiftsPending = '$baseUrl/staff/shifts/pending';
|
||||
|
||||
/// Cancelled shifts.
|
||||
static const String staffShiftsCancelled = '$baseUrl/staff/shifts/cancelled';
|
||||
|
||||
/// Completed shifts.
|
||||
static const String staffShiftsCompleted = '$baseUrl/staff/shifts/completed';
|
||||
|
||||
/// Shift details by ID.
|
||||
static String staffShiftDetails(String shiftId) =>
|
||||
'$baseUrl/staff/shifts/$shiftId';
|
||||
|
||||
/// Staff profile sections overview.
|
||||
static const String staffProfileSections = '$baseUrl/staff/profile/sections';
|
||||
|
||||
/// Personal info.
|
||||
static const String staffPersonalInfo = '$baseUrl/staff/profile/personal-info';
|
||||
|
||||
/// Industries/experience.
|
||||
static const String staffIndustries = '$baseUrl/staff/profile/industries';
|
||||
|
||||
/// Skills.
|
||||
static const String staffSkills = '$baseUrl/staff/profile/skills';
|
||||
|
||||
/// Documents.
|
||||
static const String staffDocuments = '$baseUrl/staff/profile/documents';
|
||||
|
||||
/// Attire items.
|
||||
static const String staffAttire = '$baseUrl/staff/profile/attire';
|
||||
|
||||
/// Tax forms.
|
||||
static const String staffTaxForms = '$baseUrl/staff/profile/tax-forms';
|
||||
|
||||
/// Emergency contacts.
|
||||
static const String staffEmergencyContacts =
|
||||
'$baseUrl/staff/profile/emergency-contacts';
|
||||
|
||||
/// Certificates.
|
||||
static const String staffCertificates = '$baseUrl/staff/profile/certificates';
|
||||
|
||||
/// Bank accounts.
|
||||
static const String staffBankAccounts = '$baseUrl/staff/profile/bank-accounts';
|
||||
|
||||
/// Benefits.
|
||||
static const String staffBenefits = '$baseUrl/staff/profile/benefits';
|
||||
|
||||
/// Time card.
|
||||
static const String staffTimeCard = '$baseUrl/staff/profile/time-card';
|
||||
|
||||
/// Privacy settings.
|
||||
static const String staffPrivacy = '$baseUrl/staff/profile/privacy';
|
||||
|
||||
/// FAQs.
|
||||
static const String staffFaqs = '$baseUrl/staff/faqs';
|
||||
|
||||
/// FAQs search.
|
||||
static const String staffFaqsSearch = '$baseUrl/staff/faqs/search';
|
||||
|
||||
// ── Staff Write ───────────────────────────────────────────────────────
|
||||
|
||||
/// Staff profile setup.
|
||||
static const String staffProfileSetup = '$baseUrl/staff/profile/setup';
|
||||
|
||||
/// Clock in.
|
||||
static const String staffClockIn = '$baseUrl/staff/clock-in';
|
||||
|
||||
/// Clock out.
|
||||
static const String staffClockOut = '$baseUrl/staff/clock-out';
|
||||
|
||||
/// Quick-set availability.
|
||||
static const String staffAvailabilityQuickSet =
|
||||
'$baseUrl/staff/availability/quick-set';
|
||||
|
||||
/// Apply for a shift.
|
||||
static String staffShiftApply(String shiftId) =>
|
||||
'$baseUrl/staff/shifts/$shiftId/apply';
|
||||
|
||||
/// Accept a shift.
|
||||
static String staffShiftAccept(String shiftId) =>
|
||||
'$baseUrl/staff/shifts/$shiftId/accept';
|
||||
|
||||
/// Decline a shift.
|
||||
static String staffShiftDecline(String shiftId) =>
|
||||
'$baseUrl/staff/shifts/$shiftId/decline';
|
||||
|
||||
/// Request a shift swap.
|
||||
static String staffShiftRequestSwap(String shiftId) =>
|
||||
'$baseUrl/staff/shifts/$shiftId/request-swap';
|
||||
|
||||
/// Update emergency contact by ID.
|
||||
static String staffEmergencyContactUpdate(String contactId) =>
|
||||
'$baseUrl/staff/profile/emergency-contacts/$contactId';
|
||||
|
||||
/// Update tax form by type.
|
||||
static String staffTaxFormUpdate(String formType) =>
|
||||
'$baseUrl/staff/profile/tax-forms/$formType';
|
||||
|
||||
/// Submit tax form by type.
|
||||
static String staffTaxFormSubmit(String formType) =>
|
||||
'$baseUrl/staff/profile/tax-forms/$formType/submit';
|
||||
|
||||
/// Upload staff profile photo.
|
||||
static const String staffProfilePhoto = '$baseUrl/staff/profile/photo';
|
||||
|
||||
/// Upload document by ID.
|
||||
static String staffDocumentUpload(String documentId) =>
|
||||
'$baseUrl/staff/profile/documents/$documentId/upload';
|
||||
|
||||
/// Upload attire by ID.
|
||||
static String staffAttireUpload(String documentId) =>
|
||||
'$baseUrl/staff/profile/attire/$documentId/upload';
|
||||
|
||||
/// Delete certificate by ID.
|
||||
static String staffCertificateDelete(String certificateId) =>
|
||||
'$baseUrl/staff/profile/certificates/$certificateId';
|
||||
|
||||
// ── Client Read ───────────────────────────────────────────────────────
|
||||
|
||||
/// Client session data.
|
||||
static const String clientSession = '$baseUrl/client/session';
|
||||
|
||||
/// Client dashboard.
|
||||
static const String clientDashboard = '$baseUrl/client/dashboard';
|
||||
|
||||
/// Client reorders.
|
||||
static const String clientReorders = '$baseUrl/client/reorders';
|
||||
|
||||
/// Billing accounts.
|
||||
static const String clientBillingAccounts = '$baseUrl/client/billing/accounts';
|
||||
|
||||
/// Pending invoices.
|
||||
static const String clientBillingInvoicesPending =
|
||||
'$baseUrl/client/billing/invoices/pending';
|
||||
|
||||
/// Invoice history.
|
||||
static const String clientBillingInvoicesHistory =
|
||||
'$baseUrl/client/billing/invoices/history';
|
||||
|
||||
/// Current bill.
|
||||
static const String clientBillingCurrentBill =
|
||||
'$baseUrl/client/billing/current-bill';
|
||||
|
||||
/// Savings data.
|
||||
static const String clientBillingSavings = '$baseUrl/client/billing/savings';
|
||||
|
||||
/// Spend breakdown.
|
||||
static const String clientBillingSpendBreakdown =
|
||||
'$baseUrl/client/billing/spend-breakdown';
|
||||
|
||||
/// Coverage overview.
|
||||
static const String clientCoverage = '$baseUrl/client/coverage';
|
||||
|
||||
/// Coverage stats.
|
||||
static const String clientCoverageStats = '$baseUrl/client/coverage/stats';
|
||||
|
||||
/// Core team.
|
||||
static const String clientCoverageCoreTeam =
|
||||
'$baseUrl/client/coverage/core-team';
|
||||
|
||||
/// Hubs list.
|
||||
static const String clientHubs = '$baseUrl/client/hubs';
|
||||
|
||||
/// Cost centers.
|
||||
static const String clientCostCenters = '$baseUrl/client/cost-centers';
|
||||
|
||||
/// Vendors.
|
||||
static const String clientVendors = '$baseUrl/client/vendors';
|
||||
|
||||
/// Vendor roles by ID.
|
||||
static String clientVendorRoles(String vendorId) =>
|
||||
'$baseUrl/client/vendors/$vendorId/roles';
|
||||
|
||||
/// Hub managers by ID.
|
||||
static String clientHubManagers(String hubId) =>
|
||||
'$baseUrl/client/hubs/$hubId/managers';
|
||||
|
||||
/// Team members.
|
||||
static const String clientTeamMembers = '$baseUrl/client/team-members';
|
||||
|
||||
/// View orders.
|
||||
static const String clientOrdersView = '$baseUrl/client/orders/view';
|
||||
|
||||
/// Order reorder preview.
|
||||
static String clientOrderReorderPreview(String orderId) =>
|
||||
'$baseUrl/client/orders/$orderId/reorder-preview';
|
||||
|
||||
/// Reports summary.
|
||||
static const String clientReportsSummary = '$baseUrl/client/reports/summary';
|
||||
|
||||
/// Daily ops report.
|
||||
static const String clientReportsDailyOps =
|
||||
'$baseUrl/client/reports/daily-ops';
|
||||
|
||||
/// Spend report.
|
||||
static const String clientReportsSpend = '$baseUrl/client/reports/spend';
|
||||
|
||||
/// Coverage report.
|
||||
static const String clientReportsCoverage =
|
||||
'$baseUrl/client/reports/coverage';
|
||||
|
||||
/// Forecast report.
|
||||
static const String clientReportsForecast =
|
||||
'$baseUrl/client/reports/forecast';
|
||||
|
||||
/// Performance report.
|
||||
static const String clientReportsPerformance =
|
||||
'$baseUrl/client/reports/performance';
|
||||
|
||||
/// No-show report.
|
||||
static const String clientReportsNoShow = '$baseUrl/client/reports/no-show';
|
||||
|
||||
// ── Client Write ──────────────────────────────────────────────────────
|
||||
|
||||
/// Create one-time order.
|
||||
static const String clientOrdersOneTime = '$baseUrl/client/orders/one-time';
|
||||
|
||||
/// Create recurring order.
|
||||
static const String clientOrdersRecurring =
|
||||
'$baseUrl/client/orders/recurring';
|
||||
|
||||
/// Create permanent order.
|
||||
static const String clientOrdersPermanent =
|
||||
'$baseUrl/client/orders/permanent';
|
||||
|
||||
/// Edit order by ID.
|
||||
static String clientOrderEdit(String orderId) =>
|
||||
'$baseUrl/client/orders/$orderId/edit';
|
||||
|
||||
/// Cancel order by ID.
|
||||
static String clientOrderCancel(String orderId) =>
|
||||
'$baseUrl/client/orders/$orderId/cancel';
|
||||
|
||||
/// Create hub.
|
||||
static const String clientHubCreate = '$baseUrl/client/hubs';
|
||||
|
||||
/// Update hub by ID.
|
||||
static String clientHubUpdate(String hubId) =>
|
||||
'$baseUrl/client/hubs/$hubId';
|
||||
|
||||
/// Delete hub by ID.
|
||||
static String clientHubDelete(String hubId) =>
|
||||
'$baseUrl/client/hubs/$hubId';
|
||||
|
||||
/// Assign NFC to hub.
|
||||
static String clientHubAssignNfc(String hubId) =>
|
||||
'$baseUrl/client/hubs/$hubId/assign-nfc';
|
||||
|
||||
/// Assign managers to hub.
|
||||
static String clientHubAssignManagers(String hubId) =>
|
||||
'$baseUrl/client/hubs/$hubId/managers';
|
||||
|
||||
/// Approve invoice.
|
||||
static String clientInvoiceApprove(String invoiceId) =>
|
||||
'$baseUrl/client/billing/invoices/$invoiceId/approve';
|
||||
|
||||
/// Dispute invoice.
|
||||
static String clientInvoiceDispute(String invoiceId) =>
|
||||
'$baseUrl/client/billing/invoices/$invoiceId/dispute';
|
||||
|
||||
/// Submit coverage review.
|
||||
static const String clientCoverageReviews =
|
||||
'$baseUrl/client/coverage/reviews';
|
||||
|
||||
/// Cancel late worker assignment.
|
||||
static String clientCoverageCancelLateWorker(String assignmentId) =>
|
||||
'$baseUrl/client/coverage/late-workers/$assignmentId/cancel';
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart';
|
||||
import 'package:krow_core/src/services/api_service/inspectors/idempotency_interceptor.dart';
|
||||
|
||||
/// A custom Dio client for the KROW project that includes basic configuration
|
||||
/// and an [AuthInterceptor].
|
||||
/// A custom Dio client for the KROW project that includes basic configuration,
|
||||
/// [AuthInterceptor], and [IdempotencyInterceptor].
|
||||
class DioClient extends DioMixin implements Dio {
|
||||
DioClient([BaseOptions? baseOptions]) {
|
||||
options =
|
||||
@@ -18,10 +19,11 @@ class DioClient extends DioMixin implements Dio {
|
||||
// Add interceptors
|
||||
interceptors.addAll(<Interceptor>[
|
||||
AuthInterceptor(),
|
||||
IdempotencyInterceptor(),
|
||||
LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
), // Added for better debugging
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A Dio interceptor that adds an `Idempotency-Key` header to write requests.
|
||||
///
|
||||
/// The V2 API requires an idempotency key for all POST, PUT, and DELETE
|
||||
/// requests to prevent duplicate operations. A unique UUID v4 is generated
|
||||
/// per request automatically.
|
||||
class IdempotencyInterceptor extends Interceptor {
|
||||
/// The UUID generator instance.
|
||||
static const Uuid _uuid = Uuid();
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) {
|
||||
final String method = options.method.toUpperCase();
|
||||
if (method == 'POST' || method == 'PUT' || method == 'DELETE') {
|
||||
options.headers['Idempotency-Key'] = _uuid.v4();
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Mixin to handle API layer errors and map them to domain exceptions.
|
||||
///
|
||||
/// Use this in repository implementations to wrap [ApiService] calls.
|
||||
/// It catches [DioException], [SocketException], etc., and throws
|
||||
/// the appropriate [AppException] subclass.
|
||||
mixin ApiErrorHandler {
|
||||
/// Executes a Future and maps low-level exceptions to [AppException].
|
||||
///
|
||||
/// [timeout] defaults to 30 seconds.
|
||||
Future<T> executeProtected<T>(
|
||||
Future<T> Function() action, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
try {
|
||||
return await action().timeout(timeout);
|
||||
} on TimeoutException {
|
||||
debugPrint(
|
||||
'ApiErrorHandler: Request timed out after ${timeout.inSeconds}s',
|
||||
);
|
||||
throw ServiceUnavailableException(
|
||||
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _mapDioException(e);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(
|
||||
technicalMessage: 'SocketException: ${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
// If it's already an AppException, rethrow it.
|
||||
if (e is AppException) rethrow;
|
||||
|
||||
final String errorStr = e.toString().toLowerCase();
|
||||
if (_isNetworkRelated(errorStr)) {
|
||||
debugPrint('ApiErrorHandler: Network-related error: $e');
|
||||
throw NetworkException(technicalMessage: e.toString());
|
||||
}
|
||||
|
||||
debugPrint('ApiErrorHandler: Unhandled exception caught: $e');
|
||||
throw UnknownException(technicalMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [DioException] to the appropriate [AppException].
|
||||
AppException _mapDioException(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
debugPrint('ApiErrorHandler: Dio timeout: ${e.type}');
|
||||
return ServiceUnavailableException(
|
||||
technicalMessage: 'Dio ${e.type}: ${e.message}',
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
debugPrint('ApiErrorHandler: Connection error: ${e.message}');
|
||||
return NetworkException(
|
||||
technicalMessage: 'Connection error: ${e.message}',
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final int? statusCode = e.response?.statusCode;
|
||||
final String body = e.response?.data?.toString() ?? '';
|
||||
debugPrint(
|
||||
'ApiErrorHandler: Bad response $statusCode: $body',
|
||||
);
|
||||
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
return NotAuthenticatedException(
|
||||
technicalMessage: 'HTTP $statusCode: $body',
|
||||
);
|
||||
}
|
||||
if (statusCode == 404) {
|
||||
return ServerException(
|
||||
technicalMessage: 'HTTP 404: Not found — $body',
|
||||
);
|
||||
}
|
||||
if (statusCode == 429) {
|
||||
return ServiceUnavailableException(
|
||||
technicalMessage: 'Rate limited (429): $body',
|
||||
);
|
||||
}
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return ServiceUnavailableException(
|
||||
technicalMessage: 'HTTP $statusCode: $body',
|
||||
);
|
||||
}
|
||||
return ServerException(
|
||||
technicalMessage: 'HTTP $statusCode: $body',
|
||||
);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return UnknownException(
|
||||
technicalMessage: 'Request cancelled',
|
||||
);
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return NetworkException(
|
||||
technicalMessage: 'Bad certificate: ${e.message}',
|
||||
);
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
if (e.error is SocketException) {
|
||||
return NetworkException(
|
||||
technicalMessage: 'Socket error: ${e.error}',
|
||||
);
|
||||
}
|
||||
return UnknownException(
|
||||
technicalMessage: 'Unknown Dio error: ${e.message}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if an error string is network-related.
|
||||
bool _isNetworkRelated(String errorStr) {
|
||||
return errorStr.contains('socketexception') ||
|
||||
errorStr.contains('network') ||
|
||||
errorStr.contains('offline') ||
|
||||
errorStr.contains('connection failed') ||
|
||||
errorStr.contains('unavailable') ||
|
||||
errorStr.contains('handshake') ||
|
||||
errorStr.contains('clientexception') ||
|
||||
errorStr.contains('failed host lookup') ||
|
||||
errorStr.contains('connection error') ||
|
||||
errorStr.contains('terminated') ||
|
||||
errorStr.contains('connectexception');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
/// Enum representing the current session state.
|
||||
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
||||
|
||||
/// Data class for session state.
|
||||
class SessionState {
|
||||
/// Creates a [SessionState].
|
||||
SessionState({required this.type, this.userId, this.errorMessage});
|
||||
|
||||
/// Creates a loading state.
|
||||
factory SessionState.loading() =>
|
||||
SessionState(type: SessionStateType.loading);
|
||||
|
||||
/// Creates an authenticated state.
|
||||
factory SessionState.authenticated({required String userId}) =>
|
||||
SessionState(type: SessionStateType.authenticated, userId: userId);
|
||||
|
||||
/// Creates an unauthenticated state.
|
||||
factory SessionState.unauthenticated() =>
|
||||
SessionState(type: SessionStateType.unauthenticated);
|
||||
|
||||
/// Creates an error state.
|
||||
factory SessionState.error(String message) =>
|
||||
SessionState(type: SessionStateType.error, errorMessage: message);
|
||||
|
||||
/// The type of session state.
|
||||
final SessionStateType type;
|
||||
|
||||
/// The current user ID (if authenticated).
|
||||
final String? userId;
|
||||
|
||||
/// Error message (if error occurred).
|
||||
final String? errorMessage;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
|
||||
}
|
||||
|
||||
/// Mixin for handling Firebase Auth session management, token refresh,
|
||||
/// and state emissions.
|
||||
///
|
||||
/// Implementors must provide [auth] and [fetchUserRole]. The role fetch
|
||||
/// should call `GET /auth/session` via [ApiService] instead of querying
|
||||
/// Data Connect directly.
|
||||
mixin SessionHandlerMixin {
|
||||
/// Stream controller for session state changes.
|
||||
final StreamController<SessionState> _sessionStateController =
|
||||
StreamController<SessionState>.broadcast();
|
||||
|
||||
/// Last emitted session state (for late subscribers).
|
||||
SessionState? _lastSessionState;
|
||||
|
||||
/// Public stream for listening to session state changes.
|
||||
/// Late subscribers will immediately receive the last emitted state.
|
||||
Stream<SessionState> get onSessionStateChanged {
|
||||
return _createStreamWithLastState();
|
||||
}
|
||||
|
||||
/// Creates a stream that emits the last state before subscribing to new events.
|
||||
Stream<SessionState> _createStreamWithLastState() async* {
|
||||
if (_lastSessionState != null) {
|
||||
yield _lastSessionState!;
|
||||
}
|
||||
yield* _sessionStateController.stream;
|
||||
}
|
||||
|
||||
/// Last token refresh timestamp to avoid excessive checks.
|
||||
DateTime? _lastTokenRefreshTime;
|
||||
|
||||
/// Subscription to auth state changes.
|
||||
StreamSubscription<firebase_auth.User?>? _authStateSubscription;
|
||||
|
||||
/// Minimum interval between token refresh checks.
|
||||
static const Duration _minRefreshCheckInterval = Duration(seconds: 2);
|
||||
|
||||
/// Time before token expiry to trigger a refresh.
|
||||
static const Duration _refreshThreshold = Duration(minutes: 5);
|
||||
|
||||
/// Firebase Auth instance (to be provided by implementing class).
|
||||
firebase_auth.FirebaseAuth get auth;
|
||||
|
||||
/// List of allowed roles for this app (set during initialization).
|
||||
List<String> _allowedRoles = <String>[];
|
||||
|
||||
/// Initialize the auth state listener (call once on app startup).
|
||||
void initializeAuthListener({
|
||||
List<String> allowedRoles = const <String>[],
|
||||
}) {
|
||||
_allowedRoles = allowedRoles;
|
||||
|
||||
_authStateSubscription?.cancel();
|
||||
|
||||
_authStateSubscription = auth.authStateChanges().listen(
|
||||
(firebase_auth.User? user) async {
|
||||
if (user == null) {
|
||||
handleSignOut();
|
||||
} else {
|
||||
await _handleSignIn(user);
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
_emitSessionState(SessionState.error(error.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates if user has one of the allowed roles.
|
||||
Future<bool> validateUserRole(
|
||||
String userId,
|
||||
List<String> allowedRoles,
|
||||
) async {
|
||||
try {
|
||||
final String? userRole = await fetchUserRole(userId);
|
||||
return userRole != null && allowedRoles.contains(userRole);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to validate user role: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches user role from the backend.
|
||||
///
|
||||
/// Implementors should call `GET /auth/session` via [ApiService] and
|
||||
/// extract the role from the response.
|
||||
Future<String?> fetchUserRole(String userId);
|
||||
|
||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
||||
Future<void> ensureSessionValid() async {
|
||||
final firebase_auth.User? user = auth.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
final DateTime now = DateTime.now();
|
||||
if (_lastTokenRefreshTime != null) {
|
||||
final Duration timeSinceLastCheck = now.difference(
|
||||
_lastTokenRefreshTime!,
|
||||
);
|
||||
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
final firebase_auth.IdTokenResult idToken =
|
||||
await user.getIdTokenResult();
|
||||
final DateTime? expiryTime = idToken.expirationTime;
|
||||
|
||||
if (expiryTime == null) return;
|
||||
|
||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||
if (timeUntilExpiry <= _refreshThreshold) {
|
||||
await user.getIdTokenResult();
|
||||
}
|
||||
|
||||
_lastTokenRefreshTime = now;
|
||||
return;
|
||||
} catch (e) {
|
||||
retryCount++;
|
||||
debugPrint(
|
||||
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
||||
);
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
_emitSessionState(
|
||||
SessionState.error(
|
||||
'Token validation failed after $maxRetries attempts: $e',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final Duration backoffDuration = Duration(
|
||||
seconds: 1 << (retryCount - 1),
|
||||
);
|
||||
debugPrint(
|
||||
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
||||
);
|
||||
await Future<void>.delayed(backoffDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-in event.
|
||||
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||
try {
|
||||
_emitSessionState(SessionState.loading());
|
||||
|
||||
if (_allowedRoles.isNotEmpty) {
|
||||
final String? userRole = await fetchUserRole(user.uid);
|
||||
|
||||
if (userRole == null) {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_allowedRoles.contains(userRole)) {
|
||||
await auth.signOut();
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final firebase_auth.IdTokenResult idToken =
|
||||
await user.getIdTokenResult();
|
||||
if (idToken.expirationTime != null &&
|
||||
DateTime.now().difference(idToken.expirationTime!) <
|
||||
const Duration(minutes: 5)) {
|
||||
await user.getIdTokenResult();
|
||||
}
|
||||
|
||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||
} catch (e) {
|
||||
_emitSessionState(SessionState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-out event.
|
||||
void handleSignOut() {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
}
|
||||
|
||||
/// Emit session state update.
|
||||
void _emitSessionState(SessionState state) {
|
||||
_lastSessionState = state;
|
||||
if (!_sessionStateController.isClosed) {
|
||||
_sessionStateController.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose session handler resources.
|
||||
Future<void> disposeSessionHandler() async {
|
||||
await _authStateSubscription?.cancel();
|
||||
await _sessionStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,4 @@ dependencies:
|
||||
flutter_local_notifications: ^21.0.0
|
||||
shared_preferences: ^2.5.4
|
||||
workmanager: ^0.9.0+3
|
||||
uuid: ^4.5.1
|
||||
|
||||
Reference in New Issue
Block a user