- 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>
652 lines
20 KiB
Dart
652 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();
|
|
},
|
|
);
|
|
|
|
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.failureOption != c.failureOption,
|
|
listener: (context, state) =>
|
|
state.failureOption.fold(() {}, _showFailure),
|
|
),
|
|
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!],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|