ionic-planb-logistic-app-fl.../lib/api/grpc_client.dart
Mathias Beaulieu-Duncan 823f9107fd auto-claude: subtask-3-4 - Implement command methods (complete, uncomplete, skip)
Adds gRPC command methods to GrpcCqrsApiClient for delivery operations:
- completeDelivery: Mark delivery as completed with optional timestamp
- markDeliveryAsUncompleted: Revert completed delivery to pending
- skipDelivery: Skip a delivery that cannot be completed

All methods follow the Result<T> pattern for consistent error handling,
check CommandResponse.success flag, and map error messages appropriately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 13:06:08 -05:00

551 lines
18 KiB
Dart

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<T>` 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<CallOptions> _buildCallOptions() async {
final metadata = <String, String>{};
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<T>` 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<Result<T>> _executeWithAuth<T>(
Future<T> 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<Result<void>> _executeCommandWithAuth(
Future<void> 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<Result<List<DeliveryRoute>>> getDeliveryRoutes() async {
final result = await _executeWithAuth<DeliveryRoutesResponse>(
(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<Result<List<Delivery>>> getDeliveries({
required int routeFragmentId,
}) async {
final request = GetDeliveriesRequest(routeFragmentId: routeFragmentId);
final result = await _executeWithAuth<DeliveriesResponse>(
(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<Result<void>> completeDelivery({
required int deliveryId,
String? deliveredAt,
}) async {
final request = CompleteDeliveryRequest(
deliveryId: deliveryId,
deliveredAt: deliveredAt,
);
final result = await _executeWithAuth<CommandResponse>(
(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<Result<void>> markDeliveryAsUncompleted({
required int deliveryId,
}) async {
final request = MarkDeliveryUncompletedRequest(
deliveryId: deliveryId,
);
final result = await _executeWithAuth<CommandResponse>(
(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<Result<void>> skipDelivery({
required int deliveryId,
}) async {
final request = SkipDeliveryRequest(
deliveryId: deliveryId,
);
final result = await _executeWithAuth<CommandResponse>(
(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<void> 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<void> terminate() async {
await _channel?.terminate();
_channel = null;
_deliveryClient = null;
}
/// Returns true if the channel is currently active.
bool get isConnected => _channel != null;
}