feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user