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,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|