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 createState() => _RewardDetailPageState(); @override Widget wrappedRoute(BuildContext context) => BlocProvider(create: (_) => getIt(), child: this); } class _RewardDetailPageState extends State { /// Diisi bila server menjawab voucher habis / tidak tersedia; katalog /// dimuat ulang saat kembali ke halaman sebelumnya. String? _unavailableMessage; Voucher get _voucher => widget.voucher; Future _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(); 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().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( 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( 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, ), ), ), ], ), ); } }