Files
enaklo-flutter/lib/presentation/pages/reward/reward_detail_page.dart
T
efrilm 38850b281f
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
feat: integrate enakgame and voucher
2026-10-10 02:33:16 +07:00

411 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
),
],
),
);
}
}