Files
enaklo-flutter/lib/presentation/pages/wallet/transfer_page.dart
T

565 lines
19 KiB
Dart
Raw Normal View History

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 (§8.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),
),
],
),
),
),
);
}
}