import 'dart:async'; 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/exchange_form/exchange_form_bloc.dart'; import '../../../application/wallet/wallet_history_loader/wallet_history_loader_bloc.dart'; import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart'; import '../../../common/extension/extension.dart'; import '../../../common/function/app_function.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 '../pin/pin_guard.dart'; import '../pin/widgets/pin_sheet.dart'; import 'wallet_ui.dart'; import 'widgets/wallet_confirm_dialog.dart'; import 'widgets/wallet_history_section.dart'; import 'widgets/wallet_result_view.dart'; /// Tukar EnakCoin → EnakPoint (§8.1). @RoutePage() class ExchangePage extends StatefulWidget implements AutoRouteWrapper { const ExchangePage({super.key}); @override State createState() => _ExchangePageState(); @override Widget wrappedRoute(BuildContext context) => MultiBlocProvider( providers: [ BlocProvider(create: (_) => getIt()), // Riwayat dilihat dari sisi EnakPoint: berapa poin yang didapat. BlocProvider( create: (_) => getIt() ..add( const WalletHistoryLoaderEvent.filterChanged( types: [WalletTransactionType.exchangeIn], ), ), ), ], child: this, ); } class _ExchangePageState extends State { final TextEditingController _coinsController = TextEditingController(); final ScrollController _scrollController = ScrollController(); Timer? _debounce; /// Kurs terakhir yang diketahui, tetap ditampilkan selama preview baru /// sedang dihitung. ExchangePreview? _rate; @override void initState() { super.initState(); context.read().add(const WalletLoaderEvent.fetched()); _scrollController.addListener(_onScroll); } @override void dispose() { _debounce?.cancel(); _coinsController.dispose(); _scrollController.dispose(); super.dispose(); } void _onScroll() { final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 200) { context.read().add( const WalletHistoryLoaderEvent.loadedMore(), ); } } Future _onRefresh() async { context.read().add(const WalletLoaderEvent.fetched()); final history = context.read(); history.add(const WalletHistoryLoaderEvent.refreshed()); await history.stream.firstWhere((s) => !s.isFetching); } void _onCoinsChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 500), () { if (!mounted) return; context.read().add( ExchangeFormEvent.coinsChanged(int.tryParse(digitsOnly(value)) ?? 0), ); }); } /// Isi jumlah langsung tanpa debounce, mis. dari tombol "Tukar semua". void _fillCoins(int coins) { _debounce?.cancel(); final text = coins.thousandFormat; _coinsController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); context.read().add(ExchangeFormEvent.coinsChanged(coins)); } /// Bulatkan ke bawah ke kelipatan kurs bila kursnya sudah diketahui. int _roundToRate(int coins) { final step = _rate?.coinAmount ?? 0; return step > 0 ? coins - coins % step : coins; } Future _confirm(ExchangeFormState state) async { dismissKeyboard(context); final preview = state.preview!; final confirmed = await showWalletConfirmDialog( context, title: 'Tukar EnakCoin?', message: 'Tukar ${preview.coins.thousandFormat} EnakCoin menjadi ' '${preview.points.thousandFormat} EnakPoint. Penukaran tidak bisa ' 'dibatalkan.', confirmLabel: 'Tukar', ); if (!confirmed || !mounted) return; if (!await ensurePinReady(context) || !mounted) return; final success = await showPinSheet( context, title: 'Tukar EnakCoin', onSubmit: (pin) async { final bloc = context.read(); bloc.add(ExchangeFormEvent.submitted(pin)); final result = await bloc.stream.firstWhere((s) => !s.isSubmitting); return result.failureOption.toNullable(); }, ); if (success && mounted) { context.read().add(const WalletLoaderEvent.fetched()); } } void _showFailure(WalletFailure failure) { if (failure.isPinError) return; if (failure.isNetworkError) { AppFlushbar.showError( context, 'Koneksi terputus, status penukaran belum diketahui. Tekan Tukar lagi ' 'untuk mengulang. Saldo tidak akan terpotong dua kali.', ); return; } AppFlushbar.showError( context, walletFailureMessage( failure, rejectedMessage: 'Penukaran tidak bisa diproses. Periksa jumlah dan saldo ' 'EnakCoin kamu.', ), ); } @override Widget build(BuildContext context) { return MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.failureOption != c.failureOption, listener: (context, state) => state.failureOption.fold(() {}, _showFailure), ), BlocListener( listenWhen: (p, c) => p.preview != c.preview && c.preview != null, listener: (context, state) => setState(() => _rate = state.preview), ), ], child: BlocBuilder( builder: (context, state) { final result = state.result; return Scaffold( backgroundColor: AppColor.background, appBar: AppBar(title: const Text('Tukar EnakCoin')), body: AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: result != null ? KeyedSubtree( key: const ValueKey('success'), child: _buildSuccess(result), ) : KeyedSubtree( key: const ValueKey('form'), child: _buildForm(state), ), ), ); }, ), ); } Widget _buildForm(ExchangeFormState state) { return RefreshIndicator( color: AppColor.primary, onRefresh: _onRefresh, child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 28), sliver: SliverToBoxAdapter( child: BlocBuilder( buildWhen: (p, c) => p.summary != c.summary || p.hasLoaded != c.hasLoaded, builder: (context, walletState) => _buildFormContent(state, walletState), ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), sliver: WalletHistorySection( title: 'Riwayat penukaran', emptyIcon: Icons.currency_exchange_rounded, emptyTitle: 'Belum ada penukaran', emptySubtitle: (_) => 'EnakPoint yang kamu dapat dari tukar EnakCoin akan ' 'muncul di sini.', ), ), ], ), ); } Widget _buildFormContent( ExchangeFormState state, WalletLoaderState walletState, ) { final summary = walletState.summary; final coinBalance = summary.coinBalance; final exceedsBalance = walletState.hasLoaded && state.coins > coinBalance; final preview = state.preview; final points = preview != null && preview.valid && !exceedsBalance ? preview.points : 0; final maxCoins = _roundToRate(coinBalance); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _ExchangeCard( label: 'Kamu tukar', currency: WalletCurrency.coin, trailing: Text( walletState.hasLoaded ? 'Saldo ${coinBalance.thousandFormat}' : 'Memuat saldo…', style: AppStyle.sm.copyWith(color: AppColor.textSecondary), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _coinsController, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, enabled: !state.isSubmitting, cursorColor: AppColor.primary, inputFormatters: [ThousandsInputFormatter()], onChanged: _onCoinsChanged, style: AppStyle.h4.copyWith(fontWeight: FontWeight.w700), decoration: InputDecoration( hintText: '0', hintStyle: AppStyle.h4.copyWith( color: AppColor.textLight, fontWeight: FontWeight.w700, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 8), ), ), Align( alignment: Alignment.centerLeft, child: _QuickChip( label: 'Tukar semua', onTap: walletState.hasLoaded && maxCoins > 0 && !state.isSubmitting ? () => _fillCoins(maxCoins) : null, ), ), ], ), ), const SizedBox(height: 8), Stack( clipBehavior: Clip.none, children: [ _ExchangeCard( label: 'Kamu dapat', currency: WalletCurrency.point, highlighted: points > 0, trailing: Text( walletState.hasLoaded ? 'Saldo ${summary.pointBalance.thousandFormat}' : '', style: AppStyle.sm.copyWith(color: AppColor.textSecondary), ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: state.isPreviewing ? 0.35 : 1, child: TweenAnimationBuilder( tween: IntTween(begin: 0, end: points), duration: const Duration(milliseconds: 450), curve: Curves.easeOutCubic, builder: (context, value, _) => Text( value.thousandFormat, style: AppStyle.h4.copyWith( fontWeight: FontWeight.w700, color: points > 0 ? AppColor.success : AppColor.textLight, ), ), ), ), ), ), const Positioned( top: -24, left: 0, right: 0, child: Center(child: _SwapBadge()), ), ], ), const SizedBox(height: 12), AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, alignment: Alignment.topCenter, child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _buildStatus(state, exceedsBalance), ), ), const SizedBox(height: 20), AppElevatedButton( title: 'Tukar', height: 48, isLoading: state.isSubmitting, onPressed: state.canSubmit && !exceedsBalance ? () => _confirm(state) : null, ), ], ); } /// Satu baris keterangan di bawah kartu: kurs, proses hitung, atau error. Widget _buildStatus(ExchangeFormState state, bool exceedsBalance) { if (state.isPreviewing) { return const _StatusLine( key: ValueKey('previewing'), icon: SpinKitThreeBounce(color: AppColor.primary, size: 14), text: 'Menghitung…', ); } if (exceedsBalance) { return const _StatusLine( key: ValueKey('exceeds'), icon: Icon( Icons.error_outline_rounded, size: 16, color: AppColor.error, ), text: 'Melebihi saldo EnakCoin kamu', color: AppColor.error, ); } final previewFailure = state.previewFailureOption.toNullable(); if (previewFailure != null) { return _StatusLine( key: const ValueKey('failure'), icon: const Icon( Icons.error_outline_rounded, size: 16, color: AppColor.error, ), text: walletFailureMessage( previewFailure, rejectedMessage: 'Jumlah EnakCoin tidak valid.', ), color: AppColor.error, ); } final preview = state.preview; if (preview != null && !preview.valid) { final rounded = _roundToRate(state.coins); return _StatusLine( key: const ValueKey('invalid'), icon: const Icon( Icons.error_outline_rounded, size: 16, color: AppColor.error, ), text: preview.reason ?? 'Jumlah harus kelipatan ${preview.coinAmount.thousandFormat} ' 'EnakCoin.', color: AppColor.error, action: rounded > 0 && rounded != state.coins ? _QuickChip( label: 'Pakai ${rounded.thousandFormat}', onTap: () => _fillCoins(rounded), ) : null, ); } final rate = _rate; if (rate == null) { return const _StatusLine( key: ValueKey('hint'), icon: Icon( Icons.info_outline_rounded, size: 16, color: AppColor.textSecondary, ), text: 'Masukkan jumlah EnakCoin untuk melihat kurs penukaran.', ); } return _StatusLine( key: const ValueKey('rate'), icon: const Icon( Icons.currency_exchange_rounded, size: 16, color: AppColor.textSecondary, ), text: 'Kurs ${rate.coinAmount.thousandFormat} EnakCoin = ' '${rate.pointAmount.thousandFormat} EnakPoint · penukaran tidak ' 'bisa dibatalkan', ); } Widget _buildSuccess(ExchangeResult result) { return WalletResultView( title: 'Penukaran berhasil', amountText: '+${result.points.thousandFormat} EnakPoint', lines: [ 'Ditukar dari ${result.coins.thousandFormat} EnakCoin', 'Saldo sekarang: ${result.pointBalance.thousandFormat} EnakPoint · ' '${result.coinBalance.thousandFormat} EnakCoin', ...result.lots .where((lot) => lot.expiresAt != null) .map( (lot) => '${lot.amount.thousandFormat} EnakPoint ini berlaku sampai ' '${formatWibDate(lot.expiresAt!)}', ), ], onDone: () => context.router.maybePop(), ); } } /// Kartu satu sisi penukaran (asal atau tujuan). class _ExchangeCard extends StatelessWidget { const _ExchangeCard({ required this.label, required this.currency, required this.trailing, required this.child, this.highlighted = false, }); final String label; final WalletCurrency currency; final Widget trailing; final Widget child; final bool highlighted; @override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 250), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: highlighted ? AppColor.successWithOpacity(0.06) : AppColor.white, borderRadius: BorderRadius.circular(AppValue.borderRadius), border: Border.all( color: highlighted ? AppColor.successWithOpacity(0.4) : AppColor.white, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( label, style: AppStyle.sm.copyWith( color: AppColor.textSecondary, fontWeight: FontWeight.w600, ), ), const Spacer(), trailing, ], ), const SizedBox(height: 4), Row( children: [ Expanded(child: child), const SizedBox(width: 12), Container( padding: const EdgeInsets.fromLTRB(6, 6, 12, 6), decoration: BoxDecoration( color: AppColor.background, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ WalletCurrencyIcon(currency: currency, size: 24), const SizedBox(width: 6), Text( currency.label, style: AppStyle.sm.copyWith(fontWeight: FontWeight.w700), ), ], ), ), ], ), ], ), ); } } /// Lingkaran panah di antara dua kartu. class _SwapBadge extends StatelessWidget { const _SwapBadge(); @override Widget build(BuildContext context) { return Container( width: 40, height: 40, decoration: BoxDecoration( color: AppColor.primary, shape: BoxShape.circle, border: Border.all(color: AppColor.background, width: 4), ), child: const Icon( Icons.arrow_downward_rounded, size: 18, color: AppColor.white, ), ); } } class _QuickChip extends StatelessWidget { const _QuickChip({required this.label, required this.onTap}); final String label; final VoidCallback? onTap; @override Widget build(BuildContext context) { final enabled = onTap != null; return Material( color: enabled ? AppColor.primaryWithOpacity(0.08) : AppColor.background, borderRadius: BorderRadius.circular(20), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Text( label, style: AppStyle.xs.copyWith( color: enabled ? AppColor.primary : AppColor.textLight, fontWeight: FontWeight.w700, ), ), ), ), ); } } class _StatusLine extends StatelessWidget { const _StatusLine({ super.key, required this.icon, required this.text, this.color = AppColor.textSecondary, this.action, }); final Widget icon; final String text; final Color color; final Widget? action; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ SizedBox(width: 20, child: Center(child: icon)), const SizedBox(width: 6), Expanded( child: Text(text, style: AppStyle.xs.copyWith(color: color)), ), if (action != null) ...[const SizedBox(width: 8), action!], ], ), ); } }