import 'dart:async'; import 'package:fixnum/fixnum.dart'; import 'package:grpc/grpc.dart'; import 'package:protobuf/protobuf.dart' as $protobuf; import '../generated/cqrs_services.pb.dart' as $pb; import '../generated/cqrs_services.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'; /// gRPC-based CQRS API client for Plan B Logistics. class GrpcCqrsApiClient { final GrpcConfig config; final AuthService? authService; ClientChannel? _channel; DynamicQueryServiceClient? _queryClient; CommandServiceClient? _commandClient; GrpcCqrsApiClient({ required this.config, this.authService, }); 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!; } DynamicQueryServiceClient get queryClient { _queryClient ??= DynamicQueryServiceClient(channel); return _queryClient!; } CommandServiceClient get commandClient { _commandClient ??= CommandServiceClient(channel); return _commandClient!; } 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, ); } 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('Service unavailable'); default: return ApiError.unknown( error.message ?? 'Unknown error', exception: error, ); } } Future>> getDeliveryRoutes() async { try { final options = await _buildCallOptions(); final request = DynamicQuerySimpleDeliveryRouteQueryItemsRequest(); final response = await queryClient.querySimpleDeliveryRouteQueryItems( request, options: options, ); final routes = response.data.map((item) => DeliveryRoute( id: item.id.toInt(), routeId: item.routeId.toInt(), name: item.name, routeName: item.routeName, deliveriesCount: item.deliveriesCount, deliveredCount: item.deliveredCount, completed: item.completed, createdAt: item.createdAt.toDateTime().toIso8601String(), )).toList(); return Result.success(routes); } on GrpcError catch (e) { return Result.error(_mapGrpcError(e)); } catch (e) { return Result.error(ApiError.unknown(e.toString(), exception: Exception(e.toString()))); } } Future>> getDeliveries({required int routeFragmentId}) async { try { final options = await _buildCallOptions(); final request = DynamicQuerySimpleDeliveriesQueryItemsRequest( filters: [ DynamicQueryFilter( path: 'RouteFragmentId', type: 0, // Equal value: routeFragmentId.toString(), ), ], ); final response = await queryClient.querySimpleDeliveriesQueryItems( request, options: options, ); final deliveries = response.data.map((item) { final address = item.hasDeliveryAddress() ? DeliveryAddress( id: item.deliveryAddress.id.toInt(), line1: item.deliveryAddress.line1, line2: item.deliveryAddress.line2.isNotEmpty ? item.deliveryAddress.line2 : null, postalCode: item.deliveryAddress.postalCode.isNotEmpty ? item.deliveryAddress.postalCode : null, city: item.deliveryAddress.city, subdivision: item.deliveryAddress.subdivision.isNotEmpty ? item.deliveryAddress.subdivision : null, countryCode: item.deliveryAddress.countryCode, latitude: item.deliveryAddress.latitude, longitude: item.deliveryAddress.longitude, formattedAddress: item.deliveryAddress.formattedAddress, ) : null; final orders = item.orders.map((orderProto) { final contacts = orderProto.contacts.map((contactProto) => DeliveryContact( firstName: contactProto.firstName, lastName: contactProto.lastName, phoneNumber: contactProto.phoneNumber.isNotEmpty ? contactProto.phoneNumber : null, fullName: contactProto.fullName, )).toList(); return DeliveryOrder( id: orderProto.id.toInt(), isNewCustomer: orderProto.isNewCustomer, note: orderProto.note.isNotEmpty ? orderProto.note : null, totalAmount: double.tryParse(orderProto.totalAmount) ?? 0.0, totalPaid: orderProto.totalPaid.isNotEmpty ? double.tryParse(orderProto.totalPaid) : null, totalItems: orderProto.totalItems, contacts: contacts, contact: orderProto.hasContact() ? DeliveryContact( firstName: orderProto.contact.firstName, lastName: orderProto.contact.lastName, phoneNumber: orderProto.contact.phoneNumber.isNotEmpty ? orderProto.contact.phoneNumber : null, fullName: orderProto.contact.fullName, ) : null, ); }).toList(); final deliveredBy = item.hasDeliveredBy() ? UserInfo( id: item.deliveredBy.id.toInt(), firstName: item.deliveredBy.firstName, lastName: item.deliveredBy.lastName, fullName: item.deliveredBy.fullName, ) : null; return Delivery( id: item.id.toInt(), routeFragmentId: item.routeFragmentId.toInt(), deliveryIndex: item.deliveryIndex, orders: orders, deliveredBy: deliveredBy, deliveryAddress: address, deliveredAt: item.hasDeliveredAt() ? item.deliveredAt.toDateTime().toIso8601String() : null, skippedAt: item.hasSkippedAt() ? item.skippedAt.toDateTime().toIso8601String() : null, createdAt: item.createdAt.toDateTime().toIso8601String(), updatedAt: item.hasUpdatedAt() ? item.updatedAt.toDateTime().toIso8601String() : null, delivered: item.delivered, hasBeenSkipped: item.hasBeenSkipped, isSkipped: item.isSkipped, name: item.name, ); }).toList(); return Result.success(deliveries); } on GrpcError catch (e) { return Result.error(_mapGrpcError(e)); } catch (e) { return Result.error(ApiError.unknown(e.toString(), exception: Exception(e.toString()))); } } Future> completeDelivery({required int deliveryId}) async { try { final options = await _buildCallOptions(); final request = CompleteDeliveryCommandRequest( deliveryId: Int64(deliveryId), deliveredAt: DateTime.now().toUtc().toProto3Timestamp(), ); await commandClient.completeDelivery(request, options: options); return Result.success(null); } on GrpcError catch (e) { return Result.error(_mapGrpcError(e)); } catch (e) { return Result.error(ApiError.unknown(e.toString(), exception: Exception(e.toString()))); } } Future> markDeliveryAsUncompleted({required int deliveryId}) async { try { final options = await _buildCallOptions(); final request = MarkDeliveryAsUncompletedCommandRequest( deliveryId: Int64(deliveryId), ); await commandClient.markDeliveryAsUncompleted(request, options: options); return Result.success(null); } on GrpcError catch (e) { return Result.error(_mapGrpcError(e)); } catch (e) { return Result.error(ApiError.unknown(e.toString(), exception: Exception(e.toString()))); } } Future> skipDelivery({ required int deliveryId, required String description, }) async { try { final options = await _buildCallOptions(); final request = SkipDeliveryCommandRequest( deliveryId: Int64(deliveryId), description: description, skippedAt: DateTime.now().toUtc().toProto3Timestamp(), ); await commandClient.skipDelivery(request, options: options); return Result.success(null); } on GrpcError catch (e) { return Result.error(_mapGrpcError(e)); } catch (e) { return Result.error(ApiError.unknown(e.toString(), exception: Exception(e.toString()))); } } void shutdown() { _channel?.shutdown(); _channel = null; _queryClient = null; _commandClient = null; } } // Extension to convert DateTime to protobuf Timestamp extension DateTimeToProto on DateTime { $pb.Timestamp toProto3Timestamp() { return $pb.Timestamp() ..seconds = Int64(millisecondsSinceEpoch ~/ 1000) ..nanos = ((millisecondsSinceEpoch % 1000) * 1000000).toInt(); } }