381 lines
12 KiB
Dart
381 lines
12 KiB
Dart
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,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|