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/voucher/voucher_loader/voucher_loader_bloc.dart'; import '../../../application/voucher/voucher_redemption_loader/voucher_redemption_loader_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 '../../router/app_router.gr.dart'; import '../wallet/wallet_ui.dart'; import 'widgets/redemption_card.dart'; import 'widgets/redemption_detail_sheet.dart'; import 'widgets/voucher_card.dart'; /// EnakReward: tukar EnakPoint ke voucher (§9.1) dan "Voucher saya" (§9.3). @RoutePage() class RewardPage extends StatefulWidget implements AutoRouteWrapper { const RewardPage({super.key}); @override State createState() => _RewardPageState(); @override Widget wrappedRoute(BuildContext context) => MultiBlocProvider( providers: [ BlocProvider( create: (_) => getIt()..add(const VoucherLoaderEvent.fetched()), ), BlocProvider( create: (_) => getIt() ..add(const VoucherRedemptionLoaderEvent.refreshed()), ), ], child: this, ); } class _RewardPageState extends State with SingleTickerProviderStateMixin { late final TabController _tabController = TabController( length: 2, vsync: this, ); @override void initState() { super.initState(); // Tombol Tukar nonaktif bila saldo EnakPoint kurang. context.read().add(const WalletLoaderEvent.fetched()); } @override void dispose() { _tabController.dispose(); super.dispose(); } /// Katalog, voucher saya, dan saldo dimuat ulang setiap kembali dari detail: /// stok bisa berubah, dan voucher yang habis harus hilang dari katalog. Future _openDetail(Voucher voucher) async { final redeemed = await context.router.push( RewardDetailRoute(voucher: voucher), ); if (!mounted) return; context.read().add(const VoucherLoaderEvent.fetched()); context.read().add( const VoucherRedemptionLoaderEvent.refreshed(), ); context.read().add(const WalletLoaderEvent.fetched()); if (redeemed == true) _tabController.animateTo(1); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColor.background, appBar: AppBar( title: const Text('EnakReward'), bottom: TabBar( controller: _tabController, tabs: const [ Tab(text: 'Tukar voucher'), Tab(text: 'Voucher saya'), ], ), ), body: TabBarView( controller: _tabController, children: [ _CatalogTab(onOpen: _openDetail), _RedemptionTab(onBrowse: () => _tabController.animateTo(0)), ], ), ); } } class _CatalogTab extends StatelessWidget { const _CatalogTab({required this.onOpen}); final ValueChanged onOpen; Future _onRefresh(BuildContext context) async { context.read().add(const WalletLoaderEvent.fetched()); final bloc = context.read(); bloc.add(const VoucherLoaderEvent.fetched()); await bloc.stream.firstWhere((s) => !s.isFetching); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (!state.hasLoaded) { return state.failureOption.fold( () => const Center( child: SpinKitFadingCircle(color: AppColor.primary, size: 36), ), (f) => _ErrorView( message: walletFailureMessage(f), onRetry: () => context.read().add( const VoucherLoaderEvent.fetched(), ), ), ); } return BlocBuilder( builder: (context, walletState) => RefreshIndicator( color: AppColor.primary, onRefresh: () => _onRefresh(context), child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ _PointBalanceCard(walletState: walletState), const SizedBox(height: 16), if (state.vouchers.isEmpty) const _EmptyView( icon: Icons.confirmation_number_outlined, message: 'Belum ada voucher yang bisa ditukar', ) else ...state.vouchers.map( (voucher) => Padding( padding: const EdgeInsets.only(bottom: 12), child: VoucherCard( voucher: voucher, canAfford: !walletState.hasLoaded || walletState.summary.pointBalance >= voucher.pointCost, onTap: () => onOpen(voucher), ), ), ), ], ), ), ); }, ); } } class _RedemptionTab extends StatelessWidget { const _RedemptionTab({required this.onBrowse}); final VoidCallback onBrowse; Future _onRefresh(BuildContext context) async { final bloc = context.read(); bloc.add(const VoucherRedemptionLoaderEvent.refreshed()); await bloc.stream.firstWhere((s) => !s.isFetching); } @override Widget build(BuildContext context) { return BlocConsumer< VoucherRedemptionLoaderBloc, VoucherRedemptionLoaderState >( 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) { if (!state.hasLoaded) { return state.failureOption.fold( () => const Center( child: SpinKitFadingCircle(color: AppColor.primary, size: 36), ), (f) => _ErrorView( message: walletFailureMessage(f), onRetry: () => context.read().add( const VoucherRedemptionLoaderEvent.refreshed(), ), ), ); } return RefreshIndicator( color: AppColor.primary, onRefresh: () => _onRefresh(context), child: NotificationListener( onNotification: (notification) { final metrics = notification.metrics; if (metrics.pixels >= metrics.maxScrollExtent - 200) { context.read().add( const VoucherRedemptionLoaderEvent.loadedMore(), ); } return false; }, child: state.items.isEmpty ? ListView( padding: const EdgeInsets.all(16), children: [ _EmptyView( icon: Icons.local_activity_outlined, message: 'Belum ada voucher', actionLabel: 'Tukar EnakPoint sekarang', onAction: onBrowse, ), ], ) : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), itemCount: state.items.length + (state.hasMore ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, index) { if (index >= state.items.length) { return const Padding( padding: EdgeInsets.all(16), child: Center( child: SpinKitFadingCircle( color: AppColor.primary, size: 24, ), ), ); } final redemption = state.items[index]; return RedemptionCard( redemption: redemption, onTap: () => RedemptionDetailSheet.show(context, redemption), ); }, ), ), ); }, ); } } /// Saldo EnakPoint, selalu "setara potongan", tidak pernah "saldo Rp". class _PointBalanceCard extends StatelessWidget { const _PointBalanceCard({required this.walletState}); final WalletLoaderState walletState; @override Widget build(BuildContext context) { final summary = walletState.summary; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: const LinearGradient( colors: AppColor.primaryGradient, begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Container( width: 44, height: 44, padding: const EdgeInsets.all(6), decoration: const BoxDecoration( color: AppColor.white, shape: BoxShape.circle, ), child: const WalletCurrencyIcon( currency: WalletCurrency.point, size: 32, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'EnakPoint kamu', style: AppStyle.sm.copyWith( color: AppColor.textWhite.withOpacity(0.9), ), ), Text( walletState.hasLoaded ? summary.pointBalance.thousandFormat : '…', style: AppStyle.h5.copyWith( color: AppColor.textWhite, fontWeight: FontWeight.w700, ), ), if (walletState.hasLoaded) Text( pointDiscountLabel(summary.pointDiscountValue), style: AppStyle.xs.copyWith( color: AppColor.textWhite.withOpacity(0.9), ), ), ], ), ), ], ), ); } } class _EmptyView extends StatelessWidget { const _EmptyView({ required this.icon, required this.message, this.actionLabel, this.onAction, }); final IconData icon; final String message; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 48), child: Column( children: [ Icon(icon, size: 56, color: AppColor.textLight), const SizedBox(height: 12), Text( message, textAlign: TextAlign.center, style: AppStyle.md.copyWith(color: AppColor.textSecondary), ), if (actionLabel != null) ...[ const SizedBox(height: 16), AppOutlineButton( title: actionLabel!, width: 220, onPressed: onAction, ), ], ], ), ); } } class _ErrorView extends StatelessWidget { const _ErrorView({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { 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: onRetry, ), ], ), ), ); } }