EnakPoint & EnakCoin wallet, PIN, outlet, order, community, reward
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5954510b93
commit
f5e3075205
@@ -0,0 +1,568 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user