- Use protobuf well_known_types Timestamp for gRPC compatibility - Fix ApiError.network() to include required message parameter - Complete migration from HTTP to gRPC with cqrs_services proto - App now successfully connects to gRPC backend at 192.168.88.228:5011 - Mobile UX optimization with toggleable deliveries overlay functional Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
307 lines
10 KiB
Dart
307 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:fixnum/fixnum.dart';
|
|
import 'package:grpc/grpc.dart';
|
|
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart' as $timestamp;
|
|
|
|
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<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,
|
|
);
|
|
}
|
|
|
|
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<Result<List<DeliveryRoute>>> 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<Result<List<Delivery>>> 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<Result<void>> 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<Result<void>> 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<Result<void>> 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 {
|
|
$timestamp.Timestamp toProto3Timestamp() {
|
|
return $timestamp.Timestamp()
|
|
..seconds = Int64(millisecondsSinceEpoch ~/ 1000)
|
|
..nanos = ((millisecondsSinceEpoch % 1000) * 1000000).toInt();
|
|
}
|
|
}
|