411 lines
14 KiB
Dart
411 lines
14 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|