feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run

This commit is contained in:
efrilm
2026-10-10 02:33:16 +07:00
parent e5515d5871
commit 38850b281f
104 changed files with 7122 additions and 8063 deletions
@@ -0,0 +1,101 @@
import 'dart:developer';
import 'package:data_channel/data_channel.dart';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../common/api/api_client.dart';
import '../../../common/api/api_failure.dart';
import '../../../common/api/interceptors/sensitive_aware_log_interceptor.dart';
import '../../../common/function/app_function.dart';
import '../../../common/url/api_path.dart';
import '../../../domain/wallet/wallet.dart';
import '../wallet_dtos.dart';
import '../wallet_response.dart';
/// Katalog, tukar, dan riwayat voucher (§9).
@injectable
class VoucherRemoteDataProvider {
final ApiClient _apiClient;
final String _logName = 'VoucherRemoteDataProvider';
VoucherRemoteDataProvider(this._apiClient);
Future<DC<WalletFailure, List<VoucherDto>>> fetchVouchers() => _request(
'fetchVouchers',
() => _apiClient.get(ApiPath.vouchers, headers: getAuthorizationHeader()),
(response) => ((response.data as Map)['data'] as List? ?? [])
.map((e) => VoucherDto.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
);
Future<DC<WalletFailure, VoucherRedemptionDto>> redeem({
required String voucherId,
required String pin,
required String idempotencyKey,
}) => _request(
'redeem',
() => _apiClient.post(
ApiPath.voucherRedeem(voucherId),
headers: {...getAuthorizationHeader(), 'Idempotency-Key': idempotencyKey},
data: {'pin': pin},
extra: {kSensitiveRequest: true},
),
(response) => VoucherRedemptionDto.fromJson(walletDataOf(response)),
);
Future<DC<WalletFailure, VoucherRedemptionPageDto>> fetchRedemptions({
required int page,
required int limit,
}) => _request(
'fetchRedemptions',
() => _apiClient.get(
ApiPath.voucherRedemptions,
headers: getAuthorizationHeader(),
params: {'page': page, 'limit': limit},
),
_parseRedemptionPage,
);
/// `data` bisa berupa list langsung dengan `pagination` di envelope, atau
/// objek `{ data, pagination }`.
VoucherRedemptionPageDto _parseRedemptionPage(Response response) {
final body = response.data as Map;
final payload = body['data'];
final rawItems = payload is Map ? payload['data'] : payload;
final rawPagination = payload is Map
? payload['pagination']
: body['pagination'];
return VoucherRedemptionPageDto(
items: (rawItems as List? ?? [])
.map(
(e) => VoucherRedemptionDto.fromJson(
Map<String, dynamic>.from(e as Map),
),
)
.toList(),
pagination: rawPagination is Map
? WalletPaginationDto.fromJson(
Map<String, dynamic>.from(rawPagination),
)
: const WalletPaginationDto(),
);
}
Future<DC<WalletFailure, T>> _request<T>(
String name,
Future<Response> Function() call,
T Function(Response response) parse,
) async {
try {
final response = await call();
final failure = parseWalletFailure(response);
if (failure != null) return DC.error(failure);
return DC.data(parse(response));
} on ApiFailure catch (e, s) {
log(name, name: _logName, error: e, stackTrace: s);
return DC.error(WalletFailure.serverError(e));
}
}
}
@@ -60,19 +60,6 @@ class WalletRemoteDataProvider {
(response) => WalletExpiringDto.fromJson(walletDataOf(response)),
);
Future<DC<WalletFailure, PaymentCodeDto>> createPaymentCode({
required String pin,
}) => _request(
'createPaymentCode',
() => _apiClient.post(
ApiPath.walletPaymentCode,
headers: getAuthorizationHeader(),
data: {'pin': pin},
extra: {kSensitiveRequest: true},
),
(response) => PaymentCodeDto.fromJson(walletDataOf(response)),
);
Future<DC<WalletFailure, ExchangePreviewDto>> previewExchange({
required int coins,
}) => _request(
@@ -100,7 +100,6 @@ abstract class CustomerOrderPaymentDto with _$CustomerOrderPaymentDto {
@JsonKey(name: 'method_type') String? methodType,
@JsonKey(name: 'amount') int? amount,
@JsonKey(name: 'refund_amount') int? refundAmount,
@JsonKey(name: 'points_used') int? pointsUsed,
}) = _CustomerOrderPaymentDto;
factory CustomerOrderPaymentDto.fromJson(Map<String, dynamic> json) =>
@@ -114,7 +113,6 @@ abstract class CustomerOrderPaymentDto with _$CustomerOrderPaymentDto {
methodType: methodType ?? '',
amount: amount ?? 0,
refundAmount: refundAmount ?? 0,
pointsUsed: pointsUsed,
);
}
@@ -6,7 +6,6 @@ abstract class OutletDto with _$OutletDto {
@JsonKey(name: 'id') String? id,
@JsonKey(name: 'name') String? name,
@JsonKey(name: 'address') String? address,
@JsonKey(name: 'accepts_point_payment') bool? acceptsPointPayment,
@JsonKey(name: 'earns_points') bool? earnsPoints,
@JsonKey(name: 'earns_coins') bool? earnsCoins,
}) = _OutletDto;
@@ -20,7 +19,6 @@ abstract class OutletDto with _$OutletDto {
id: id ?? '',
name: name ?? '',
address: (address?.trim().isEmpty ?? true) ? null : address!.trim(),
acceptsPointPayment: acceptsPointPayment ?? false,
earnsPoints: earnsPoints ?? false,
earnsCoins: earnsCoins ?? false,
);
@@ -1,24 +0,0 @@
part of '../wallet_dtos.dart';
@freezed
abstract class PaymentCodeDto with _$PaymentCodeDto {
const factory PaymentCodeDto({
@JsonKey(name: 'code') String? code,
@JsonKey(name: 'qr_payload') String? qrPayload,
@JsonKey(name: 'expires_at') String? expiresAt,
}) = _PaymentCodeDto;
factory PaymentCodeDto.fromJson(Map<String, dynamic> json) =>
_$PaymentCodeDtoFromJson(json);
const PaymentCodeDto._();
PaymentCode toDomain() => PaymentCode(
code: code ?? '',
qrPayload: qrPayload ?? '',
// Kode berlaku 2 menit; dipakai hanya bila server tidak mengirim expires_at.
expiresAt:
_parseDateTime(expiresAt) ??
DateTime.now().add(const Duration(minutes: 2)),
);
}
@@ -0,0 +1,103 @@
part of '../wallet_dtos.dart';
/// `terms` belum dibaca: bentuknya masih disepakati dengan tim backoffice,
/// sementara itu yang ditampilkan `description` saja (§9.1).
@freezed
abstract class VoucherDto with _$VoucherDto {
const factory VoucherDto({
@JsonKey(name: 'id') String? id,
@JsonKey(name: 'name') String? name,
@JsonKey(name: 'description') String? description,
@JsonKey(name: 'image_url') String? imageUrl,
@JsonKey(name: 'voucher_type') String? voucherType,
@JsonKey(name: 'face_value') int? faceValue,
@JsonKey(name: 'point_cost') int? pointCost,
@JsonKey(name: 'max_per_customer') int? maxPerCustomer,
@JsonKey(name: 'valid_until') String? validUntil,
@JsonKey(name: 'available') int? available,
}) = _VoucherDto;
factory VoucherDto.fromJson(Map<String, dynamic> json) =>
_$VoucherDtoFromJson(json);
const VoucherDto._();
Voucher toDomain() => Voucher(
id: id ?? '',
name: name ?? '',
description: (description?.trim().isEmpty ?? true) ? null : description,
imageUrl: (imageUrl?.isEmpty ?? true) ? null : imageUrl,
type: VoucherType.fromApi(voucherType),
faceValue: faceValue ?? 0,
pointCost: pointCost ?? 0,
maxPerCustomer: maxPerCustomer,
validUntil: _parseDateTime(validUntil),
available: available,
);
}
/// Item "Voucher saya" dan response tukar voucher; [pointBalance] dan
/// [replayed] hanya ada di response tukar.
@freezed
abstract class VoucherRedemptionDto with _$VoucherRedemptionDto {
const factory VoucherRedemptionDto({
@JsonKey(name: 'id') String? id,
@JsonKey(name: 'voucher_id') String? voucherId,
@JsonKey(name: 'voucher_name') String? voucherName,
@JsonKey(name: 'voucher_image_url') String? voucherImageUrl,
@JsonKey(name: 'voucher_type') String? voucherType,
@JsonKey(name: 'status') String? status,
@JsonKey(name: 'face_value') int? faceValue,
@JsonKey(name: 'point_cost') int? pointCost,
@JsonKey(name: 'code') String? code,
@JsonKey(name: 'code_expires_at') String? codeExpiresAt,
@JsonKey(name: 'completed_at') String? completedAt,
@JsonKey(name: 'created_at') String? createdAt,
@JsonKey(name: 'point_balance') int? pointBalance,
@JsonKey(name: 'replayed') bool? replayed,
}) = _VoucherRedemptionDto;
factory VoucherRedemptionDto.fromJson(Map<String, dynamic> json) =>
_$VoucherRedemptionDtoFromJson(json);
const VoucherRedemptionDto._();
VoucherRedemption toDomain() => VoucherRedemption(
id: id ?? '',
voucherId: voucherId ?? '',
voucherName: voucherName ?? '',
voucherImageUrl: (voucherImageUrl?.isEmpty ?? true)
? null
: voucherImageUrl,
voucherType: VoucherType.fromApi(voucherType),
status: VoucherRedemptionStatus.fromApi(status),
faceValue: faceValue ?? 0,
pointCost: pointCost ?? 0,
code: (code?.isEmpty ?? true) ? null : code,
codeExpiresAt: _parseDateTime(codeExpiresAt),
completedAt: _parseDateTime(completedAt),
createdAt: _parseDateTime(createdAt),
);
VoucherRedeemResult toRedeemResult() => VoucherRedeemResult(
redemption: toDomain(),
pointBalance: pointBalance ?? 0,
replayed: replayed ?? false,
);
}
class VoucherRedemptionPageDto {
final List<VoucherRedemptionDto> items;
final WalletPaginationDto pagination;
const VoucherRedemptionPageDto({
required this.items,
required this.pagination,
});
VoucherRedemptionPage toDomain() => VoucherRedemptionPage(
items: items.map((e) => e.toDomain()).toList(),
page: pagination.page ?? 1,
totalPages: pagination.totalPages ?? 1,
);
}
@@ -0,0 +1,64 @@
import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:data_channel/data_channel.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/wallet/wallet.dart';
import '../datasources/voucher_remote_data_provider.dart';
@Injectable(as: IVoucherRepository)
class VoucherRepository implements IVoucherRepository {
final VoucherRemoteDataProvider _remoteDataProvider;
final String _logName = 'VoucherRepository';
VoucherRepository(this._remoteDataProvider);
@override
Future<Either<WalletFailure, List<Voucher>>> getVouchers() => _run(
'getVouchers',
_remoteDataProvider.fetchVouchers,
(dtos) => dtos.map((e) => e.toDomain()).toList(),
);
@override
Future<Either<WalletFailure, VoucherRedeemResult>> redeem({
required String voucherId,
required String pin,
required String idempotencyKey,
}) => _run(
'redeem',
() => _remoteDataProvider.redeem(
voucherId: voucherId,
pin: pin,
idempotencyKey: idempotencyKey,
),
(dto) => dto.toRedeemResult(),
);
@override
Future<Either<WalletFailure, VoucherRedemptionPage>> getRedemptions({
required int page,
int limit = 20,
}) => _run(
'getRedemptions',
() => _remoteDataProvider.fetchRedemptions(page: page, limit: limit),
(dto) => dto.toDomain(),
);
Future<Either<WalletFailure, R>> _run<D, R>(
String name,
Future<DC<WalletFailure, D>> Function() call,
R Function(D dto) toDomain,
) async {
try {
final result = await call();
if (result.hasError) return left(result.error!);
return right(toDomain(result.data as D));
} catch (e, s) {
log(name, name: _logName, error: e, stackTrace: s);
return left(const WalletFailure.unexpectedError());
}
}
}
@@ -50,15 +50,6 @@ class WalletRepository implements IWalletRepository {
(dto) => dto.toDomain(),
);
@override
Future<Either<WalletFailure, PaymentCode>> createPaymentCode({
required String pin,
}) => _run(
'createPaymentCode',
() => _remoteDataProvider.createPaymentCode(pin: pin),
(dto) => dto.toDomain(),
);
@override
Future<Either<WalletFailure, ExchangePreview>> previewExchange({
required int coins,
+1 -1
View File
@@ -8,12 +8,12 @@ part 'wallet_dtos.g.dart';
part 'dto/wallet_summary_dto.dart';
part 'dto/wallet_transaction_dto.dart';
part 'dto/wallet_expiring_dto.dart';
part 'dto/payment_code_dto.dart';
part 'dto/exchange_dto.dart';
part 'dto/transfer_dto.dart';
part 'dto/pin_dto.dart';
part 'dto/outlet_dto.dart';
part 'dto/customer_order_dto.dart';
part 'dto/voucher_dto.dart';
DateTime? _parseDateTime(String? value) =>
value == null ? null : DateTime.tryParse(value);
File diff suppressed because it is too large Load Diff
+65 -18
View File
@@ -112,20 +112,6 @@ _WalletExpiringDto _$WalletExpiringDtoFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$WalletExpiringDtoToJson(_WalletExpiringDto instance) =>
<String, dynamic>{'point': instance.point, 'coin': instance.coin};
_PaymentCodeDto _$PaymentCodeDtoFromJson(Map<String, dynamic> json) =>
_PaymentCodeDto(
code: json['code'] as String?,
qrPayload: json['qr_payload'] as String?,
expiresAt: json['expires_at'] as String?,
);
Map<String, dynamic> _$PaymentCodeDtoToJson(_PaymentCodeDto instance) =>
<String, dynamic>{
'code': instance.code,
'qr_payload': instance.qrPayload,
'expires_at': instance.expiresAt,
};
_ExchangePreviewDto _$ExchangePreviewDtoFromJson(Map<String, dynamic> json) =>
_ExchangePreviewDto(
coinAmount: (json['coin_amount'] as num?)?.toInt(),
@@ -253,7 +239,6 @@ _OutletDto _$OutletDtoFromJson(Map<String, dynamic> json) => _OutletDto(
id: json['id'] as String?,
name: json['name'] as String?,
address: json['address'] as String?,
acceptsPointPayment: json['accepts_point_payment'] as bool?,
earnsPoints: json['earns_points'] as bool?,
earnsCoins: json['earns_coins'] as bool?,
);
@@ -263,7 +248,6 @@ Map<String, dynamic> _$OutletDtoToJson(_OutletDto instance) =>
'id': instance.id,
'name': instance.name,
'address': instance.address,
'accepts_point_payment': instance.acceptsPointPayment,
'earns_points': instance.earnsPoints,
'earns_coins': instance.earnsCoins,
};
@@ -358,7 +342,6 @@ _CustomerOrderPaymentDto _$CustomerOrderPaymentDtoFromJson(
methodType: json['method_type'] as String?,
amount: (json['amount'] as num?)?.toInt(),
refundAmount: (json['refund_amount'] as num?)?.toInt(),
pointsUsed: (json['points_used'] as num?)?.toInt(),
);
Map<String, dynamic> _$CustomerOrderPaymentDtoToJson(
@@ -369,5 +352,69 @@ Map<String, dynamic> _$CustomerOrderPaymentDtoToJson(
'method_type': instance.methodType,
'amount': instance.amount,
'refund_amount': instance.refundAmount,
'points_used': instance.pointsUsed,
};
_VoucherDto _$VoucherDtoFromJson(Map<String, dynamic> json) => _VoucherDto(
id: json['id'] as String?,
name: json['name'] as String?,
description: json['description'] as String?,
imageUrl: json['image_url'] as String?,
voucherType: json['voucher_type'] as String?,
faceValue: (json['face_value'] as num?)?.toInt(),
pointCost: (json['point_cost'] as num?)?.toInt(),
maxPerCustomer: (json['max_per_customer'] as num?)?.toInt(),
validUntil: json['valid_until'] as String?,
available: (json['available'] as num?)?.toInt(),
);
Map<String, dynamic> _$VoucherDtoToJson(_VoucherDto instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'image_url': instance.imageUrl,
'voucher_type': instance.voucherType,
'face_value': instance.faceValue,
'point_cost': instance.pointCost,
'max_per_customer': instance.maxPerCustomer,
'valid_until': instance.validUntil,
'available': instance.available,
};
_VoucherRedemptionDto _$VoucherRedemptionDtoFromJson(
Map<String, dynamic> json,
) => _VoucherRedemptionDto(
id: json['id'] as String?,
voucherId: json['voucher_id'] as String?,
voucherName: json['voucher_name'] as String?,
voucherImageUrl: json['voucher_image_url'] as String?,
voucherType: json['voucher_type'] as String?,
status: json['status'] as String?,
faceValue: (json['face_value'] as num?)?.toInt(),
pointCost: (json['point_cost'] as num?)?.toInt(),
code: json['code'] as String?,
codeExpiresAt: json['code_expires_at'] as String?,
completedAt: json['completed_at'] as String?,
createdAt: json['created_at'] as String?,
pointBalance: (json['point_balance'] as num?)?.toInt(),
replayed: json['replayed'] as bool?,
);
Map<String, dynamic> _$VoucherRedemptionDtoToJson(
_VoucherRedemptionDto instance,
) => <String, dynamic>{
'id': instance.id,
'voucher_id': instance.voucherId,
'voucher_name': instance.voucherName,
'voucher_image_url': instance.voucherImageUrl,
'voucher_type': instance.voucherType,
'status': instance.status,
'face_value': instance.faceValue,
'point_cost': instance.pointCost,
'code': instance.code,
'code_expires_at': instance.codeExpiresAt,
'completed_at': instance.completedAt,
'created_at': instance.createdAt,
'point_balance': instance.pointBalance,
'replayed': instance.replayed,
};
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import '../../common/api/interceptors/unauthorized_interceptor.dart';
import '../../domain/wallet/wallet.dart';
/// Hitung mundur bawaan bila server menjawab `429` tanpa header `Retry-After`.
@@ -8,7 +9,7 @@ const int kDefaultOtpRetrySeconds = 60;
/// Membaca envelope `{ success, data, errors }`. Mengembalikan `null` bila
/// sukses, atau [WalletFailure] yang sesuai bila gagal.
WalletFailure? parseWalletFailure(Response response) {
if (response.statusCode == 401) return const WalletFailure.unauthorized();
if (isTokenRejected(response)) return const WalletFailure.unauthorized();
final body = response.data;
if (body is! Map) return _failureFromStatus(response);