From b22853872537262bff254f5e22d394da61c220f4 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 4 Jun 2026 00:48:50 +0700 Subject: [PATCH] feat: implement idempotency key for critical API endpoints --- .../order/order_form/order_form_bloc.dart | 10 ++ .../order_form/order_form_bloc.freezed.dart | 43 +++++++- .../order/order_form/order_form_state.dart | 4 + .../payment_form/payment_form_bloc.dart | 35 +++++- .../payment_form_bloc.freezed.dart | 69 +++++++++++- .../payment_form/payment_form_state.dart | 6 + .../refund/refund_form/refund_form_bloc.dart | 14 ++- .../refund_form/refund_form_bloc.freezed.dart | 38 ++++++- .../refund/refund_form/refund_form_state.dart | 4 + .../void/void_form/void_form_bloc.dart | 14 ++- .../void_form/void_form_bloc.freezed.dart | 38 ++++++- .../void/void_form/void_form_state.dart | 4 + lib/common/api/api_client.dart | 2 + .../interceptors/idempotency_interceptor.dart | 104 ++++++++++++++++++ lib/common/database/database_helper.dart | 11 +- .../repositories/i_order_repository.dart | 9 +- .../datasources/remote_data_provider.dart | 32 ++++-- .../order/repositories/order_repository.dart | 14 ++- .../order/order_void_confirm_dialog.dart | 14 ++- pubspec.lock | 2 +- pubspec.yaml | 1 + 21 files changed, 431 insertions(+), 37 deletions(-) create mode 100644 lib/common/api/interceptors/idempotency_interceptor.dart diff --git a/lib/application/order/order_form/order_form_bloc.dart b/lib/application/order/order_form/order_form_bloc.dart index cff91b6..e287690 100644 --- a/lib/application/order/order_form/order_form_bloc.dart +++ b/lib/application/order/order_form/order_form_bloc.dart @@ -3,6 +3,7 @@ import 'package:dartz/dartz.dart' hide Order; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart' hide Order; +import 'package:uuid/uuid.dart'; import '../../../common/types/order_type.dart'; import '../../../domain/customer/customer.dart'; @@ -123,10 +124,15 @@ class OrderFormBloc extends Bloc { addItemOrder: (e) async { Either failureOrAddItemOrder; + // Generate new idempotency key saat user intent (tap Add Items) + // Kalau sedang retry, pakai key yang sama + final idempotencyKey = state.addItemIdempotencyKey ?? const Uuid().v4(); + emit( state.copyWith( isAddingItemOrder: true, failureOrAddItemOrder: none(), + addItemIdempotencyKey: idempotencyKey, ), ); @@ -145,12 +151,16 @@ class OrderFormBloc extends Bloc { failureOrAddItemOrder = await _repository.addItemOrder( id: e.orderId, request: request, + idempotencyKey: idempotencyKey, ); emit( state.copyWith( isAddingItemOrder: false, failureOrAddItemOrder: optionOf(failureOrAddItemOrder), + // Clear key on success, keep on failure for retry + addItemIdempotencyKey: + failureOrAddItemOrder.isRight() ? null : idempotencyKey, ), ); }, diff --git a/lib/application/order/order_form/order_form_bloc.freezed.dart b/lib/application/order/order_form/order_form_bloc.freezed.dart index 2cd8cfc..b0f9b3b 100644 --- a/lib/application/order/order_form/order_form_bloc.freezed.dart +++ b/lib/application/order/order_form/order_form_bloc.freezed.dart @@ -1584,6 +1584,11 @@ mixin _$OrderFormState { bool get isCreatingWithPayment => throw _privateConstructorUsedError; bool get isAddingItemOrder => throw _privateConstructorUsedError; + /// Idempotency key untuk add item order. + /// Di-generate saat user tap "Add Items", dipakai ulang saat retry, + /// di-clear setelah sukses. + String? get addItemIdempotencyKey => throw _privateConstructorUsedError; + /// Create a copy of OrderFormState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -1608,6 +1613,7 @@ abstract class $OrderFormStateCopyWith<$Res> { bool isCreating, bool isCreatingWithPayment, bool isAddingItemOrder, + String? addItemIdempotencyKey, }); $PaymentMethodCopyWith<$Res>? get paymentMethod; @@ -1638,6 +1644,7 @@ class _$OrderFormStateCopyWithImpl<$Res, $Val extends OrderFormState> Object? isCreating = null, Object? isCreatingWithPayment = null, Object? isAddingItemOrder = null, + Object? addItemIdempotencyKey = freezed, }) { return _then( _value.copyWith( @@ -1678,6 +1685,10 @@ class _$OrderFormStateCopyWithImpl<$Res, $Val extends OrderFormState> ? _value.isAddingItemOrder : isAddingItemOrder // ignore: cast_nullable_to_non_nullable as bool, + addItemIdempotencyKey: freezed == addItemIdempotencyKey + ? _value.addItemIdempotencyKey + : addItemIdempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -1731,6 +1742,7 @@ abstract class _$$OrderFormStateImplCopyWith<$Res> bool isCreating, bool isCreatingWithPayment, bool isAddingItemOrder, + String? addItemIdempotencyKey, }); @override @@ -1762,6 +1774,7 @@ class __$$OrderFormStateImplCopyWithImpl<$Res> Object? isCreating = null, Object? isCreatingWithPayment = null, Object? isAddingItemOrder = null, + Object? addItemIdempotencyKey = freezed, }) { return _then( _$OrderFormStateImpl( @@ -1801,6 +1814,10 @@ class __$$OrderFormStateImplCopyWithImpl<$Res> ? _value.isAddingItemOrder : isAddingItemOrder // ignore: cast_nullable_to_non_nullable as bool, + addItemIdempotencyKey: freezed == addItemIdempotencyKey + ? _value.addItemIdempotencyKey + : addItemIdempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -1821,6 +1838,7 @@ class _$OrderFormStateImpl this.isCreating = false, this.isCreatingWithPayment = false, this.isAddingItemOrder = false, + this.addItemIdempotencyKey, }); @override @@ -1845,9 +1863,15 @@ class _$OrderFormStateImpl @JsonKey() final bool isAddingItemOrder; + /// Idempotency key untuk add item order. + /// Di-generate saat user tap "Add Items", dipakai ulang saat retry, + /// di-clear setelah sukses. + @override + final String? addItemIdempotencyKey; + @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'OrderFormState(paymentMethod: $paymentMethod, customerName: $customerName, customer: $customer, failureOrCreateOrder: $failureOrCreateOrder, failureOrCreateOrderWithPayment: $failureOrCreateOrderWithPayment, failureOrAddItemOrder: $failureOrAddItemOrder, isCreating: $isCreating, isCreatingWithPayment: $isCreatingWithPayment, isAddingItemOrder: $isAddingItemOrder)'; + return 'OrderFormState(paymentMethod: $paymentMethod, customerName: $customerName, customer: $customer, failureOrCreateOrder: $failureOrCreateOrder, failureOrCreateOrderWithPayment: $failureOrCreateOrderWithPayment, failureOrAddItemOrder: $failureOrAddItemOrder, isCreating: $isCreating, isCreatingWithPayment: $isCreatingWithPayment, isAddingItemOrder: $isAddingItemOrder, addItemIdempotencyKey: $addItemIdempotencyKey)'; } @override @@ -1868,7 +1892,10 @@ class _$OrderFormStateImpl ..add(DiagnosticsProperty('failureOrAddItemOrder', failureOrAddItemOrder)) ..add(DiagnosticsProperty('isCreating', isCreating)) ..add(DiagnosticsProperty('isCreatingWithPayment', isCreatingWithPayment)) - ..add(DiagnosticsProperty('isAddingItemOrder', isAddingItemOrder)); + ..add(DiagnosticsProperty('isAddingItemOrder', isAddingItemOrder)) + ..add( + DiagnosticsProperty('addItemIdempotencyKey', addItemIdempotencyKey), + ); } @override @@ -1897,7 +1924,9 @@ class _$OrderFormStateImpl (identical(other.isCreatingWithPayment, isCreatingWithPayment) || other.isCreatingWithPayment == isCreatingWithPayment) && (identical(other.isAddingItemOrder, isAddingItemOrder) || - other.isAddingItemOrder == isAddingItemOrder)); + other.isAddingItemOrder == isAddingItemOrder) && + (identical(other.addItemIdempotencyKey, addItemIdempotencyKey) || + other.addItemIdempotencyKey == addItemIdempotencyKey)); } @override @@ -1912,6 +1941,7 @@ class _$OrderFormStateImpl isCreating, isCreatingWithPayment, isAddingItemOrder, + addItemIdempotencyKey, ); /// Create a copy of OrderFormState @@ -1938,6 +1968,7 @@ abstract class _OrderFormState implements OrderFormState { final bool isCreating, final bool isCreatingWithPayment, final bool isAddingItemOrder, + final String? addItemIdempotencyKey, }) = _$OrderFormStateImpl; @override @@ -1959,6 +1990,12 @@ abstract class _OrderFormState implements OrderFormState { @override bool get isAddingItemOrder; + /// Idempotency key untuk add item order. + /// Di-generate saat user tap "Add Items", dipakai ulang saat retry, + /// di-clear setelah sukses. + @override + String? get addItemIdempotencyKey; + /// Create a copy of OrderFormState /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/application/order/order_form/order_form_state.dart b/lib/application/order/order_form/order_form_state.dart index ed8b946..e832c18 100644 --- a/lib/application/order/order_form/order_form_state.dart +++ b/lib/application/order/order_form/order_form_state.dart @@ -13,6 +13,10 @@ class OrderFormState with _$OrderFormState { @Default(false) bool isCreating, @Default(false) bool isCreatingWithPayment, @Default(false) bool isAddingItemOrder, + /// Idempotency key untuk add item order. + /// Di-generate saat user tap "Add Items", dipakai ulang saat retry, + /// di-clear setelah sukses. + String? addItemIdempotencyKey, }) = _OrderFormState; factory OrderFormState.initial() => OrderFormState( diff --git a/lib/application/payment/payment_form/payment_form_bloc.dart b/lib/application/payment/payment_form/payment_form_bloc.dart index 5824921..0eccd44 100644 --- a/lib/application/payment/payment_form/payment_form_bloc.dart +++ b/lib/application/payment/payment_form/payment_form_bloc.dart @@ -4,6 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:dartz/dartz.dart' hide Order; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart' hide Order; +import 'package:uuid/uuid.dart'; import '../../../common/types/split_type.dart'; import '../../../domain/order/order.dart'; @@ -38,7 +39,15 @@ class PaymentFormBloc extends Bloc { submitted: (e) async { Either failureOrPayment; - emit(state.copyWith(isSubmitting: true, failureOrPayment: none())); + // Generate new idempotency key saat user intent (tap Bayar) + // Kalau sedang retry (isSubmitting sebelumnya gagal), pakai key yang sama + final idempotencyKey = state.idempotencyKey ?? const Uuid().v4(); + + emit(state.copyWith( + isSubmitting: true, + failureOrPayment: none(), + idempotencyKey: idempotencyKey, + )); final request = PaymentRequest( orderId: state.order.id, @@ -58,20 +67,32 @@ class PaymentFormBloc extends Bloc { .toList(), ); - failureOrPayment = await _repository.createPayment(request: request); + failureOrPayment = await _repository.createPayment( + request: request, + idempotencyKey: idempotencyKey, + ); emit( state.copyWith( isSubmitting: false, failureOrPayment: optionOf(failureOrPayment), + // Clear key on success, keep on failure for retry + idempotencyKey: failureOrPayment.isRight() ? null : idempotencyKey, ), ); }, submittedSplitBill: (e) async { Either failureOrPayment; + // Generate new idempotency key untuk split bill + final idempotencyKey = state.splitBillIdempotencyKey ?? const Uuid().v4(); + emit( - state.copyWith(isSubmitting: true, failureOrPaymentSplitBill: none()), + state.copyWith( + isSubmitting: true, + failureOrPaymentSplitBill: none(), + splitBillIdempotencyKey: idempotencyKey, + ), ); final request = PaymentSplitBillRequest( @@ -93,12 +114,18 @@ class PaymentFormBloc extends Bloc { log(request.toString()); - failureOrPayment = await _repository.createSplitBill(request); + failureOrPayment = await _repository.createSplitBill( + request, + idempotencyKey: idempotencyKey, + ); emit( state.copyWith( isSubmitting: false, failureOrPaymentSplitBill: optionOf(failureOrPayment), + // Clear key on success, keep on failure for retry + splitBillIdempotencyKey: + failureOrPayment.isRight() ? null : idempotencyKey, ), ); }, diff --git a/lib/application/payment/payment_form/payment_form_bloc.freezed.dart b/lib/application/payment/payment_form/payment_form_bloc.freezed.dart index 1ae147f..b8c7886 100644 --- a/lib/application/payment/payment_form/payment_form_bloc.freezed.dart +++ b/lib/application/payment/payment_form/payment_form_bloc.freezed.dart @@ -813,6 +813,14 @@ mixin _$PaymentFormState { PaymentMethod? get paymentMethod => throw _privateConstructorUsedError; bool get isSubmitting => throw _privateConstructorUsedError; + /// Idempotency key untuk payment submission. + /// Di-generate saat user tap "Bayar", dipakai ulang saat retry, + /// di-clear setelah sukses. + String? get idempotencyKey => throw _privateConstructorUsedError; + + /// Idempotency key untuk split bill submission. + String? get splitBillIdempotencyKey => throw _privateConstructorUsedError; + /// Create a copy of PaymentFormState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -834,6 +842,8 @@ abstract class $PaymentFormStateCopyWith<$Res> { Option> failureOrPaymentSplitBill, PaymentMethod? paymentMethod, bool isSubmitting, + String? idempotencyKey, + String? splitBillIdempotencyKey, }); $OrderCopyWith<$Res> get order; @@ -861,6 +871,8 @@ class _$PaymentFormStateCopyWithImpl<$Res, $Val extends PaymentFormState> Object? failureOrPaymentSplitBill = null, Object? paymentMethod = freezed, Object? isSubmitting = null, + Object? idempotencyKey = freezed, + Object? splitBillIdempotencyKey = freezed, }) { return _then( _value.copyWith( @@ -888,6 +900,14 @@ class _$PaymentFormStateCopyWithImpl<$Res, $Val extends PaymentFormState> ? _value.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable as bool, + idempotencyKey: freezed == idempotencyKey + ? _value.idempotencyKey + : idempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, + splitBillIdempotencyKey: freezed == splitBillIdempotencyKey + ? _value.splitBillIdempotencyKey + : splitBillIdempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -934,6 +954,8 @@ abstract class _$$PaymentFormStateImplCopyWith<$Res> Option> failureOrPaymentSplitBill, PaymentMethod? paymentMethod, bool isSubmitting, + String? idempotencyKey, + String? splitBillIdempotencyKey, }); @override @@ -962,6 +984,8 @@ class __$$PaymentFormStateImplCopyWithImpl<$Res> Object? failureOrPaymentSplitBill = null, Object? paymentMethod = freezed, Object? isSubmitting = null, + Object? idempotencyKey = freezed, + Object? splitBillIdempotencyKey = freezed, }) { return _then( _$PaymentFormStateImpl( @@ -989,6 +1013,14 @@ class __$$PaymentFormStateImplCopyWithImpl<$Res> ? _value.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable as bool, + idempotencyKey: freezed == idempotencyKey + ? _value.idempotencyKey + : idempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, + splitBillIdempotencyKey: freezed == splitBillIdempotencyKey + ? _value.splitBillIdempotencyKey + : splitBillIdempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -1004,6 +1036,8 @@ class _$PaymentFormStateImpl implements _PaymentFormState { required this.failureOrPaymentSplitBill, this.paymentMethod, this.isSubmitting = false, + this.idempotencyKey, + this.splitBillIdempotencyKey, }) : _pendingItems = pendingItems; @override @@ -1026,9 +1060,19 @@ class _$PaymentFormStateImpl implements _PaymentFormState { @JsonKey() final bool isSubmitting; + /// Idempotency key untuk payment submission. + /// Di-generate saat user tap "Bayar", dipakai ulang saat retry, + /// di-clear setelah sukses. + @override + final String? idempotencyKey; + + /// Idempotency key untuk split bill submission. + @override + final String? splitBillIdempotencyKey; + @override String toString() { - return 'PaymentFormState(order: $order, pendingItems: $pendingItems, failureOrPayment: $failureOrPayment, failureOrPaymentSplitBill: $failureOrPaymentSplitBill, paymentMethod: $paymentMethod, isSubmitting: $isSubmitting)'; + return 'PaymentFormState(order: $order, pendingItems: $pendingItems, failureOrPayment: $failureOrPayment, failureOrPaymentSplitBill: $failureOrPaymentSplitBill, paymentMethod: $paymentMethod, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey, splitBillIdempotencyKey: $splitBillIdempotencyKey)'; } @override @@ -1051,7 +1095,14 @@ class _$PaymentFormStateImpl implements _PaymentFormState { (identical(other.paymentMethod, paymentMethod) || other.paymentMethod == paymentMethod) && (identical(other.isSubmitting, isSubmitting) || - other.isSubmitting == isSubmitting)); + other.isSubmitting == isSubmitting) && + (identical(other.idempotencyKey, idempotencyKey) || + other.idempotencyKey == idempotencyKey) && + (identical( + other.splitBillIdempotencyKey, + splitBillIdempotencyKey, + ) || + other.splitBillIdempotencyKey == splitBillIdempotencyKey)); } @override @@ -1063,6 +1114,8 @@ class _$PaymentFormStateImpl implements _PaymentFormState { failureOrPaymentSplitBill, paymentMethod, isSubmitting, + idempotencyKey, + splitBillIdempotencyKey, ); /// Create a copy of PaymentFormState @@ -1086,6 +1139,8 @@ abstract class _PaymentFormState implements PaymentFormState { failureOrPaymentSplitBill, final PaymentMethod? paymentMethod, final bool isSubmitting, + final String? idempotencyKey, + final String? splitBillIdempotencyKey, }) = _$PaymentFormStateImpl; @override @@ -1101,6 +1156,16 @@ abstract class _PaymentFormState implements PaymentFormState { @override bool get isSubmitting; + /// Idempotency key untuk payment submission. + /// Di-generate saat user tap "Bayar", dipakai ulang saat retry, + /// di-clear setelah sukses. + @override + String? get idempotencyKey; + + /// Idempotency key untuk split bill submission. + @override + String? get splitBillIdempotencyKey; + /// Create a copy of PaymentFormState /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/application/payment/payment_form/payment_form_state.dart b/lib/application/payment/payment_form/payment_form_state.dart index c5fb5bb..fe8d1f9 100644 --- a/lib/application/payment/payment_form/payment_form_state.dart +++ b/lib/application/payment/payment_form/payment_form_state.dart @@ -9,6 +9,12 @@ class PaymentFormState with _$PaymentFormState { required Option> failureOrPaymentSplitBill, PaymentMethod? paymentMethod, @Default(false) bool isSubmitting, + /// Idempotency key untuk payment submission. + /// Di-generate saat user tap "Bayar", dipakai ulang saat retry, + /// di-clear setelah sukses. + String? idempotencyKey, + /// Idempotency key untuk split bill submission. + String? splitBillIdempotencyKey, }) = _PaymentFormState; factory PaymentFormState.initial() => PaymentFormState( diff --git a/lib/application/refund/refund_form/refund_form_bloc.dart b/lib/application/refund/refund_form/refund_form_bloc.dart index 2f47307..7aa030a 100644 --- a/lib/application/refund/refund_form/refund_form_bloc.dart +++ b/lib/application/refund/refund_form/refund_form_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:dartz/dartz.dart' hide Order; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart' hide Order; +import 'package:uuid/uuid.dart'; import '../../../common/data/refund_data.dart'; import '../../../domain/order/order.dart'; @@ -34,7 +35,15 @@ class RefundFormBloc extends Bloc { submitted: (e) async { Either? failureOrRefund; - emit(state.copyWith(isSubmitting: true, failureOrRefund: none())); + // Generate new idempotency key saat user intent (tap Refund) + // Kalau sedang retry, pakai key yang sama + final idempotencyKey = state.idempotencyKey ?? const Uuid().v4(); + + emit(state.copyWith( + isSubmitting: true, + failureOrRefund: none(), + idempotencyKey: idempotencyKey, + )); failureOrRefund = await _repository.refundOrder( id: state.order.id, @@ -42,12 +51,15 @@ class RefundFormBloc extends Bloc { ? state.reason : state.refundReason?.value ?? '', refundAmount: state.order.totalAmount, + idempotencyKey: idempotencyKey, ); emit( state.copyWith( isSubmitting: false, failureOrRefund: optionOf(failureOrRefund), + // Clear key on success, keep on failure for retry + idempotencyKey: failureOrRefund.isRight() ? null : idempotencyKey, ), ); }, diff --git a/lib/application/refund/refund_form/refund_form_bloc.freezed.dart b/lib/application/refund/refund_form/refund_form_bloc.freezed.dart index 904bc3b..95265af 100644 --- a/lib/application/refund/refund_form/refund_form_bloc.freezed.dart +++ b/lib/application/refund/refund_form/refund_form_bloc.freezed.dart @@ -692,6 +692,11 @@ mixin _$RefundFormState { throw _privateConstructorUsedError; bool get isSubmitting => throw _privateConstructorUsedError; + /// Idempotency key untuk refund submission. + /// Di-generate saat user tap "Refund", dipakai ulang saat retry, + /// di-clear setelah sukses. + String? get idempotencyKey => throw _privateConstructorUsedError; + /// Create a copy of RefundFormState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -712,6 +717,7 @@ abstract class $RefundFormStateCopyWith<$Res> { RefundReason? refundReason, Option> failureOrRefund, bool isSubmitting, + String? idempotencyKey, }); $OrderCopyWith<$Res> get order; @@ -737,6 +743,7 @@ class _$RefundFormStateCopyWithImpl<$Res, $Val extends RefundFormState> Object? refundReason = freezed, Object? failureOrRefund = null, Object? isSubmitting = null, + Object? idempotencyKey = freezed, }) { return _then( _value.copyWith( @@ -760,6 +767,10 @@ class _$RefundFormStateCopyWithImpl<$Res, $Val extends RefundFormState> ? _value.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable as bool, + idempotencyKey: freezed == idempotencyKey + ? _value.idempotencyKey + : idempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -791,6 +802,7 @@ abstract class _$$RefundFormStateImplCopyWith<$Res> RefundReason? refundReason, Option> failureOrRefund, bool isSubmitting, + String? idempotencyKey, }); @override @@ -816,6 +828,7 @@ class __$$RefundFormStateImplCopyWithImpl<$Res> Object? refundReason = freezed, Object? failureOrRefund = null, Object? isSubmitting = null, + Object? idempotencyKey = freezed, }) { return _then( _$RefundFormStateImpl( @@ -839,6 +852,10 @@ class __$$RefundFormStateImplCopyWithImpl<$Res> ? _value.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable as bool, + idempotencyKey: freezed == idempotencyKey + ? _value.idempotencyKey + : idempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -853,6 +870,7 @@ class _$RefundFormStateImpl implements _RefundFormState { this.refundReason, required this.failureOrRefund, this.isSubmitting = false, + this.idempotencyKey, }); @override @@ -867,9 +885,15 @@ class _$RefundFormStateImpl implements _RefundFormState { @JsonKey() final bool isSubmitting; + /// Idempotency key untuk refund submission. + /// Di-generate saat user tap "Refund", dipakai ulang saat retry, + /// di-clear setelah sukses. + @override + final String? idempotencyKey; + @override String toString() { - return 'RefundFormState(order: $order, reason: $reason, refundReason: $refundReason, failureOrRefund: $failureOrRefund, isSubmitting: $isSubmitting)'; + return 'RefundFormState(order: $order, reason: $reason, refundReason: $refundReason, failureOrRefund: $failureOrRefund, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey)'; } @override @@ -884,7 +908,9 @@ class _$RefundFormStateImpl implements _RefundFormState { (identical(other.failureOrRefund, failureOrRefund) || other.failureOrRefund == failureOrRefund) && (identical(other.isSubmitting, isSubmitting) || - other.isSubmitting == isSubmitting)); + other.isSubmitting == isSubmitting) && + (identical(other.idempotencyKey, idempotencyKey) || + other.idempotencyKey == idempotencyKey)); } @override @@ -895,6 +921,7 @@ class _$RefundFormStateImpl implements _RefundFormState { refundReason, failureOrRefund, isSubmitting, + idempotencyKey, ); /// Create a copy of RefundFormState @@ -916,6 +943,7 @@ abstract class _RefundFormState implements RefundFormState { final RefundReason? refundReason, required final Option> failureOrRefund, final bool isSubmitting, + final String? idempotencyKey, }) = _$RefundFormStateImpl; @override @@ -929,6 +957,12 @@ abstract class _RefundFormState implements RefundFormState { @override bool get isSubmitting; + /// Idempotency key untuk refund submission. + /// Di-generate saat user tap "Refund", dipakai ulang saat retry, + /// di-clear setelah sukses. + @override + String? get idempotencyKey; + /// Create a copy of RefundFormState /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/application/refund/refund_form/refund_form_state.dart b/lib/application/refund/refund_form/refund_form_state.dart index ae19f02..093ae67 100644 --- a/lib/application/refund/refund_form/refund_form_state.dart +++ b/lib/application/refund/refund_form/refund_form_state.dart @@ -8,6 +8,10 @@ class RefundFormState with _$RefundFormState { RefundReason? refundReason, required Option> failureOrRefund, @Default(false) bool isSubmitting, + /// Idempotency key untuk refund submission. + /// Di-generate saat user tap "Refund", dipakai ulang saat retry, + /// di-clear setelah sukses. + String? idempotencyKey, }) = _RefundFormState; factory RefundFormState.initial() => RefundFormState( diff --git a/lib/application/void/void_form/void_form_bloc.dart b/lib/application/void/void_form/void_form_bloc.dart index 545d350..3e5b415 100644 --- a/lib/application/void/void_form/void_form_bloc.dart +++ b/lib/application/void/void_form/void_form_bloc.dart @@ -3,6 +3,7 @@ import 'package:collection/collection.dart'; import 'package:dartz/dartz.dart' hide Order; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart' hide Order; +import 'package:uuid/uuid.dart'; import '../../../common/types/void_type.dart'; import '../../../domain/order/order.dart'; @@ -105,13 +106,22 @@ class VoidFormBloc extends Bloc { }); } - emit(state.copyWith(isSubmitting: true, failureOrVoid: none())); + // Generate new idempotency key saat user intent (tap Void) + // Kalau sedang retry, pakai key yang sama + final idempotencyKey = state.idempotencyKey ?? const Uuid().v4(); + + emit(state.copyWith( + isSubmitting: true, + failureOrVoid: none(), + idempotencyKey: idempotencyKey, + )); failureOrVoid = await _repository.voidOrder( orderId: state.order.id, reason: state.voidReason ?? '', orderItems: voidItems, type: state.voidType.toStringType(), + idempotencyKey: idempotencyKey, ); emit( @@ -119,6 +129,8 @@ class VoidFormBloc extends Bloc { isSubmitting: false, failureOrVoid: optionOf(failureOrVoid), voidItems: state.voidType.isItem ? voidItems : state.pendingItems, + // Clear key on success, keep on failure for retry + idempotencyKey: failureOrVoid.isRight() ? null : idempotencyKey, ), ); }, diff --git a/lib/application/void/void_form/void_form_bloc.freezed.dart b/lib/application/void/void_form/void_form_bloc.freezed.dart index 612fa3a..44e832a 100644 --- a/lib/application/void/void_form/void_form_bloc.freezed.dart +++ b/lib/application/void/void_form/void_form_bloc.freezed.dart @@ -1256,6 +1256,11 @@ mixin _$VoidFormState { throw _privateConstructorUsedError; bool get isSubmitting => throw _privateConstructorUsedError; + /// Idempotency key untuk void submission. + /// Di-generate saat user tap "Void", dipakai ulang saat retry, + /// di-clear setelah sukses atau clearState. + String? get idempotencyKey => throw _privateConstructorUsedError; + /// Create a copy of VoidFormState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -1280,6 +1285,7 @@ abstract class $VoidFormStateCopyWith<$Res> { int totalPriceVoid, Option> failureOrVoid, bool isSubmitting, + String? idempotencyKey, }); $OrderCopyWith<$Res> get order; @@ -1309,6 +1315,7 @@ class _$VoidFormStateCopyWithImpl<$Res, $Val extends VoidFormState> Object? totalPriceVoid = null, Object? failureOrVoid = null, Object? isSubmitting = null, + Object? idempotencyKey = freezed, }) { return _then( _value.copyWith( @@ -1348,6 +1355,10 @@ class _$VoidFormStateCopyWithImpl<$Res, $Val extends VoidFormState> ? _value.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable as bool, + idempotencyKey: freezed == idempotencyKey + ? _value.idempotencyKey + : idempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -1383,6 +1394,7 @@ abstract class _$$VoidFormStateImplCopyWith<$Res> int totalPriceVoid, Option> failureOrVoid, bool isSubmitting, + String? idempotencyKey, }); @override @@ -1412,6 +1424,7 @@ class __$$VoidFormStateImplCopyWithImpl<$Res> Object? totalPriceVoid = null, Object? failureOrVoid = null, Object? isSubmitting = null, + Object? idempotencyKey = freezed, }) { return _then( _$VoidFormStateImpl( @@ -1451,6 +1464,10 @@ class __$$VoidFormStateImplCopyWithImpl<$Res> ? _value.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable as bool, + idempotencyKey: freezed == idempotencyKey + ? _value.idempotencyKey + : idempotencyKey // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -1469,6 +1486,7 @@ class _$VoidFormStateImpl implements _VoidFormState { required this.totalPriceVoid, required this.failureOrVoid, this.isSubmitting = false, + this.idempotencyKey, }) : _pendingItems = pendingItems, _voidItems = voidItems, _selectedItemQuantities = selectedItemQuantities; @@ -1512,9 +1530,15 @@ class _$VoidFormStateImpl implements _VoidFormState { @JsonKey() final bool isSubmitting; + /// Idempotency key untuk void submission. + /// Di-generate saat user tap "Void", dipakai ulang saat retry, + /// di-clear setelah sukses atau clearState. + @override + final String? idempotencyKey; + @override String toString() { - return 'VoidFormState(order: $order, pendingItems: $pendingItems, voidItems: $voidItems, voidType: $voidType, selectedItemQuantities: $selectedItemQuantities, voidReason: $voidReason, totalPriceVoid: $totalPriceVoid, failureOrVoid: $failureOrVoid, isSubmitting: $isSubmitting)'; + return 'VoidFormState(order: $order, pendingItems: $pendingItems, voidItems: $voidItems, voidType: $voidType, selectedItemQuantities: $selectedItemQuantities, voidReason: $voidReason, totalPriceVoid: $totalPriceVoid, failureOrVoid: $failureOrVoid, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey)'; } @override @@ -1544,7 +1568,9 @@ class _$VoidFormStateImpl implements _VoidFormState { (identical(other.failureOrVoid, failureOrVoid) || other.failureOrVoid == failureOrVoid) && (identical(other.isSubmitting, isSubmitting) || - other.isSubmitting == isSubmitting)); + other.isSubmitting == isSubmitting) && + (identical(other.idempotencyKey, idempotencyKey) || + other.idempotencyKey == idempotencyKey)); } @override @@ -1559,6 +1585,7 @@ class _$VoidFormStateImpl implements _VoidFormState { totalPriceVoid, failureOrVoid, isSubmitting, + idempotencyKey, ); /// Create a copy of VoidFormState @@ -1581,6 +1608,7 @@ abstract class _VoidFormState implements VoidFormState { required final int totalPriceVoid, required final Option> failureOrVoid, final bool isSubmitting, + final String? idempotencyKey, }) = _$VoidFormStateImpl; @override @@ -1602,6 +1630,12 @@ abstract class _VoidFormState implements VoidFormState { @override bool get isSubmitting; + /// Idempotency key untuk void submission. + /// Di-generate saat user tap "Void", dipakai ulang saat retry, + /// di-clear setelah sukses atau clearState. + @override + String? get idempotencyKey; + /// Create a copy of VoidFormState /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/application/void/void_form/void_form_state.dart b/lib/application/void/void_form/void_form_state.dart index b094324..6fadb62 100644 --- a/lib/application/void/void_form/void_form_state.dart +++ b/lib/application/void/void_form/void_form_state.dart @@ -12,6 +12,10 @@ class VoidFormState with _$VoidFormState { required int totalPriceVoid, required Option> failureOrVoid, @Default(false) bool isSubmitting, + /// Idempotency key untuk void submission. + /// Di-generate saat user tap "Void", dipakai ulang saat retry, + /// di-clear setelah sukses atau clearState. + String? idempotencyKey, }) = _VoidFormState; factory VoidFormState.initial() => VoidFormState( diff --git a/lib/common/api/api_client.dart b/lib/common/api/api_client.dart index 0584967..f32adef 100644 --- a/lib/common/api/api_client.dart +++ b/lib/common/api/api_client.dart @@ -16,6 +16,7 @@ import 'interceptors/bad_network_interceptor.dart'; import 'interceptors/bad_request_interceptor.dart'; import 'interceptors/connection_timeout_interceptor.dart'; import 'interceptors/crashlytic_interceptor.dart'; +import 'interceptors/idempotency_interceptor.dart'; import 'interceptors/internal_server_interceptor.dart'; import 'interceptors/not_found_interceptor.dart'; import 'interceptors/unauthorized_interceptor.dart'; @@ -30,6 +31,7 @@ class ApiClient { _dio.options.connectTimeout = const Duration(seconds: 20); _dio.options.validateStatus = (status) => status != null && status >= 200 && status < 500; + _dio.interceptors.add(IdempotencyInterceptor(_dio)); _dio.interceptors.add(BadNetworkErrorInterceptor()); _dio.interceptors.add(BadRequestErrorInterceptor()); _dio.interceptors.add(InternalServerErrorInterceptor()); diff --git a/lib/common/api/interceptors/idempotency_interceptor.dart b/lib/common/api/interceptors/idempotency_interceptor.dart new file mode 100644 index 0000000..02cd432 --- /dev/null +++ b/lib/common/api/interceptors/idempotency_interceptor.dart @@ -0,0 +1,104 @@ +import 'package:dio/dio.dart'; +import 'package:uuid/uuid.dart'; + +/// Interceptor yang secara otomatis menambahkan header X-Idempotency-Key +/// untuk endpoint-endpoint kritis yang memerlukan idempotency. +/// +/// Key sebaiknya di-generate di level BLoC/Cubit saat user trigger action, +/// lalu dikirim via headers. Interceptor ini hanya berfungsi sebagai fallback +/// jika key belum di-set. +/// +/// Jika response 409 dengan error code `request_in_progress`, interceptor +/// akan retry setelah delay 1-2 detik. +class IdempotencyInterceptor extends Interceptor { + static const _headerKey = 'X-Idempotency-Key'; + static const _replayHeader = 'X-Idempotent-Replay'; + + // Endpoints that require idempotency keys (exact match) + static const _idempotentPaths = [ + '/api/v1/payments', + '/api/v1/orders/void', + ]; + + // Endpoints that require idempotency keys (pattern match) + static final _idempotentPathPatterns = [ + RegExp(r'/api/v1/orders/.+/add-items$'), + RegExp(r'/api/v1/orders/.+/refund$'), + RegExp(r'/api/v1/payments/.+/refund$'), + ]; + + static const _maxRetries = 3; + static const _retryDelay = Duration(seconds: 2); + + final Dio _dio; + + IdempotencyInterceptor(this._dio); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (options.method == 'POST' && _requiresIdempotencyKey(options.path)) { + // Gunakan key yang sudah di-set dari BLoC, atau generate baru sebagai fallback + options.headers[_headerKey] ??= const Uuid().v4(); + } + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + // Log jika response adalah replay dari request sebelumnya + final isReplay = response.headers.value(_replayHeader); + if (isReplay == 'true') { + // Response ini adalah hasil dari request pertama yang sudah diproses. + // Treat as success — tidak perlu perlakuan khusus. + } + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + final response = err.response; + final options = err.requestOptions; + + if (response == null) { + handler.next(err); + return; + } + + // Handle 409 Conflict with request_in_progress + if (response.statusCode == 409 && + _isRequestInProgress(response.data) && + _requiresIdempotencyKey(options.path)) { + final retryCount = options.extra['_idempotency_retry_count'] ?? 0; + + if (retryCount < _maxRetries) { + await Future.delayed(_retryDelay); + + options.extra['_idempotency_retry_count'] = retryCount + 1; + + try { + final retryResponse = await _dio.fetch(options); + handler.resolve(retryResponse); + } on DioException catch (e) { + handler.next(e); + } + return; + } + } + + handler.next(err); + } + + bool _requiresIdempotencyKey(String path) { + if (_idempotentPaths.any((p) => path.endsWith(p))) return true; + if (_idempotentPathPatterns.any((r) => r.hasMatch(path))) return true; + return false; + } + + bool _isRequestInProgress(dynamic data) { + if (data is Map) { + final errorCode = data['error_code'] ?? data['code'] ?? ''; + return errorCode == 'request_in_progress'; + } + return false; + } +} diff --git a/lib/common/database/database_helper.dart b/lib/common/database/database_helper.dart index 6842641..9d3d666 100644 --- a/lib/common/database/database_helper.dart +++ b/lib/common/database/database_helper.dart @@ -17,7 +17,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 1, // Updated version for categories table + version: 2, onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -38,6 +38,7 @@ class DatabaseHelper { business_type TEXT, image_url TEXT, printer_type TEXT, + print_to_checker INTEGER DEFAULT 0, metadata TEXT, is_active INTEGER, created_at TEXT, @@ -107,7 +108,13 @@ class DatabaseHelper { await db.execute('CREATE INDEX idx_printers_type ON printers(type)'); } - Future _onUpgrade(Database db, int oldVersion, int newVersion) async {} + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + await db.execute( + 'ALTER TABLE products ADD COLUMN print_to_checker INTEGER DEFAULT 0', + ); + } + } Future close() async { final db = await database; diff --git a/lib/domain/order/repositories/i_order_repository.dart b/lib/domain/order/repositories/i_order_repository.dart index 811d962..f27ddd6 100644 --- a/lib/domain/order/repositories/i_order_repository.dart +++ b/lib/domain/order/repositories/i_order_repository.dart @@ -24,10 +24,12 @@ abstract class IOrderRepository { Future> addItemOrder({ required String id, required List request, + String? idempotencyKey, }); Future> createPayment({ required PaymentRequest request, + String? idempotencyKey, }); Future> voidOrder({ @@ -35,15 +37,18 @@ abstract class IOrderRepository { required String reason, String type = "ITEM", // TYPE: ALL, ITEM required List orderItems, + String? idempotencyKey, }); Future> createSplitBill( - PaymentSplitBillRequest request, - ); + PaymentSplitBillRequest request, { + String? idempotencyKey, + }); Future> refundOrder({ required String id, required String reason, required int refundAmount, + String? idempotencyKey, }); } diff --git a/lib/infrastructure/order/datasources/remote_data_provider.dart b/lib/infrastructure/order/datasources/remote_data_provider.dart index 35ec899..16bc31a 100644 --- a/lib/infrastructure/order/datasources/remote_data_provider.dart +++ b/lib/infrastructure/order/datasources/remote_data_provider.dart @@ -18,6 +18,15 @@ class OrderRemoteDataProvider { final _logName = 'OrderRemoteDataProvider'; OrderRemoteDataProvider(this._apiClient); + /// Helper untuk merge auth header dengan idempotency key + Map _buildHeaders({String? idempotencyKey}) { + final headers = getAuthorizationHeader(); + if (idempotencyKey != null) { + headers['X-Idempotency-Key'] = idempotencyKey; + } + return headers; + } + Future> fetchOrders({ int page = 1, int limit = 10, @@ -151,13 +160,14 @@ class OrderRemoteDataProvider { } Future> storePayment( - PaymentRequestDto request, - ) async { + PaymentRequestDto request, { + String? idempotencyKey, + }) async { try { final response = await _apiClient.post( ApiPath.payments, data: request.toJson(), - headers: getAuthorizationHeader(), + headers: _buildHeaders(idempotencyKey: idempotencyKey), ); if (response.data['success'] == false) { @@ -178,6 +188,7 @@ class OrderRemoteDataProvider { Future> addItemOrder({ required String id, required List request, + String? idempotencyKey, }) async { try { final response = await _apiClient.post( @@ -186,7 +197,7 @@ class OrderRemoteDataProvider { 'notes': '', 'order_items': request.map((e) => e.toRequest()).toList(), }, - headers: getAuthorizationHeader(), + headers: _buildHeaders(idempotencyKey: idempotencyKey), ); if (response.data['success'] == false) { @@ -209,6 +220,7 @@ class OrderRemoteDataProvider { required String reason, String type = "ITEM", // TYPE: ALL, ITEM required List orderItems, + String? idempotencyKey, }) async { try { final response = await _apiClient.post( @@ -223,7 +235,7 @@ class OrderRemoteDataProvider { ) .toList(), }, - headers: getAuthorizationHeader(), + headers: _buildHeaders(idempotencyKey: idempotencyKey), ); if (response.data['success'] == false) { @@ -238,14 +250,15 @@ class OrderRemoteDataProvider { } Future> createSplitBill( - PaymentSplitBillRequestDto request, - ) async { + PaymentSplitBillRequestDto request, { + String? idempotencyKey, + }) async { log(request.toRequest().toString()); try { final response = await _apiClient.post( "${ApiPath.orders}/split-bill", data: request.toRequest(), - headers: getAuthorizationHeader(), + headers: _buildHeaders(idempotencyKey: idempotencyKey), ); if (response.data['success'] == false) { @@ -267,12 +280,13 @@ class OrderRemoteDataProvider { required String id, required String reason, required int refundAmount, + String? idempotencyKey, }) async { try { final response = await _apiClient.post( '${ApiPath.orders}/$id/refund', data: {'refund_amount': refundAmount, 'reason': reason}, - headers: getAuthorizationHeader(), + headers: _buildHeaders(idempotencyKey: idempotencyKey), ); if (response.data['success'] == false) { diff --git a/lib/infrastructure/order/repositories/order_repository.dart b/lib/infrastructure/order/repositories/order_repository.dart index 34577f4..acbe478 100644 --- a/lib/infrastructure/order/repositories/order_repository.dart +++ b/lib/infrastructure/order/repositories/order_repository.dart @@ -110,6 +110,7 @@ class OrderRepository implements IOrderRepository { Future> addItemOrder({ required String id, required List request, + String? idempotencyKey, }) async { try { final result = await _dataProvider.addItemOrder( @@ -117,6 +118,7 @@ class OrderRepository implements IOrderRepository { request: request .map((e) => AddItemOrderRequestDto.fromDomain(e)) .toList(), + idempotencyKey: idempotencyKey, ); if (result.hasError) { @@ -134,10 +136,12 @@ class OrderRepository implements IOrderRepository { @override Future> createPayment({ required PaymentRequest request, + String? idempotencyKey, }) async { try { final result = await _dataProvider.storePayment( PaymentRequestDto.fromDomain(request), + idempotencyKey: idempotencyKey, ); if (result.hasError) { @@ -158,6 +162,7 @@ class OrderRepository implements IOrderRepository { required String reason, String type = "ITEM", required List orderItems, + String? idempotencyKey, }) async { try { final result = await _dataProvider.voidOrder( @@ -165,6 +170,7 @@ class OrderRepository implements IOrderRepository { reason: reason, type: type, orderItems: orderItems.map((e) => OrderItemDto.fromDomain(e)).toList(), + idempotencyKey: idempotencyKey, ); if (result.hasError) { @@ -180,11 +186,13 @@ class OrderRepository implements IOrderRepository { @override Future> createSplitBill( - PaymentSplitBillRequest request, - ) async { + PaymentSplitBillRequest request, { + String? idempotencyKey, + }) async { try { final result = await _dataProvider.createSplitBill( PaymentSplitBillRequestDto.fromDomain(request), + idempotencyKey: idempotencyKey, ); if (result.hasError) { @@ -204,12 +212,14 @@ class OrderRepository implements IOrderRepository { required String id, required String reason, required int refundAmount, + String? idempotencyKey, }) async { try { final result = await _dataProvider.refundPayment( id: id, reason: reason, refundAmount: refundAmount, + idempotencyKey: idempotencyKey, ); if (result.hasError) { return left(result.error!); diff --git a/lib/presentation/components/dialog/order/order_void_confirm_dialog.dart b/lib/presentation/components/dialog/order/order_void_confirm_dialog.dart index d165fec..5628674 100644 --- a/lib/presentation/components/dialog/order/order_void_confirm_dialog.dart +++ b/lib/presentation/components/dialog/order/order_void_confirm_dialog.dart @@ -28,18 +28,20 @@ class OrderVoidConfirmDialog extends StatelessWidget { children: [ Expanded( child: AppElevatedButton.outlined( - onPressed: () => context.maybePop(), + onPressed: state.isSubmitting ? null : () => context.maybePop(), label: 'Batal', ), ), const SpaceWidth(16), Expanded( child: AppElevatedButton.filled( - onPressed: () { - context.read().add( - const VoidFormEvent.submitted(), - ); - }, + onPressed: state.isSubmitting + ? null + : () { + context.read().add( + const VoidFormEvent.submitted(), + ); + }, label: state.voidType.isAll ? 'Void Pesanan' : 'Void Item', isLoading: state.isSubmitting, ), diff --git a/pubspec.lock b/pubspec.lock index bb4c7a8..e880e30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1374,7 +1374,7 @@ packages: source: hosted version: "1.4.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index 6f90891..1870e13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: table_calendar: ^3.1.2 synchronized: ^3.4.0 collection: ^1.19.1 + uuid: ^4.5.1 dev_dependencies: flutter_test: