- Wallet EnakPoint & EnakCoin: beranda, riwayat, saldo kedaluwarsa, kode bayar, tukar EnakCoin, transfer - PIN (buat, ganti, lupa), outlet, riwayat order, game spin - Halaman community, webview, coming soon, dan ikon menu beranda baru - Redesign Reward Saya: kartu tiket, filter chip, detail kode + QR - Hapus customer point loader, menu, dan checkout lama - Base URL ke enaklo-pos-api.altru.id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
19 KiB
Dart
569 lines
19 KiB
Dart
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();
|
||
},
|
||
);
|
||
|
||
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.failureOption != c.failureOption,
|
||
listener: (context, state) =>
|
||
state.failureOption.fold(() {}, _showFailure),
|
||
),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|