feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s

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,410 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/voucher/voucher_redeem_form/voucher_redeem_form_bloc.dart';
import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/wallet/wallet.dart';
import '../../../injection.dart';
import '../../components/button/button.dart';
import '../../components/toast/flushbar.dart';
import '../pin/pin_guard.dart';
import '../pin/widgets/pin_sheet.dart';
import '../wallet/wallet_ui.dart';
import '../wallet/widgets/wallet_confirm_dialog.dart';
import 'reward_ui.dart';
/// Detail voucher di katalog dan tukar EnakPoint ke voucher (§9.2). Pop
/// dengan `true` bila voucher berhasil ditukar.
@RoutePage()
class RewardDetailPage extends StatefulWidget implements AutoRouteWrapper {
const RewardDetailPage({super.key, required this.voucher});
final Voucher voucher;
@override
State<RewardDetailPage> createState() => _RewardDetailPageState();
@override
Widget wrappedRoute(BuildContext context) =>
BlocProvider(create: (_) => getIt<VoucherRedeemFormBloc>(), child: this);
}
class _RewardDetailPageState extends State<RewardDetailPage> {
/// Diisi bila server menjawab voucher habis / tidak tersedia; katalog
/// dimuat ulang saat kembali ke halaman sebelumnya.
String? _unavailableMessage;
Voucher get _voucher => widget.voucher;
Future<void> _redeem() async {
final confirmed = await showWalletConfirmDialog(
context,
title: 'Tukar voucher?',
message:
'Tukar ${_voucher.pointCost.thousandFormat} EnakPoint dengan '
'${_voucher.name}? Tidak bisa dibatalkan.',
confirmLabel: 'Tukar',
);
if (!confirmed || !mounted) return;
if (!await ensurePinReady(context) || !mounted) return;
final success = await showPinSheet(
context,
title: 'Tukar voucher',
onSubmit: (pin) async {
final bloc = context.read<VoucherRedeemFormBloc>();
bloc.add(
VoucherRedeemFormEvent.submitted(voucherId: _voucher.id, pin: pin),
);
final result = await bloc.stream.firstWhere((s) => !s.isSubmitting);
return result.failureOption.toNullable();
},
onFailure: _showFailure,
);
if (success && mounted) {
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
}
}
void _showFailure(WalletFailure failure) {
if (failure.isPinError) return;
if (failure.isNetworkError) {
AppFlushbar.showError(
context,
'Koneksi terputus, status penukaran belum diketahui. Tekan Tukar lagi '
'untuk mengulang. EnakPoint tidak akan terpotong dua kali.',
);
return;
}
final description = describeVoucherRedeemFailure(failure);
if (description.reloadCatalog) {
setState(() => _unavailableMessage = description.message);
}
AppFlushbar.showError(context, description.message);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<VoucherRedeemFormBloc, VoucherRedeemFormState>(
builder: (context, state) {
final result = state.result;
return PopScope(
canPop: result == null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && result != null) context.router.pop(true);
},
child: Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(title: const Text('Detail voucher')),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: result != null
? KeyedSubtree(
key: const ValueKey('success'),
child: _buildSuccess(result),
)
: KeyedSubtree(
key: const ValueKey('detail'),
child: _buildDetail(state),
),
),
),
);
},
);
}
Widget _buildDetail(VoucherRedeemFormState state) {
final faceValue = voucherFaceValueLabel(_voucher.type, _voucher.faceValue);
return BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
builder: (context, walletState) {
final pointBalance = walletState.summary.pointBalance;
final canAfford =
!walletState.hasLoaded || pointBalance >= _voucher.pointCost;
final String? blockedLabel = _unavailableMessage != null
? 'Voucher tidak tersedia'
: _voucher.isOutOfStock
? 'Habis'
: !canAfford
? 'EnakPoint kurang'
: null;
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(
AppValue.borderRadius,
),
),
child: Column(
children: [
VoucherImage(
url: _voucher.imageUrl,
size: 120,
dimmed:
_voucher.isOutOfStock ||
_unavailableMessage != null,
),
const SizedBox(height: 16),
Text(
voucherTypeLabel(
_voucher.type,
_voucher.faceValue,
).toUpperCase(),
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
),
),
const SizedBox(height: 4),
Text(
_voucher.name,
textAlign: TextAlign.center,
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.w700,
),
),
if (faceValue != null)
Text(
faceValue,
style: AppStyle.md.copyWith(
color: AppColor.textSecondary,
),
),
if (_voucher.description != null) ...[
const SizedBox(height: 12),
Text(
_voucher.description!,
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
height: 1.5,
),
),
],
],
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(
AppValue.borderRadius,
),
),
child: Column(
children: [
_InfoRow(
label: 'Ditukar dengan',
value:
'${_voucher.pointCost.thousandFormat} EnakPoint',
),
if (walletState.hasLoaded)
_InfoRow(
label: 'Saldo EnakPoint kamu',
value: pointBalance.thousandFormat,
valueColor: canAfford ? null : AppColor.error,
),
if (_voucher.validUntil != null)
_InfoRow(
label: 'Bisa ditukar sampai',
value: formatWibDateTime(_voucher.validUntil!),
),
if (_voucher.available != null)
_InfoRow(
label: 'Sisa stok',
value: _voucher.isOutOfStock
? 'Habis'
: _voucher.available!.thousandFormat,
),
if (_voucher.maxPerCustomer != null)
_InfoRow(
label: 'Batas tukar',
value:
'${_voucher.maxPerCustomer!.thousandFormat}× '
'per customer',
),
],
),
),
],
),
),
Container(
color: AppColor.white,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: SafeArea(
top: false,
child: AppElevatedButton(
title:
blockedLabel ??
'Tukar ${_voucher.pointCost.thousandFormat} EnakPoint',
height: 48,
isLoading: state.isSubmitting,
onPressed: blockedLabel == null && !state.isSubmitting
? _redeem
: null,
),
),
),
],
);
},
);
}
Widget _buildSuccess(VoucherRedeemResult result) {
final redemption = result.redemption;
final isPending = redemption.status == VoucherRedemptionStatus.pending;
final isFailed = redemption.status == VoucherRedemptionStatus.failed;
final (IconData icon, Color color, String title) = isPending
? (
Icons.hourglass_top_rounded,
AppColor.warning,
'Voucher sedang diproses',
)
: isFailed
? (Icons.undo_rounded, AppColor.error, 'Penukaran gagal')
: (Icons.check_circle, AppColor.success, 'Voucher berhasil ditukar');
return SafeArea(
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
const SizedBox(height: 24),
Icon(icon, color: color, size: 72),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
redemption.voucherName,
textAlign: TextAlign.center,
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 24),
if (isPending)
_Note(
'EnakPoint sudah terpotong. Cek lagi di Voucher saya. Bila '
'gagal, EnakPoint dikembalikan otomatis.',
)
else if (isFailed)
_Note('EnakPoint sudah dikembalikan.')
else if (redemption.code != null)
VoucherCodeBox(code: redemption.code!)
else
const VoucherNoCodeNote(),
const SizedBox(height: 16),
if (!isPending && !isFailed && redemption.codeExpiresAt != null)
Text(
'Berlaku sampai '
'${formatWibDateTime(redemption.codeExpiresAt!)}',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 6),
Text(
'Saldo EnakPoint sekarang: '
'${result.pointBalance.thousandFormat}',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
),
Padding(
padding: const EdgeInsets.all(20),
child: AppElevatedButton(
title: 'Lihat voucher saya',
onPressed: () => context.router.pop(true),
),
),
],
),
);
}
}
class _Note extends StatelessWidget {
const _Note(this.message);
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(AppValue.borderRadius),
),
child: Text(
message,
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value, this.valueColor});
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Text(
label,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
textAlign: TextAlign.end,
style: AppStyle.sm.copyWith(
color: valueColor ?? AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}