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,651 @@
|
||||
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!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../../../application/wallet/payment_code_form/payment_code_form_bloc.dart';
|
||||
import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
|
||||
import '../../../common/extension/extension.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';
|
||||
|
||||
/// Bayar di kasir dengan kode bayar (§7.1).
|
||||
@RoutePage()
|
||||
class PaymentCodePage extends StatefulWidget implements AutoRouteWrapper {
|
||||
const PaymentCodePage({super.key});
|
||||
|
||||
@override
|
||||
State<PaymentCodePage> createState() => _PaymentCodePageState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) =>
|
||||
BlocProvider(create: (_) => getIt<PaymentCodeFormBloc>(), child: this);
|
||||
}
|
||||
|
||||
class _PaymentCodePageState extends State<PaymentCodePage> {
|
||||
Timer? _ticker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _generate());
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
if (!mounted) return;
|
||||
final code = context.read<PaymentCodeFormBloc>().state.paymentCode;
|
||||
if (code == null) return;
|
||||
|
||||
if (!DateTime.now().isBefore(code.expiresAt)) {
|
||||
context.read<PaymentCodeFormBloc>().add(
|
||||
const PaymentCodeFormEvent.expired(),
|
||||
);
|
||||
} else {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _generate() async {
|
||||
if (!await ensurePinReady(context) || !mounted) return;
|
||||
|
||||
await showPinSheet(
|
||||
context,
|
||||
title: 'Bayar di kasir',
|
||||
subtitle: 'Masukkan PIN untuk menampilkan kode bayar',
|
||||
onSubmit: (pin) async {
|
||||
final bloc = context.read<PaymentCodeFormBloc>();
|
||||
bloc.add(PaymentCodeFormEvent.submitted(pin));
|
||||
final state = await bloc.stream.firstWhere((s) => !s.isSubmitting);
|
||||
return state.failureOption.toNullable();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<PaymentCodeFormBloc, PaymentCodeFormState>(
|
||||
listenWhen: (p, c) => p.failureOption != c.failureOption,
|
||||
listener: (context, state) => state.failureOption.fold(() {}, (f) {
|
||||
if (!f.isPinError) {
|
||||
AppFlushbar.showError(context, walletFailureMessage(f));
|
||||
}
|
||||
}),
|
||||
builder: (context, state) {
|
||||
final code = state.paymentCode;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('Bayar di Kasir')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildBalance(),
|
||||
const SizedBox(height: 16),
|
||||
code == null ? _buildEmpty(state.isSubmitting) : _buildCode(code),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tunjukkan kode ini ke kasir. Kasir yang memilih jumlah '
|
||||
'EnakPoint yang dipakai. Kode hanya bisa dipakai sekali, dan '
|
||||
'membuat kode baru membatalkan kode sebelumnya.',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalance() {
|
||||
return BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
|
||||
builder: (context, state) {
|
||||
final summary = state.summary;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const WalletCurrencyIcon(
|
||||
currency: WalletCurrency.point,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${summary.pointBalance.thousandFormat} EnakPoint',
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
pointDiscountLabel(summary.pointDiscountValue),
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty(bool isSubmitting) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.qr_code_2, size: 72, color: AppColor.textLight),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Kode bayar belum dibuat atau sudah kedaluwarsa',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppElevatedButton(
|
||||
title: 'Buat kode baru',
|
||||
isLoading: isSubmitting,
|
||||
onPressed: isSubmitting ? null : _generate,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCode(PaymentCode code) {
|
||||
final remaining = code.expiresAt.difference(DateTime.now());
|
||||
final spacedCode = code.code.split('').join(' ');
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
spacedCode,
|
||||
style: AppStyle.h1.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
QrImageView(
|
||||
data: code.qrPayload,
|
||||
size: 220,
|
||||
backgroundColor: AppColor.white,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.timer_outlined,
|
||||
size: 18,
|
||||
color: AppColor.warning,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Berlaku ${formatCountdown(remaining)}',
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.warning,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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/wallet_expiring_loader/wallet_expiring_loader_bloc.dart';
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../../../injection.dart';
|
||||
import '../../components/button/button.dart';
|
||||
import 'wallet_ui.dart';
|
||||
|
||||
/// Saldo yang akan kedaluwarsa (§4.3).
|
||||
@RoutePage()
|
||||
class WalletExpiringPage extends StatelessWidget implements AutoRouteWrapper {
|
||||
const WalletExpiringPage({super.key});
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) => BlocProvider(
|
||||
create: (_) =>
|
||||
getIt<WalletExpiringLoaderBloc>()
|
||||
..add(const WalletExpiringLoaderEvent.fetched()),
|
||||
child: this,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('Akan Kedaluwarsa')),
|
||||
body: BlocBuilder<WalletExpiringLoaderBloc, WalletExpiringLoaderState>(
|
||||
builder: (context, state) {
|
||||
if (state.isFetching) {
|
||||
return const Center(
|
||||
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
|
||||
);
|
||||
}
|
||||
|
||||
final failure = state.failureOption.toNullable();
|
||||
if (failure != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(walletFailureMessage(failure), style: AppStyle.md),
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: 'Coba lagi',
|
||||
width: 160,
|
||||
onPressed: () => context
|
||||
.read<WalletExpiringLoaderBloc>()
|
||||
.add(const WalletExpiringLoaderEvent.fetched()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final expiring = state.expiring;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Saldo bisa dipakai sampai pukul 23:59 WIB pada tanggal '
|
||||
'kedaluwarsa. Saldo yang kedaluwarsa hangus tanpa kompensasi.',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(currency: WalletCurrency.point, items: expiring.point),
|
||||
const SizedBox(height: 16),
|
||||
_Section(currency: WalletCurrency.coin, items: expiring.coin),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
const _Section({required this.currency, required this.items});
|
||||
|
||||
final WalletCurrency currency;
|
||||
final List<ExpiringAmount> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
currency.label,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
child: Text(
|
||||
'Tidak ada saldo yang akan kedaluwarsa',
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
)
|
||||
else
|
||||
...items.map(
|
||||
(item) => ListTile(
|
||||
leading: const Icon(Icons.schedule, color: AppColor.warning),
|
||||
title: Text(
|
||||
'${item.amount.thousandFormat} ${currency.label}',
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Berlaku sampai ${item.date.toDate}, 23:59 WIB',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
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/wallet_history_loader/wallet_history_loader_bloc.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 'wallet_ui.dart';
|
||||
import 'widgets/wallet_transaction_tile.dart';
|
||||
|
||||
/// Riwayat mutasi (§4.2).
|
||||
@RoutePage()
|
||||
class WalletHistoryPage extends StatefulWidget implements AutoRouteWrapper {
|
||||
const WalletHistoryPage({
|
||||
super.key,
|
||||
this.initialCurrency = WalletCurrency.point,
|
||||
});
|
||||
|
||||
final WalletCurrency initialCurrency;
|
||||
|
||||
@override
|
||||
State<WalletHistoryPage> createState() => _WalletHistoryPageState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) => BlocProvider(
|
||||
create: (_) =>
|
||||
getIt<WalletHistoryLoaderBloc>()
|
||||
..add(WalletHistoryLoaderEvent.currencyChanged(initialCurrency)),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
class _WalletHistoryPageState extends State<WalletHistoryPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(
|
||||
length: WalletCurrency.values.length,
|
||||
vsync: this,
|
||||
initialIndex: WalletCurrency.values.indexOf(widget.initialCurrency),
|
||||
);
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) return;
|
||||
context.read<WalletHistoryLoaderBloc>().add(
|
||||
WalletHistoryLoaderEvent.currencyChanged(
|
||||
WalletCurrency.values[_tabController.index],
|
||||
),
|
||||
);
|
||||
});
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.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> _openFilter(WalletHistoryLoaderState state) async {
|
||||
final result = await showModalBottomSheet<_HistoryFilter>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColor.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => _FilterSheet(
|
||||
initial: _HistoryFilter(
|
||||
types: state.types,
|
||||
from: state.from,
|
||||
to: state.to,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
context.read<WalletHistoryLoaderBloc>().add(
|
||||
WalletHistoryLoaderEvent.filterChanged(
|
||||
types: result.types,
|
||||
from: result.from,
|
||||
to: result.to,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<WalletHistoryLoaderBloc, WalletHistoryLoaderState>(
|
||||
listenWhen: (p, c) =>
|
||||
p.failureOption != c.failureOption && c.items.isNotEmpty,
|
||||
listener: (context, state) => state.failureOption.fold(
|
||||
() {},
|
||||
(f) => AppFlushbar.showError(context, walletFailureMessage(f)),
|
||||
),
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Riwayat'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => _openFilter(state),
|
||||
icon: Badge(
|
||||
isLabelVisible: state.hasFilter,
|
||||
backgroundColor: AppColor.primary,
|
||||
smallSize: 8,
|
||||
child: const Icon(Icons.filter_list),
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: WalletCurrency.values
|
||||
.map((currency) => Tab(text: currency.label))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (state.hasFilter) _buildActiveFilter(state),
|
||||
Expanded(child: _buildList(state)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActiveFilter(WalletHistoryLoaderState state) {
|
||||
final parts = <String>[
|
||||
if (state.types.isNotEmpty) '${state.types.length} tipe',
|
||||
if (state.from != null || state.to != null)
|
||||
'${state.from == null ? '…' : formatShortDate(state.from!)} – '
|
||||
'${state.to == null ? '…' : formatShortDate(state.to!)}',
|
||||
];
|
||||
|
||||
return Container(
|
||||
color: AppColor.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Filter: ${parts.join(' · ')}',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.read<WalletHistoryLoaderBloc>().add(
|
||||
const WalletHistoryLoaderEvent.filterChanged(types: []),
|
||||
),
|
||||
child: Text(
|
||||
'Hapus filter',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(WalletHistoryLoaderState state) {
|
||||
if (state.isFetching && state.items.isEmpty) {
|
||||
return const Center(
|
||||
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
|
||||
);
|
||||
}
|
||||
|
||||
final failure = state.failureOption.toNullable();
|
||||
if (failure != null && state.items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(walletFailureMessage(failure), style: AppStyle.md),
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: 'Coba lagi',
|
||||
width: 160,
|
||||
onPressed: () => context.read<WalletHistoryLoaderBloc>().add(
|
||||
const WalletHistoryLoaderEvent.refreshed(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColor.primary,
|
||||
onRefresh: () async {
|
||||
final bloc = context.read<WalletHistoryLoaderBloc>();
|
||||
bloc.add(const WalletHistoryLoaderEvent.refreshed());
|
||||
await bloc.stream.firstWhere((s) => !s.isFetching);
|
||||
},
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
children: [
|
||||
const SizedBox(height: 120),
|
||||
Text(
|
||||
state.hasFilter
|
||||
? 'Tidak ada transaksi yang cocok dengan filter'
|
||||
: 'Belum ada transaksi',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: state.items.length + (state.hasMore ? 1 : 0),
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: AppColor.borderLight),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SpinKitFadingCircle(
|
||||
color: AppColor.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
color: AppColor.white,
|
||||
child: WalletTransactionTile(transaction: state.items[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryFilter {
|
||||
const _HistoryFilter({required this.types, this.from, this.to});
|
||||
|
||||
final List<WalletTransactionType> types;
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
}
|
||||
|
||||
class _FilterSheet extends StatefulWidget {
|
||||
const _FilterSheet({required this.initial});
|
||||
|
||||
final _HistoryFilter initial;
|
||||
|
||||
@override
|
||||
State<_FilterSheet> createState() => _FilterSheetState();
|
||||
}
|
||||
|
||||
class _FilterSheetState extends State<_FilterSheet> {
|
||||
late final Set<WalletTransactionType> _types = {...widget.initial.types};
|
||||
late DateTime? _from = widget.initial.from;
|
||||
late DateTime? _to = widget.initial.to;
|
||||
|
||||
Future<void> _pickRange() async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: now,
|
||||
initialDateRange: _from != null && _to != null
|
||||
? DateTimeRange(start: _from!, end: _to!)
|
||||
: null,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
_from = picked.start;
|
||||
_to = picked.end;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filter riwayat',
|
||||
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tipe',
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: WalletTransactionType.filterable.map((type) {
|
||||
final selected = _types.contains(type);
|
||||
return FilterChip(
|
||||
label: Text(type.label, style: AppStyle.sm),
|
||||
selected: selected,
|
||||
selectedColor: AppColor.primaryWithOpacity(0.15),
|
||||
checkmarkColor: AppColor.primary,
|
||||
onSelected: (value) => setState(
|
||||
() => value ? _types.add(type) : _types.remove(type),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tanggal',
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickRange,
|
||||
icon: const Icon(Icons.date_range),
|
||||
label: Text(
|
||||
_from == null
|
||||
? 'Pilih rentang tanggal'
|
||||
: '${formatShortDate(_from!)} – ${formatShortDate(_to!)}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppOutlineButton(
|
||||
title: 'Reset',
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop(const _HistoryFilter(types: [])),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AppElevatedButton(
|
||||
title: 'Terapkan',
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
_HistoryFilter(
|
||||
types: WalletTransactionType.filterable
|
||||
.where(_types.contains)
|
||||
.toList(),
|
||||
from: _from,
|
||||
to: _to,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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/wallet_loader/wallet_loader_bloc.dart';
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../../components/button/button.dart';
|
||||
import '../../router/app_router.gr.dart';
|
||||
import 'wallet_ui.dart';
|
||||
import 'widgets/wallet_expiring_banner.dart';
|
||||
import 'widgets/wallet_transaction_tile.dart';
|
||||
|
||||
/// Beranda wallet (§4.1).
|
||||
@RoutePage()
|
||||
class WalletPage extends StatefulWidget {
|
||||
const WalletPage({super.key});
|
||||
|
||||
@override
|
||||
State<WalletPage> createState() => _WalletPageState();
|
||||
}
|
||||
|
||||
class _WalletPageState extends State<WalletPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
}
|
||||
|
||||
/// Saldo dimuat ulang setiap kembali dari layar transaksi.
|
||||
Future<void> _open(PageRouteInfo route) async {
|
||||
await context.router.push(route);
|
||||
if (mounted) _refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('EnakPoint & EnakCoin')),
|
||||
body: BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded) {
|
||||
return state.failureOption.fold(
|
||||
() => const Center(
|
||||
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
|
||||
),
|
||||
(f) => _buildError(walletFailureMessage(f)),
|
||||
);
|
||||
}
|
||||
|
||||
final summary = state.summary;
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColor.primary,
|
||||
onRefresh: () async {
|
||||
_refresh();
|
||||
await context.read<WalletLoaderBloc>().stream.firstWhere(
|
||||
(s) => !s.isFetching,
|
||||
);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
children: [
|
||||
_buildBalanceCard(summary),
|
||||
..._buildExpiringBanners(summary),
|
||||
_buildActions(summary),
|
||||
const SizedBox(height: 8),
|
||||
_buildRecent(summary),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String message) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, style: AppStyle.md, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: 'Coba lagi',
|
||||
width: 160,
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceCard(WalletSummary summary) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColor.primaryGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.primary.withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const _CurrencyBadge(currency: WalletCurrency.point, size: 44),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'EnakPoint',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
summary.pointBalance.thousandFormat,
|
||||
style: AppStyle.h2.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
pointDiscountLabel(summary.pointDiscountValue),
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Divider(color: AppColor.white.withOpacity(0.3), height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const _CurrencyBadge(currency: WalletCurrency.coin, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'EnakCoin',
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
summary.coinBalance.thousandFormat,
|
||||
style: AppStyle.xl.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildExpiringBanners(WalletSummary summary) {
|
||||
final banners = <Widget>[
|
||||
if (summary.nearestExpiringPoint != null)
|
||||
WalletExpiringBanner(
|
||||
currency: WalletCurrency.point,
|
||||
expiring: summary.nearestExpiringPoint!,
|
||||
onTap: () => _open(const WalletExpiringRoute()),
|
||||
),
|
||||
if (summary.nearestExpiringCoin != null)
|
||||
WalletExpiringBanner(
|
||||
currency: WalletCurrency.coin,
|
||||
expiring: summary.nearestExpiringCoin!,
|
||||
onTap: () => _open(const WalletExpiringRoute()),
|
||||
),
|
||||
];
|
||||
|
||||
return banners
|
||||
.map(
|
||||
(banner) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: banner,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget _buildActions(WalletSummary summary) {
|
||||
final actions = [
|
||||
(Icons.qr_code_2, 'Bayar di kasir', const PaymentCodeRoute()),
|
||||
(Icons.swap_horiz, 'Tukar EnakCoin', const ExchangeRoute()),
|
||||
(Icons.send_outlined, 'Transfer', const TransferRoute()),
|
||||
(Icons.casino_outlined, 'Main game', const FerrisWheelRoute()),
|
||||
];
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: actions.map((action) {
|
||||
final (icon, label, route) = action;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _open(route),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primaryWithOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: AppColor.primary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.xs.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecent(WalletSummary summary) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Riwayat terbaru',
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _open(WalletHistoryRoute()),
|
||||
child: Text(
|
||||
'Lihat semua',
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (summary.recentTransactions.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Belum ada transaksi',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
)
|
||||
else
|
||||
...summary.recentTransactions
|
||||
.take(5)
|
||||
.map(
|
||||
(tx) => WalletTransactionTile(
|
||||
transaction: tx,
|
||||
showCurrency: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ikon mata uang di atas lingkaran putih, agar kontras di kartu merah.
|
||||
class _CurrencyBadge extends StatelessWidget {
|
||||
const _CurrencyBadge({required this.currency, required this.size});
|
||||
|
||||
final WalletCurrency currency;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
padding: EdgeInsets.all(size * 0.14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColor.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: WalletCurrencyIcon(currency: currency, size: size * 0.72),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../common/api/api_failure.dart';
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../../components/assets/assets.gen.dart';
|
||||
|
||||
/// Pesan ramah untuk customer. [rejectedMessage] dipakai untuk penolakan
|
||||
/// aturan bisnis (`304`), karena `cause` dari server tidak boleh tampil mentah.
|
||||
String walletFailureMessage(WalletFailure failure, {String? rejectedMessage}) {
|
||||
return failure.map(
|
||||
serverError: (value) => value.failure.maybeMap(
|
||||
connectionError: (_) => 'Tidak ada koneksi internet. Coba lagi.',
|
||||
connectionTimeout: (_) => 'Koneksi lambat. Coba lagi.',
|
||||
orElse: () => 'Terjadi kesalahan, coba lagi',
|
||||
),
|
||||
unexpectedError: (_) => 'Terjadi kesalahan, coba lagi',
|
||||
unauthorized: (_) => 'Sesi kamu sudah berakhir. Silakan masuk lagi.',
|
||||
invalidRequest: (_) => 'Permintaan tidak dapat diproses. Coba lagi.',
|
||||
rejected: (_) => rejectedMessage ?? 'Permintaan tidak dapat diproses.',
|
||||
notFound: (_) => 'Data tidak ditemukan',
|
||||
tooManyRequests: (value) =>
|
||||
'Terlalu sering meminta kode. Coba lagi dalam '
|
||||
'${value.retryAfterSeconds} detik.',
|
||||
internalError: (_) => 'Terjadi kesalahan, coba lagi',
|
||||
pinNotSet: (_) => 'Kamu belum membuat PIN.',
|
||||
pinInvalid: (value) => value.remainingAttempts == null
|
||||
? 'PIN salah.'
|
||||
: 'PIN salah, sisa ${value.remainingAttempts} percobaan.',
|
||||
pinLocked: (value) => value.lockedUntil == null
|
||||
? 'PIN terkunci. Silakan gunakan Lupa PIN.'
|
||||
: 'PIN terkunci sampai ${formatWibTime(value.lockedUntil!)}.',
|
||||
transferBlocked: (value) => value.transferBlockedUntil == null
|
||||
? 'Transfer sementara ditahan karena PIN baru direset.'
|
||||
: 'Transfer bisa dilakukan lagi pada '
|
||||
'${formatWibDateTime(value.transferBlockedUntil!)}.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Jam WIB, dengan tanggal bila bukan hari ini: "14:30 WIB" /
|
||||
/// "1 Okt 2026 14:30 WIB".
|
||||
String formatWibTime(DateTime time) {
|
||||
final wib = time.toWib;
|
||||
final today = DateTime.now().toWib;
|
||||
final sameDay =
|
||||
wib.year == today.year &&
|
||||
wib.month == today.month &&
|
||||
wib.day == today.day;
|
||||
return sameDay ? '${wib.toHourMinute} WIB' : formatWibDateTime(time);
|
||||
}
|
||||
|
||||
String formatWibDateTime(DateTime time) => '${time.toWib.toShortDatetime} WIB';
|
||||
|
||||
/// Tanggal kalender WIB dari timestamp, mis. `expires_at`.
|
||||
String formatWibDate(DateTime time) => time.toWib.toDate;
|
||||
|
||||
/// Nilai rupiah EnakPoint selalu "setara potongan", tidak pernah saldo atau
|
||||
/// uang (§1 aturan 2).
|
||||
String pointDiscountLabel(int discountValue) =>
|
||||
'setara potongan ${discountValue.currencyFormatRp}';
|
||||
|
||||
/// Jumlah bertanda: "+875" / "−120" (tanda minus U+2212).
|
||||
String formatSignedAmount(int amount) => amount >= 0
|
||||
? '+${amount.thousandFormat}'
|
||||
: '−${amount.abs().thousandFormat}';
|
||||
|
||||
Color signedAmountColor(int amount) =>
|
||||
amount >= 0 ? AppColor.success : AppColor.error;
|
||||
|
||||
String formatCountdown(Duration duration) {
|
||||
final seconds = duration.inSeconds < 0 ? 0 : duration.inSeconds;
|
||||
final m = (seconds ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (seconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
/// Hanya digit, untuk nomor HP dan jumlah.
|
||||
String digitsOnly(String value) => value.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
|
||||
/// Tanggal pendek untuk chip filter: "1 Sep 2026".
|
||||
String formatShortDate(DateTime date) =>
|
||||
DateFormat('d MMM yyyy', 'id_ID').format(date);
|
||||
|
||||
/// Menampilkan jumlah dengan pemisah ribuan saat diketik: "10000" → "10.000".
|
||||
/// Baca nilainya dengan [digitsOnly].
|
||||
class ThousandsInputFormatter extends TextInputFormatter {
|
||||
static const int _maxDigits = 12;
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digits = digitsOnly(newValue.text);
|
||||
if (digits.isEmpty) return const TextEditingValue();
|
||||
if (digits.length > _maxDigits) return oldValue;
|
||||
|
||||
final text = int.parse(digits).thousandFormat;
|
||||
return TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ikon 3D EnakPoint / EnakCoin.
|
||||
class WalletCurrencyIcon extends StatelessWidget {
|
||||
const WalletCurrencyIcon({super.key, required this.currency, this.size = 24});
|
||||
|
||||
final WalletCurrency currency;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = currency == WalletCurrency.point
|
||||
? Assets.icons.enakpointIcon
|
||||
: Assets.icons.enakcoinIcon;
|
||||
// Aset aslinya 512–1024px; decode seukuran tampilan saja.
|
||||
final cacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round();
|
||||
|
||||
return asset.image(
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
cacheWidth: cacheSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/theme/theme.dart';
|
||||
|
||||
/// Konfirmasi sebelum transaksi yang tidak bisa dibatalkan.
|
||||
Future<bool> showWalletConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
required String confirmLabel,
|
||||
}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
backgroundColor: AppColor.white,
|
||||
title: Text(
|
||||
title,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
content: Text(message, style: AppStyle.md),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(
|
||||
'Batal',
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(
|
||||
confirmLabel,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmed == true;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/wallet/wallet.dart';
|
||||
|
||||
class WalletExpiringBanner extends StatelessWidget {
|
||||
const WalletExpiringBanner({
|
||||
super.key,
|
||||
required this.currency,
|
||||
required this.expiring,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final WalletCurrency currency;
|
||||
final ExpiringAmount expiring;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.warningWithOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
border: Border.all(color: AppColor.warning),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, color: AppColor.warning, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${expiring.amount.thousandFormat} ${currency.label} akan '
|
||||
'kedaluwarsa pada ${expiring.date.toDate}',
|
||||
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: AppColor.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../../application/wallet/wallet_history_loader/wallet_history_loader_bloc.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/wallet/wallet.dart';
|
||||
import '../wallet_ui.dart';
|
||||
import 'wallet_transaction_tile.dart';
|
||||
|
||||
/// Riwayat dari [WalletHistoryLoaderBloc] terdekat, sebagai sliver di dalam
|
||||
/// [CustomScrollView]. Filter tipe/mata uang diatur oleh halaman pemakainya.
|
||||
class WalletHistorySection extends StatefulWidget {
|
||||
const WalletHistorySection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.emptyIcon,
|
||||
required this.emptyTitle,
|
||||
required this.emptySubtitle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData emptyIcon;
|
||||
final String emptyTitle;
|
||||
final String Function(WalletCurrency currency) emptySubtitle;
|
||||
|
||||
@override
|
||||
State<WalletHistorySection> createState() => _WalletHistorySectionState();
|
||||
}
|
||||
|
||||
class _WalletHistorySectionState extends State<WalletHistorySection> {
|
||||
static const _radius = Radius.circular(AppValue.borderRadius);
|
||||
|
||||
/// Item yang sudah pernah dianimasikan, supaya tidak berkedip ulang saat
|
||||
/// di-scroll keluar lalu masuk lagi.
|
||||
final Set<String> _animatedIds = {};
|
||||
|
||||
/// Sebelum request pertama selesai, tampilkan skeleton, bukan "kosong".
|
||||
bool _hasFetched = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<WalletHistoryLoaderBloc, WalletHistoryLoaderState>(
|
||||
// Awal fetch halaman pertama (ganti tab / refresh) mengosongkan list,
|
||||
// jadi item yang datang berikutnya boleh dianimasikan lagi.
|
||||
listenWhen: (p, c) => p.isFetching != c.isFetching,
|
||||
listener: (context, state) {
|
||||
if (state.isFetching) {
|
||||
_animatedIds.clear();
|
||||
} else {
|
||||
_hasFetched = true;
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final showList = state.items.isNotEmpty;
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildHeader(state)),
|
||||
if (showList)
|
||||
SliverList.builder(
|
||||
itemCount: state.items.length + (state.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.items.length) return _buildLoadingMore();
|
||||
return _buildItem(state, index);
|
||||
},
|
||||
)
|
||||
else
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: _buildPlaceholder(state),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(WalletHistoryLoaderState state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
state.currency.label,
|
||||
key: ValueKey(state.currency),
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(WalletHistoryLoaderState state, int index) {
|
||||
final tx = state.items[index];
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == state.items.length - 1 && !state.hasMore;
|
||||
final animate = _animatedIds.add(tx.id);
|
||||
|
||||
final tile = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? _radius : Radius.zero,
|
||||
bottom: isLast ? _radius : Radius.zero,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (!isFirst)
|
||||
const Divider(
|
||||
height: 1,
|
||||
indent: 68,
|
||||
endIndent: 16,
|
||||
color: AppColor.borderLight,
|
||||
),
|
||||
WalletTransactionTile(transaction: tx),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!animate) return tile;
|
||||
return _FadeSlideIn(
|
||||
key: ValueKey(tx.id),
|
||||
// Hanya item awal yang bertahap; item hasil scroll langsung muncul.
|
||||
delay: Duration(milliseconds: index < 8 ? index * 50 : 0),
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingMore() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.vertical(bottom: _radius),
|
||||
),
|
||||
child: const Center(
|
||||
child: SpinKitFadingCircle(color: AppColor.primary, size: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(WalletHistoryLoaderState state) {
|
||||
if (state.isFetching || !_hasFetched) {
|
||||
return const _HistorySkeleton(key: ValueKey('skeleton'));
|
||||
}
|
||||
|
||||
final failure = state.failureOption.toNullable();
|
||||
if (failure != null) {
|
||||
return _MessageCard(
|
||||
key: const ValueKey('error'),
|
||||
icon: Icons.wifi_off_rounded,
|
||||
title: walletFailureMessage(failure),
|
||||
action: TextButton(
|
||||
onPressed: () => context.read<WalletHistoryLoaderBloc>().add(
|
||||
const WalletHistoryLoaderEvent.refreshed(),
|
||||
),
|
||||
child: Text(
|
||||
'Coba lagi',
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _MessageCard(
|
||||
key: ValueKey('empty-${state.currency}'),
|
||||
icon: widget.emptyIcon,
|
||||
title: widget.emptyTitle,
|
||||
subtitle: widget.emptySubtitle(state.currency),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageCard extends StatelessWidget {
|
||||
const _MessageCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primaryWithOpacity(0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: AppColor.primary, size: 26),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 4), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistorySkeleton extends StatelessWidget {
|
||||
const _HistorySkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget bar(double width, double height) => Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: AppColor.borderLight,
|
||||
highlightColor: AppColor.white,
|
||||
child: Column(
|
||||
children: List.generate(
|
||||
3,
|
||||
(_) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColor.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
bar(120, 12),
|
||||
const SizedBox(height: 8),
|
||||
bar(80, 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
bar(56, 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade + geser naik sekali saat pertama kali muncul.
|
||||
class _FadeSlideIn extends StatefulWidget {
|
||||
const _FadeSlideIn({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.delay = Duration.zero,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Duration delay;
|
||||
|
||||
@override
|
||||
State<_FadeSlideIn> createState() => _FadeSlideInState();
|
||||
}
|
||||
|
||||
class _FadeSlideInState extends State<_FadeSlideIn>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
late final Animation<double> _curve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(widget.delay, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _curve,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(_curve),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../components/button/button.dart';
|
||||
|
||||
/// Layar sukses untuk tukar dan transfer.
|
||||
class WalletResultView extends StatelessWidget {
|
||||
const WalletResultView({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.amountText,
|
||||
required this.lines,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String amountText;
|
||||
final List<String> lines;
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
const Icon(Icons.check_circle, color: AppColor.success, size: 72),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
amountText,
|
||||
style: AppStyle.h4.copyWith(fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...lines.map(
|
||||
(line) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
line,
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
AppElevatedButton(title: 'Selesai', onPressed: onDone),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/wallet/wallet.dart';
|
||||
import '../wallet_ui.dart';
|
||||
|
||||
class WalletTransactionTile extends StatelessWidget {
|
||||
const WalletTransactionTile({
|
||||
super.key,
|
||||
required this.transaction,
|
||||
this.showCurrency = false,
|
||||
});
|
||||
|
||||
final WalletTransaction transaction;
|
||||
|
||||
/// Tampilkan mata uang di samping jumlah, untuk list yang mencampur
|
||||
/// EnakPoint & EnakCoin. Satu order menghasilkan dua transaksi (POINT dan
|
||||
/// COIN) dengan deskripsi dan jumlah yang sama.
|
||||
final bool showCurrency;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tx = transaction;
|
||||
final color = signedAmountColor(tx.amount);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
tx.isIncoming ? Icons.south_west : Icons.north_east,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tx.type.label,
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (tx.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tx.description,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
if (tx.createdAt != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatWibDateTime(tx.createdAt!),
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
if (tx.isIncoming && tx.expiresAt != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Berlaku sampai ${formatWibDate(tx.expiresAt!)}',
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.warning,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatSignedAmount(tx.amount),
|
||||
style: AppStyle.md.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (showCurrency) ...[
|
||||
const SizedBox(width: 4),
|
||||
WalletCurrencyIcon(currency: tx.currency, size: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (showCurrency) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tx.currency.label,
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user