Files
enaklo-flutter/lib/presentation/pages/wallet/transfer_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

565 lines
19 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/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/wallet/transfer_form/transfer_form_bloc.dart';
import '../../../application/wallet/wallet_history_loader/wallet_history_loader_bloc.dart';
import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/function/app_function.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_ui.dart';
import 'widgets/wallet_history_section.dart';
import 'widgets/wallet_confirm_dialog.dart';
import 'widgets/wallet_result_view.dart';
/// Transfer EnakPoint / EnakCoin ke customer lain (§7.2).
@RoutePage()
class TransferPage extends StatefulWidget implements AutoRouteWrapper {
const TransferPage({super.key});
@override
State<TransferPage> createState() => _TransferPageState();
@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(create: (_) => getIt<TransferFormBloc>()),
BlocProvider(
create: (_) => getIt<WalletHistoryLoaderBloc>()
..add(
const WalletHistoryLoaderEvent.filterChanged(
types: [
WalletTransactionType.transferOut,
WalletTransactionType.transferIn,
],
),
),
),
],
child: this,
);
}
class _TransferPageState extends State<TransferPage> {
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _amountController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Halaman ini juga dibuka dari beranda, saldo belum tentu sudah dimuat.
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_phoneController.dispose();
_amountController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) {
context.read<WalletHistoryLoaderBloc>().add(
const WalletHistoryLoaderEvent.loadedMore(),
);
}
}
Future<void> _onRefresh() async {
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
final history = context.read<WalletHistoryLoaderBloc>();
history.add(const WalletHistoryLoaderEvent.refreshed());
await history.stream.firstWhere((s) => !s.isFetching);
}
void _onCurrencyChanged(WalletCurrency currency) {
if (context.read<TransferFormBloc>().state.currency == currency) return;
HapticFeedback.selectionClick();
context.read<TransferFormBloc>().add(
TransferFormEvent.currencyChanged(currency),
);
context.read<WalletHistoryLoaderBloc>().add(
WalletHistoryLoaderEvent.currencyChanged(currency),
);
}
void _fillAmount(int amount) {
final text = amount.thousandFormat;
_amountController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
context.read<TransferFormBloc>().add(
TransferFormEvent.amountChanged(amount),
);
}
Future<void> _onRecipientFound(TransferFormState state) async {
final recipient = state.recipient!;
final confirmed = await showWalletConfirmDialog(
context,
title: 'Kirim ke ${recipient.name} (${recipient.phoneNumber})?',
message:
'Kamu akan mengirim ${state.amount.thousandFormat} '
'${state.currency.label}. Transfer bersifat final dan tidak bisa '
'dibatalkan.',
confirmLabel: 'Kirim',
);
if (!confirmed || !mounted) return;
if (!await ensurePinReady(context) || !mounted) return;
final success = await showPinSheet(
context,
title: 'Transfer ${state.currency.label}',
onSubmit: (pin) async {
final bloc = context.read<TransferFormBloc>();
bloc.add(TransferFormEvent.submitted(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 transfer belum diketahui. Tekan Lanjut lagi '
'untuk mengulang. Saldo tidak akan terpotong dua kali.',
);
return;
}
final reason = failure.maybeMap(
rejected: (value) => _transferRejectReason(value.cause),
orElse: () => null,
);
AppFlushbar.showError(
context,
walletFailureMessage(
failure,
rejectedMessage: reason == null
? 'Transfer tidak bisa diproses.'
: 'Transfer tidak bisa diproses. $reason',
),
);
}
/// Mengenali alasan penolakan `304` dari `cause` (bahasa Inggris) tanpa
/// menampilkannya mentah.
String? _transferRejectReason(String cause) {
final text = cause.toLowerCase();
if (text.contains('daily')) return 'Melewati batas transfer harian.';
if (text.contains('not enough') || text.contains('insufficient')) {
return 'Saldo tidak cukup.';
}
if (text.contains('minimum') || text.contains('below')) {
return 'Jumlah di bawah minimal transfer.';
}
if (text.contains('maximum') ||
text.contains('exceed') ||
text.contains('above')) {
return 'Jumlah melebihi maksimal per transaksi.';
}
if (text.contains('disabled') || text.contains('not enabled')) {
return 'Fitur transfer sedang tidak tersedia.';
}
return null;
}
String? _recipientError(TransferFormState state) {
final failure = state.recipientFailureOption.toNullable();
if (failure == null) return null;
return failure.maybeMap(
notFound: (_) => 'Nomor ini tidak terdaftar',
rejected: (_) => 'Tidak bisa mengirim ke nomor ini',
orElse: () => walletFailureMessage(failure),
);
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<TransferFormBloc, TransferFormState>(
listenWhen: (p, c) =>
p.recipient != c.recipient && c.recipient != null,
listener: (context, state) => _onRecipientFound(state),
),
],
child: BlocBuilder<TransferFormBloc, TransferFormState>(
builder: (context, state) {
final result = state.result;
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(title: const Text('Transfer')),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: result != null
? KeyedSubtree(
key: const ValueKey('success'),
child: _buildSuccess(result),
)
: KeyedSubtree(
key: const ValueKey('form'),
child: _buildForm(state),
),
),
);
},
),
);
}
Widget _buildForm(TransferFormState state) {
return RefreshIndicator(
color: AppColor.primary,
onRefresh: _onRefresh,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 28),
sliver: SliverToBoxAdapter(
child: BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
buildWhen: (p, c) =>
p.summary != c.summary || p.hasLoaded != c.hasLoaded,
builder: (context, walletState) =>
_buildFormContent(state, walletState),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
sliver: WalletHistorySection(
title: 'Riwayat transfer',
emptyIcon: Icons.swap_horiz_rounded,
emptyTitle: 'Belum ada transfer',
emptySubtitle: (currency) =>
'Transfer ${currency.label} yang kamu kirim atau terima '
'akan muncul di sini.',
),
),
],
),
);
}
Widget _buildFormContent(
TransferFormState state,
WalletLoaderState walletState,
) {
final summary = walletState.summary;
final balance = _balanceOf(summary, state.currency);
final exceedsBalance = walletState.hasLoaded && state.amount > balance;
final isBusy = state.isCheckingRecipient || state.isSubmitting;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionLabel('Kirim dari'),
Row(
children: [
for (final currency in WalletCurrency.values) ...[
if (currency != WalletCurrency.values.first)
const SizedBox(width: 12),
Expanded(
child: _CurrencyOption(
currency: currency,
balance: _balanceOf(summary, currency),
isLoaded: walletState.hasLoaded,
selected: state.currency == currency,
onTap: isBusy ? null : () => _onCurrencyChanged(currency),
),
),
],
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(AppValue.borderRadius),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionLabel('Nomor HP penerima', inCard: true),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
enabled: !isBusy,
cursorColor: AppColor.primary,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(15),
],
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
onChanged: (value) => context.read<TransferFormBloc>().add(
TransferFormEvent.phoneChanged(value),
),
decoration: InputDecoration(
hintText: '08xxxxxxxxxx',
errorText: _recipientError(state),
prefixIcon: const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(
Icons.person_outline_rounded,
color: AppColor.textSecondary,
size: 22,
),
),
prefixIconConstraints: const BoxConstraints(),
),
),
const SizedBox(height: 24),
_buildSectionLabel('Jumlah', inCard: true),
TextField(
controller: _amountController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
enabled: !isBusy,
cursorColor: AppColor.primary,
inputFormatters: [ThousandsInputFormatter()],
style: AppStyle.h4.copyWith(fontWeight: FontWeight.w700),
onChanged: (value) => context.read<TransferFormBloc>().add(
TransferFormEvent.amountChanged(
int.tryParse(digitsOnly(value)) ?? 0,
),
),
decoration: InputDecoration(
hintText: '0',
hintStyle: AppStyle.h4.copyWith(
color: AppColor.textLight,
fontWeight: FontWeight.w700,
),
errorText: exceedsBalance ? 'Melebihi saldo kamu' : null,
suffixText: state.currency.label,
suffixStyle: AppStyle.md.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
walletState.hasLoaded
? 'Saldo tersedia ${balance.thousandFormat}'
: 'Memuat saldo…',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
),
TextButton(
onPressed: walletState.hasLoaded && balance > 0 && !isBusy
? () => _fillAmount(balance)
: null,
style: TextButton.styleFrom(
foregroundColor: AppColor.primary,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Kirim semua',
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
],
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
size: 16,
color: AppColor.textSecondary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Transfer bersifat final dan tidak bisa dibatalkan. Pastikan '
'nomor penerima sudah benar.',
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
),
),
],
),
const SizedBox(height: 20),
AppElevatedButton(
title: 'Lanjut',
height: 48,
isLoading: isBusy,
onPressed: state.isFilled && !exceedsBalance && !isBusy
? () {
dismissKeyboard(context);
context.read<TransferFormBloc>().add(
const TransferFormEvent.recipientChecked(),
);
}
: null,
),
],
);
}
Widget _buildSectionLabel(String text, {bool inCard = false}) {
return Padding(
padding: EdgeInsets.only(left: inCard ? 0 : 4, bottom: inCard ? 4 : 10),
child: Text(
text,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
);
}
int _balanceOf(WalletSummary summary, WalletCurrency currency) =>
currency == WalletCurrency.point
? summary.pointBalance
: summary.coinBalance;
Widget _buildSuccess(TransferResult result) {
return WalletResultView(
title: 'Transfer berhasil',
amountText:
'${result.amount.thousandFormat} ${result.currency.label} terkirim',
lines: [
'Ke ${result.recipient.name} (${result.recipient.phoneNumber})',
'Saldo tersisa: ${result.balance.thousandFormat} '
'${result.currency.label}',
...result.lots
.where((lot) => lot.expiresAt != null)
.map(
(lot) =>
'${lot.amount.thousandFormat} ${result.currency.label} yang '
'dikirim berlaku sampai ${formatWibDate(lot.expiresAt!)}',
),
],
onDone: () => context.router.maybePop(),
);
}
}
/// Kartu pilihan mata uang beserta saldonya.
class _CurrencyOption extends StatelessWidget {
const _CurrencyOption({
required this.currency,
required this.balance,
required this.isLoaded,
required this.selected,
required this.onTap,
});
final WalletCurrency currency;
final int balance;
final bool isLoaded;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(AppValue.borderRadius);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: selected
? AppColor.primaryWithOpacity(0.06)
: AppColor.white,
borderRadius: radius,
border: Border.all(
color: selected ? AppColor.primary : AppColor.white,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AnimatedScale(
scale: selected ? 1 : 0.85,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
alignment: Alignment.centerLeft,
child: WalletCurrencyIcon(currency: currency, size: 36),
),
const Spacer(),
AnimatedScale(
scale: selected ? 1 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
child: const Icon(
Icons.check_circle_rounded,
size: 20,
color: AppColor.primary,
),
),
],
),
const SizedBox(height: 12),
Text(
currency.label,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 2),
Text(
isLoaded ? balance.thousandFormat : '–',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
),
],
),
),
),
);
}
}