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 createState() => _TransferPageState(); @override Widget wrappedRoute(BuildContext context) => MultiBlocProvider( providers: [ BlocProvider(create: (_) => getIt()), BlocProvider( create: (_) => getIt() ..add( const WalletHistoryLoaderEvent.filterChanged( types: [ WalletTransactionType.transferOut, WalletTransactionType.transferIn, ], ), ), ), ], child: this, ); } class _TransferPageState extends State { 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().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().add( const WalletHistoryLoaderEvent.loadedMore(), ); } } Future _onRefresh() async { context.read().add(const WalletLoaderEvent.fetched()); final history = context.read(); history.add(const WalletHistoryLoaderEvent.refreshed()); await history.stream.firstWhere((s) => !s.isFetching); } void _onCurrencyChanged(WalletCurrency currency) { if (context.read().state.currency == currency) return; HapticFeedback.selectionClick(); context.read().add( TransferFormEvent.currencyChanged(currency), ); context.read().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().add( TransferFormEvent.amountChanged(amount), ); } Future _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(); bloc.add(TransferFormEvent.submitted(pin)); final result = await bloc.stream.firstWhere((s) => !s.isSubmitting); return result.failureOption.toNullable(); }, ); 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 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( listenWhen: (p, c) => p.failureOption != c.failureOption, listener: (context, state) => state.failureOption.fold(() {}, _showFailure), ), BlocListener( listenWhen: (p, c) => p.recipient != c.recipient && c.recipient != null, listener: (context, state) => _onRecipientFound(state), ), ], child: BlocBuilder( 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( 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().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().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().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), ), ], ), ), ), ); } }