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:
efrilm
2026-10-01 01:43:22 +07:00
co-authored by Claude Opus 5.5
parent 5954510b93
commit f5e3075205
253 changed files with 47212 additions and 17437 deletions
@@ -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),
),
],
],
),
],
),
);
}
}