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 createState() => _WalletHistorySectionState(); } class _WalletHistorySectionState extends State { 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 _animatedIds = {}; /// Sebelum request pertama selesai, tampilkan skeleton, bukan "kosong". bool _hasFetched = false; @override Widget build(BuildContext context) { return BlocConsumer( // 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().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 _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, ), ); } }