import 'dart:async'; import 'package:grpc/grpc.dart'; import '../generated/delivery_service.pbgrpc.dart'; import '../models/delivery.dart'; import '../models/delivery_address.dart'; import '../models/delivery_contact.dart'; import '../models/delivery_order.dart'; import '../models/delivery_route.dart'; import '../models/user_info.dart'; import '../services/auth_service.dart'; import 'grpc_config.dart'; import 'types.dart'; // ignore_for_file: unused_element /// gRPC-based CQRS API client for Plan B Logistics. /// /// This client wraps the generated DeliveryServiceClient with `Result` error /// handling patterns consistent with [CqrsApiClient]. It provides: /// - Lazy channel initialization /// - Authentication via Bearer token in gRPC metadata /// - Automatic token refresh on UNAUTHENTICATED errors /// - Proper gRPC error to [ApiError] mapping /// /// Usage: /// ```dart /// final client = GrpcCqrsApiClient( /// config: GrpcConfig.development, /// authService: authService, /// ); /// /// final result = await client.getDeliveryRoutes(); /// result.when( /// success: (routes) => handleRoutes(routes), /// onError: (error) => handleError(error), /// ); /// ``` class GrpcCqrsApiClient { final GrpcConfig config; final AuthService? authService; ClientChannel? _channel; DeliveryServiceClient? _deliveryClient; GrpcCqrsApiClient({ required this.config, this.authService, }); /// Returns the gRPC channel, creating it lazily if needed. /// /// The channel is configured based on [config.useTls] for development /// (insecure) vs production (TLS) environments. ClientChannel get channel { if (_channel == null) { final credentials = config.useTls ? const ChannelCredentials.secure() : const ChannelCredentials.insecure(); _channel = ClientChannel( config.host, port: config.port, options: ChannelOptions( credentials: credentials, connectionTimeout: config.timeout, idleTimeout: const Duration(minutes: 5), ), ); } return _channel!; } /// Returns the DeliveryService client, creating it lazily if needed. DeliveryServiceClient get deliveryClient { _deliveryClient ??= DeliveryServiceClient(channel); return _deliveryClient!; } /// Builds [CallOptions] with authentication metadata. /// /// Includes Bearer token in metadata if [authService] is configured and /// a valid token is available. Uses [AuthService.ensureValidToken] to /// proactively refresh tokens that are expiring soon. Future _buildCallOptions() async { final metadata = {}; if (authService != null) { final token = await authService!.ensureValidToken(); if (token != null) { metadata['authorization'] = 'Bearer $token'; } } return CallOptions( metadata: metadata, timeout: config.timeout, ); } /// Merges base [CallOptions] with additional options for a specific call. CallOptions _mergeOptions(CallOptions base, CallOptions? additional) { if (additional == null) return base; return CallOptions( metadata: { ...base.metadata, ...additional.metadata, }, timeout: additional.timeout ?? base.timeout, providers: [ ...base.metadataProviders, ...additional.metadataProviders, ], ); } /// Maps gRPC [GrpcError] to [ApiError] for consistent error handling. /// /// Maps common gRPC status codes to appropriate [ApiErrorType]: /// - UNAUTHENTICATED (16) -> HTTP 401 /// - PERMISSION_DENIED (7) -> HTTP 403 /// - NOT_FOUND (5) -> HTTP 404 /// - INVALID_ARGUMENT (3) -> Validation error /// - DEADLINE_EXCEEDED (4) -> Timeout /// - UNAVAILABLE (14) -> Network error /// - Other codes -> Unknown error ApiError _mapGrpcError(GrpcError error) { switch (error.code) { case StatusCode.unauthenticated: return ApiError.http( statusCode: 401, message: error.message ?? 'Authentication required', ); case StatusCode.permissionDenied: return ApiError.http( statusCode: 403, message: error.message ?? 'Permission denied', ); case StatusCode.notFound: return ApiError.http( statusCode: 404, message: error.message ?? 'Resource not found', ); case StatusCode.invalidArgument: return ApiError.validation( error.message ?? 'Invalid request', null, ); case StatusCode.deadlineExceeded: return ApiError.timeout(); case StatusCode.unavailable: return ApiError.network( error.message ?? 'Service unavailable', ); case StatusCode.internal: return ApiError.http( statusCode: 500, message: error.message ?? 'Internal server error', ); default: return ApiError.unknown( error.message ?? 'gRPC error: ${error.codeName}', exception: error, ); } } /// Executes a gRPC call with `Result` error handling and authentication. /// /// This is the core method that wraps gRPC calls with: /// - Authentication token injection /// - Automatic token refresh on UNAUTHENTICATED errors (single retry) /// - gRPC error to ApiError mapping /// - Timeout handling /// /// [grpcCall] is the actual gRPC method invocation. /// [isRetry] tracks whether this is a retry after token refresh. Future> _executeWithAuth( Future Function(CallOptions options) grpcCall, { bool isRetry = false, }) async { try { final options = await _buildCallOptions(); final result = await grpcCall(options); return Result.success(result); } on GrpcError catch (error) { // Handle UNAUTHENTICATED by attempting token refresh (once) if (error.code == StatusCode.unauthenticated && !isRetry && authService != null) { final refreshResult = await authService!.refreshAccessToken(); return refreshResult.when( success: (token) => _executeWithAuth(grpcCall, isRetry: true), onError: (refreshError) => Result.error(_mapGrpcError(error)), cancelled: () => Result.error(_mapGrpcError(error)), ); } return Result.error(_mapGrpcError(error)); } on TimeoutException { return Result.error(ApiError.timeout()); } catch (e, stackTrace) { return Result.error( ApiError.unknown( 'gRPC call failed: ${e.toString()}', exception: Exception(stackTrace.toString()), ), ); } } /// Executes a gRPC call that returns void (for commands). /// /// Similar to [_executeWithAuth] but for operations that don't return /// meaningful data (commands that return success/failure). Future> _executeCommandWithAuth( Future Function(CallOptions options) grpcCall, { bool isRetry = false, }) async { try { final options = await _buildCallOptions(); await grpcCall(options); return Result.success(null); } on GrpcError catch (error) { if (error.code == StatusCode.unauthenticated && !isRetry && authService != null) { final refreshResult = await authService!.refreshAccessToken(); return refreshResult.when( success: (token) => _executeCommandWithAuth(grpcCall, isRetry: true), onError: (refreshError) => Result.error(_mapGrpcError(error)), cancelled: () => Result.error(_mapGrpcError(error)), ); } return Result.error(_mapGrpcError(error)); } on TimeoutException { return Result.error(ApiError.timeout()); } catch (e, stackTrace) { return Result.error( ApiError.unknown( 'gRPC command failed: ${e.toString()}', exception: Exception(stackTrace.toString()), ), ); } } // ============================================================ // Query Methods // ============================================================ /// Gets all delivery routes. /// /// Returns a [Result] containing a list of [DeliveryRoute] objects. /// Maps the gRPC [DeliveryRoutesResponse] to domain models. /// /// Example: /// ```dart /// final result = await client.getDeliveryRoutes(); /// result.when( /// success: (routes) => displayRoutes(routes), /// onError: (error) => showError(error.message), /// ); /// ``` Future>> getDeliveryRoutes() async { final result = await _executeWithAuth( (options) => deliveryClient.getDeliveryRoutes(Empty(), options: options), ); return result.when( success: (response) { final routes = response.routes.map(_mapDeliveryRouteProto).toList(); return Result.success(routes); }, onError: (error) => Result.error(error), ); } /// Maps a [DeliveryRouteProto] to a [DeliveryRoute] domain model. DeliveryRoute _mapDeliveryRouteProto(DeliveryRouteProto proto) { return DeliveryRoute( id: proto.id, routeId: proto.routeId, name: proto.name, routeName: proto.routeName, deliveriesCount: proto.deliveriesCount, deliveredCount: proto.deliveredCount, completed: proto.completed, createdAt: proto.createdAt, ); } /// Gets deliveries for a specific route fragment. /// /// Returns a [Result] containing a list of [Delivery] objects for the /// specified [routeFragmentId]. Maps the gRPC [DeliveriesResponse] to /// domain models. /// /// Example: /// ```dart /// final result = await client.getDeliveries(routeFragmentId: 123); /// result.when( /// success: (deliveries) => displayDeliveries(deliveries), /// onError: (error) => showError(error.message), /// ); /// ``` Future>> getDeliveries({ required int routeFragmentId, }) async { final request = GetDeliveriesRequest(routeFragmentId: routeFragmentId); final result = await _executeWithAuth( (options) => deliveryClient.getDeliveries(request, options: options), ); return result.when( success: (response) { final deliveries = response.deliveries.map(_mapDeliveryProto).toList(); return Result.success(deliveries); }, onError: (error) => Result.error(error), ); } /// Maps a [DeliveryProto] to a [Delivery] domain model. Delivery _mapDeliveryProto(DeliveryProto proto) { return Delivery( id: proto.id, routeFragmentId: proto.routeFragmentId, deliveryIndex: proto.deliveryIndex, orders: proto.orders.map(_mapDeliveryOrderProto).toList(), deliveredBy: proto.hasDeliveredBy() ? _mapUserInfoProto(proto.deliveredBy) : null, deliveryAddress: proto.hasDeliveryAddress() ? _mapDeliveryAddressProto(proto.deliveryAddress) : null, deliveredAt: proto.hasDeliveredAt() ? proto.deliveredAt : null, skippedAt: proto.hasSkippedAt() ? proto.skippedAt : null, createdAt: proto.createdAt, updatedAt: proto.hasUpdatedAt() ? proto.updatedAt : null, delivered: proto.delivered, hasBeenSkipped: proto.hasBeenSkipped, isSkipped: proto.isSkipped, name: proto.name, ); } /// Maps a [DeliveryAddressProto] to a [DeliveryAddress] domain model. DeliveryAddress _mapDeliveryAddressProto(DeliveryAddressProto proto) { return DeliveryAddress( id: proto.id, line1: proto.hasLine1() ? proto.line1 : null, line2: proto.hasLine2() ? proto.line2 : null, postalCode: proto.hasPostalCode() ? proto.postalCode : null, city: proto.hasCity() ? proto.city : null, subdivision: proto.hasSubdivision() ? proto.subdivision : null, countryCode: proto.hasCountryCode() ? proto.countryCode : null, latitude: proto.hasLatitude() ? proto.latitude : null, longitude: proto.hasLongitude() ? proto.longitude : null, formattedAddress: proto.hasFormattedAddress() ? proto.formattedAddress : null, ); } /// Maps a [DeliveryOrderProto] to a [DeliveryOrder] domain model. DeliveryOrder _mapDeliveryOrderProto(DeliveryOrderProto proto) { return DeliveryOrder( id: proto.id, isNewCustomer: proto.isNewCustomer, note: proto.hasNote() ? proto.note : null, totalAmount: proto.totalAmount, totalPaid: proto.hasTotalPaid() ? proto.totalPaid : null, totalItems: proto.hasTotalItems() ? proto.totalItems : null, contacts: proto.contacts.map(_mapDeliveryContactProto).toList(), contact: proto.hasContact() ? _mapDeliveryContactProto(proto.contact) : null, ); } /// Maps a [DeliveryContactProto] to a [DeliveryContact] domain model. DeliveryContact _mapDeliveryContactProto(DeliveryContactProto proto) { return DeliveryContact( firstName: proto.firstName, lastName: proto.hasLastName() ? proto.lastName : null, fullName: proto.fullName, phoneNumber: proto.hasPhoneNumber() ? proto.phoneNumber : null, ); } /// Maps a [UserInfoProto] to a [UserInfo] domain model. UserInfo _mapUserInfoProto(UserInfoProto proto) { return UserInfo( id: proto.id, firstName: proto.firstName, lastName: proto.hasLastName() ? proto.lastName : null, fullName: proto.fullName, ); } // ============================================================ // Command Methods // ============================================================ /// Marks a delivery as completed. /// /// Returns a [Result] indicating success or failure. Optionally accepts /// a [deliveredAt] timestamp; if not provided, the server will use the /// current time. /// /// Example: /// ```dart /// final result = await client.completeDelivery(deliveryId: 123); /// result.when( /// success: (_) => showSuccess('Delivery completed'), /// onError: (error) => showError(error.message), /// ); /// ``` Future> completeDelivery({ required int deliveryId, String? deliveredAt, }) async { final request = CompleteDeliveryRequest( deliveryId: deliveryId, deliveredAt: deliveredAt, ); final result = await _executeWithAuth( (options) => deliveryClient.completeDelivery(request, options: options), ); return result.when( success: (response) { if (response.success) { return Result.success(null); } return Result.error( ApiError.unknown(response.message.isNotEmpty ? response.message : 'Failed to complete delivery'), ); }, onError: (error) => Result.error(error), ); } /// Marks a delivery as uncompleted. /// /// Returns a [Result] indicating success or failure. Use this to revert /// a previously completed delivery back to pending status. /// /// Example: /// ```dart /// final result = await client.markDeliveryAsUncompleted(deliveryId: 123); /// result.when( /// success: (_) => showSuccess('Delivery marked as uncompleted'), /// onError: (error) => showError(error.message), /// ); /// ``` Future> markDeliveryAsUncompleted({ required int deliveryId, }) async { final request = MarkDeliveryUncompletedRequest( deliveryId: deliveryId, ); final result = await _executeWithAuth( (options) => deliveryClient.markDeliveryUncompleted(request, options: options), ); return result.when( success: (response) { if (response.success) { return Result.success(null); } return Result.error( ApiError.unknown(response.message.isNotEmpty ? response.message : 'Failed to mark delivery as uncompleted'), ); }, onError: (error) => Result.error(error), ); } /// Skips a delivery. /// /// Returns a [Result] indicating success or failure. Use this when a /// delivery cannot be completed and needs to be skipped. /// /// Example: /// ```dart /// final result = await client.skipDelivery(deliveryId: 123); /// result.when( /// success: (_) => showSuccess('Delivery skipped'), /// onError: (error) => showError(error.message), /// ); /// ``` Future> skipDelivery({ required int deliveryId, }) async { final request = SkipDeliveryRequest( deliveryId: deliveryId, ); final result = await _executeWithAuth( (options) => deliveryClient.skipDelivery(request, options: options), ); return result.when( success: (response) { if (response.success) { return Result.success(null); } return Result.error( ApiError.unknown(response.message.isNotEmpty ? response.message : 'Failed to skip delivery'), ); }, onError: (error) => Result.error(error), ); } /// Shuts down the gRPC channel and releases resources. /// /// Should be called when the client is no longer needed to properly /// clean up network resources. Future shutdown() async { await _channel?.shutdown(); _channel = null; _deliveryClient = null; } /// Terminates the gRPC channel immediately. /// /// Unlike [shutdown], this does not wait for pending calls to complete. /// Use this for emergency cleanup or when the app is terminating. Future terminate() async { await _channel?.terminate(); _channel = null; _deliveryClient = null; } /// Returns true if the channel is currently active. bool get isConnected => _channel != null; }