Files
enaklo-flutter/lib/presentation/pages/wallet/exchange_page.dart
T
efrilmandClaude Opus 5.5 bc751246d8 Tab Community, tombol Bayar di navbar, logo placeholder, fix crash PIN
- Bottom navbar: Home, Pesanan, Bayar (tombol tengah ke kode bayar),
  Community, Profil; menu EnakCommunity di beranda pindah ke tab
- Image placeholder: logo Enaklo diperbesar, ilustrasi gelas dihapus
- Fix assertion Navigator saat PIN gagal karena error non-PIN: flushbar
  kini tampil lewat onFailure setelah sheet PIN tertutup (bayar, tukar,
  transfer)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 02:05:49 +07:00

648 lines
20 KiB
Dart

import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import '../../../application/wallet/exchange_form/exchange_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_confirm_dialog.dart';
import 'widgets/wallet_history_section.dart';
import 'widgets/wallet_result_view.dart';
/// Tukar EnakCoin → EnakPoint (§8.1).
@RoutePage()
class ExchangePage extends StatefulWidget implements AutoRouteWrapper {
const ExchangePage({super.key});
@override
State<ExchangePage> createState() => _ExchangePageState();
@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(create: (_) => getIt<ExchangeFormBloc>()),
// Riwayat dilihat dari sisi EnakPoint: berapa poin yang didapat.
BlocProvider(
create: (_) => getIt<WalletHistoryLoaderBloc>()
..add(
const WalletHistoryLoaderEvent.filterChanged(
types: [WalletTransactionType.exchangeIn],
),
),
),
],
child: this,
);
}
class _ExchangePageState extends State<ExchangePage> {
final TextEditingController _coinsController = TextEditingController();
final ScrollController _scrollController = ScrollController();
Timer? _debounce;
/// Kurs terakhir yang diketahui, tetap ditampilkan selama preview baru
/// sedang dihitung.
ExchangePreview? _rate;
@override
void initState() {
super.initState();
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_debounce?.cancel();
_coinsController.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 _onCoinsChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
if (!mounted) return;
context.read<ExchangeFormBloc>().add(
ExchangeFormEvent.coinsChanged(int.tryParse(digitsOnly(value)) ?? 0),
);
});
}
/// Isi jumlah langsung tanpa debounce, mis. dari tombol "Tukar semua".
void _fillCoins(int coins) {
_debounce?.cancel();
final text = coins.thousandFormat;
_coinsController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
context.read<ExchangeFormBloc>().add(ExchangeFormEvent.coinsChanged(coins));
}
/// Bulatkan ke bawah ke kelipatan kurs bila kursnya sudah diketahui.
int _roundToRate(int coins) {
final step = _rate?.coinAmount ?? 0;
return step > 0 ? coins - coins % step : coins;
}
Future<void> _confirm(ExchangeFormState state) async {
dismissKeyboard(context);
final preview = state.preview!;
final confirmed = await showWalletConfirmDialog(
context,
title: 'Tukar EnakCoin?',
message:
'Tukar ${preview.coins.thousandFormat} EnakCoin menjadi '
'${preview.points.thousandFormat} EnakPoint. Penukaran tidak bisa '
'dibatalkan.',
confirmLabel: 'Tukar',
);
if (!confirmed || !mounted) return;
if (!await ensurePinReady(context) || !mounted) return;
final success = await showPinSheet(
context,
title: 'Tukar EnakCoin',
onSubmit: (pin) async {
final bloc = context.read<ExchangeFormBloc>();
bloc.add(ExchangeFormEvent.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 penukaran belum diketahui. Tekan Tukar lagi '
'untuk mengulang. Saldo tidak akan terpotong dua kali.',
);
return;
}
AppFlushbar.showError(
context,
walletFailureMessage(
failure,
rejectedMessage:
'Penukaran tidak bisa diproses. Periksa jumlah dan saldo '
'EnakCoin kamu.',
),
);
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<ExchangeFormBloc, ExchangeFormState>(
listenWhen: (p, c) => p.preview != c.preview && c.preview != null,
listener: (context, state) => setState(() => _rate = state.preview),
),
],
child: BlocBuilder<ExchangeFormBloc, ExchangeFormState>(
builder: (context, state) {
final result = state.result;
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(title: const Text('Tukar EnakCoin')),
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(ExchangeFormState 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 penukaran',
emptyIcon: Icons.currency_exchange_rounded,
emptyTitle: 'Belum ada penukaran',
emptySubtitle: (_) =>
'EnakPoint yang kamu dapat dari tukar EnakCoin akan '
'muncul di sini.',
),
),
],
),
);
}
Widget _buildFormContent(
ExchangeFormState state,
WalletLoaderState walletState,
) {
final summary = walletState.summary;
final coinBalance = summary.coinBalance;
final exceedsBalance = walletState.hasLoaded && state.coins > coinBalance;
final preview = state.preview;
final points = preview != null && preview.valid && !exceedsBalance
? preview.points
: 0;
final maxCoins = _roundToRate(coinBalance);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ExchangeCard(
label: 'Kamu tukar',
currency: WalletCurrency.coin,
trailing: Text(
walletState.hasLoaded
? 'Saldo ${coinBalance.thousandFormat}'
: 'Memuat saldo…',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _coinsController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
enabled: !state.isSubmitting,
cursorColor: AppColor.primary,
inputFormatters: [ThousandsInputFormatter()],
onChanged: _onCoinsChanged,
style: AppStyle.h4.copyWith(fontWeight: FontWeight.w700),
decoration: InputDecoration(
hintText: '0',
hintStyle: AppStyle.h4.copyWith(
color: AppColor.textLight,
fontWeight: FontWeight.w700,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
),
Align(
alignment: Alignment.centerLeft,
child: _QuickChip(
label: 'Tukar semua',
onTap:
walletState.hasLoaded &&
maxCoins > 0 &&
!state.isSubmitting
? () => _fillCoins(maxCoins)
: null,
),
),
],
),
),
const SizedBox(height: 8),
Stack(
clipBehavior: Clip.none,
children: [
_ExchangeCard(
label: 'Kamu dapat',
currency: WalletCurrency.point,
highlighted: points > 0,
trailing: Text(
walletState.hasLoaded
? 'Saldo ${summary.pointBalance.thousandFormat}'
: '',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: state.isPreviewing ? 0.35 : 1,
child: TweenAnimationBuilder<int>(
tween: IntTween(begin: 0, end: points),
duration: const Duration(milliseconds: 450),
curve: Curves.easeOutCubic,
builder: (context, value, _) => Text(
value.thousandFormat,
style: AppStyle.h4.copyWith(
fontWeight: FontWeight.w700,
color: points > 0
? AppColor.success
: AppColor.textLight,
),
),
),
),
),
),
const Positioned(
top: -24,
left: 0,
right: 0,
child: Center(child: _SwapBadge()),
),
],
),
const SizedBox(height: 12),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _buildStatus(state, exceedsBalance),
),
),
const SizedBox(height: 20),
AppElevatedButton(
title: 'Tukar',
height: 48,
isLoading: state.isSubmitting,
onPressed: state.canSubmit && !exceedsBalance
? () => _confirm(state)
: null,
),
],
);
}
/// Satu baris keterangan di bawah kartu: kurs, proses hitung, atau error.
Widget _buildStatus(ExchangeFormState state, bool exceedsBalance) {
if (state.isPreviewing) {
return const _StatusLine(
key: ValueKey('previewing'),
icon: SpinKitThreeBounce(color: AppColor.primary, size: 14),
text: 'Menghitung…',
);
}
if (exceedsBalance) {
return const _StatusLine(
key: ValueKey('exceeds'),
icon: Icon(
Icons.error_outline_rounded,
size: 16,
color: AppColor.error,
),
text: 'Melebihi saldo EnakCoin kamu',
color: AppColor.error,
);
}
final previewFailure = state.previewFailureOption.toNullable();
if (previewFailure != null) {
return _StatusLine(
key: const ValueKey('failure'),
icon: const Icon(
Icons.error_outline_rounded,
size: 16,
color: AppColor.error,
),
text: walletFailureMessage(
previewFailure,
rejectedMessage: 'Jumlah EnakCoin tidak valid.',
),
color: AppColor.error,
);
}
final preview = state.preview;
if (preview != null && !preview.valid) {
final rounded = _roundToRate(state.coins);
return _StatusLine(
key: const ValueKey('invalid'),
icon: const Icon(
Icons.error_outline_rounded,
size: 16,
color: AppColor.error,
),
text:
preview.reason ??
'Jumlah harus kelipatan ${preview.coinAmount.thousandFormat} '
'EnakCoin.',
color: AppColor.error,
action: rounded > 0 && rounded != state.coins
? _QuickChip(
label: 'Pakai ${rounded.thousandFormat}',
onTap: () => _fillCoins(rounded),
)
: null,
);
}
final rate = _rate;
if (rate == null) {
return const _StatusLine(
key: ValueKey('hint'),
icon: Icon(
Icons.info_outline_rounded,
size: 16,
color: AppColor.textSecondary,
),
text: 'Masukkan jumlah EnakCoin untuk melihat kurs penukaran.',
);
}
return _StatusLine(
key: const ValueKey('rate'),
icon: const Icon(
Icons.currency_exchange_rounded,
size: 16,
color: AppColor.textSecondary,
),
text:
'Kurs ${rate.coinAmount.thousandFormat} EnakCoin = '
'${rate.pointAmount.thousandFormat} EnakPoint · penukaran tidak '
'bisa dibatalkan',
);
}
Widget _buildSuccess(ExchangeResult result) {
return WalletResultView(
title: 'Penukaran berhasil',
amountText: '+${result.points.thousandFormat} EnakPoint',
lines: [
'Ditukar dari ${result.coins.thousandFormat} EnakCoin',
'Saldo sekarang: ${result.pointBalance.thousandFormat} EnakPoint · '
'${result.coinBalance.thousandFormat} EnakCoin',
...result.lots
.where((lot) => lot.expiresAt != null)
.map(
(lot) =>
'${lot.amount.thousandFormat} EnakPoint ini berlaku sampai '
'${formatWibDate(lot.expiresAt!)}',
),
],
onDone: () => context.router.maybePop(),
);
}
}
/// Kartu satu sisi penukaran (asal atau tujuan).
class _ExchangeCard extends StatelessWidget {
const _ExchangeCard({
required this.label,
required this.currency,
required this.trailing,
required this.child,
this.highlighted = false,
});
final String label;
final WalletCurrency currency;
final Widget trailing;
final Widget child;
final bool highlighted;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: highlighted ? AppColor.successWithOpacity(0.06) : AppColor.white,
borderRadius: BorderRadius.circular(AppValue.borderRadius),
border: Border.all(
color: highlighted
? AppColor.successWithOpacity(0.4)
: AppColor.white,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
trailing,
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(child: child),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.fromLTRB(6, 6, 12, 6),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
WalletCurrencyIcon(currency: currency, size: 24),
const SizedBox(width: 6),
Text(
currency.label,
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w700),
),
],
),
),
],
),
],
),
);
}
}
/// Lingkaran panah di antara dua kartu.
class _SwapBadge extends StatelessWidget {
const _SwapBadge();
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColor.primary,
shape: BoxShape.circle,
border: Border.all(color: AppColor.background, width: 4),
),
child: const Icon(
Icons.arrow_downward_rounded,
size: 18,
color: AppColor.white,
),
);
}
}
class _QuickChip extends StatelessWidget {
const _QuickChip({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Material(
color: enabled ? AppColor.primaryWithOpacity(0.08) : AppColor.background,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
label,
style: AppStyle.xs.copyWith(
color: enabled ? AppColor.primary : AppColor.textLight,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}
class _StatusLine extends StatelessWidget {
const _StatusLine({
super.key,
required this.icon,
required this.text,
this.color = AppColor.textSecondary,
this.action,
});
final Widget icon;
final String text;
final Color color;
final Widget? action;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
SizedBox(width: 20, child: Center(child: icon)),
const SizedBox(width: 6),
Expanded(
child: Text(text, style: AppStyle.xs.copyWith(color: color)),
),
if (action != null) ...[const SizedBox(width: 8), action!],
],
),
);
}
}