Files
enaklo-flutter/lib/presentation/pages/wallet/wallet_history_page.dart
T
efrilm 38850b281f
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
feat: integrate enakgame and voucher
2026-10-10 02:33:16 +07:00

384 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
currency: state.currency,
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.currency, required this.initial});
/// Tab yang sedang aktif; hanya tipe milik mata uang ini yang ditawarkan.
final WalletCurrency currency;
final _HistoryFilter initial;
@override
State<_FilterSheet> createState() => _FilterSheetState();
}
class _FilterSheetState extends State<_FilterSheet> {
late final List<WalletTransactionType> _filterable =
WalletTransactionType.filterableFor(widget.currency);
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: _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: _filterable.where(_types.contains).toList(),
from: _from,
to: _to,
),
),
),
),
],
),
],
),
),
);
}
}