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 createState() => _WalletHistoryPageState(); @override Widget wrappedRoute(BuildContext context) => BlocProvider( create: (_) => getIt() ..add(WalletHistoryLoaderEvent.currencyChanged(initialCurrency)), child: this, ); } class _WalletHistoryPageState extends State 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().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().add( const WalletHistoryLoaderEvent.loadedMore(), ); } } Future _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().add( WalletHistoryLoaderEvent.filterChanged( types: result.types, from: result.from, to: result.to, ), ); } @override Widget build(BuildContext context) { return BlocConsumer( 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 = [ 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().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().add( const WalletHistoryLoaderEvent.refreshed(), ), ), ], ), ); } return RefreshIndicator( color: AppColor.primary, onRefresh: () async { final bloc = context.read(); 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 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 _types = {...widget.initial.types}; late DateTime? _from = widget.initial.from; late DateTime? _to = widget.initial.to; Future _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, ), ), ), ), ], ), ], ), ), ); } }