406 lines
13 KiB
Dart
406 lines
13 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/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<RewardPage> createState() => _RewardPageState();
|
|
|
|
@override
|
|
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
|
|
providers: [
|
|
BlocProvider(
|
|
create: (_) =>
|
|
getIt<VoucherLoaderBloc>()..add(const VoucherLoaderEvent.fetched()),
|
|
),
|
|
BlocProvider(
|
|
create: (_) =>
|
|
getIt<VoucherRedemptionLoaderBloc>()
|
|
..add(const VoucherRedemptionLoaderEvent.refreshed()),
|
|
),
|
|
],
|
|
child: this,
|
|
);
|
|
}
|
|
|
|
class _RewardPageState extends State<RewardPage>
|
|
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<WalletLoaderBloc>().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<void> _openDetail(Voucher voucher) async {
|
|
final redeemed = await context.router.push<bool>(
|
|
RewardDetailRoute(voucher: voucher),
|
|
);
|
|
if (!mounted) return;
|
|
|
|
context.read<VoucherLoaderBloc>().add(const VoucherLoaderEvent.fetched());
|
|
context.read<VoucherRedemptionLoaderBloc>().add(
|
|
const VoucherRedemptionLoaderEvent.refreshed(),
|
|
);
|
|
context.read<WalletLoaderBloc>().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<Voucher> onOpen;
|
|
|
|
Future<void> _onRefresh(BuildContext context) async {
|
|
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
|
final bloc = context.read<VoucherLoaderBloc>();
|
|
bloc.add(const VoucherLoaderEvent.fetched());
|
|
await bloc.stream.firstWhere((s) => !s.isFetching);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocBuilder<VoucherLoaderBloc, VoucherLoaderState>(
|
|
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<VoucherLoaderBloc>().add(
|
|
const VoucherLoaderEvent.fetched(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
|
|
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<void> _onRefresh(BuildContext context) async {
|
|
final bloc = context.read<VoucherRedemptionLoaderBloc>();
|
|
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<VoucherRedemptionLoaderBloc>().add(
|
|
const VoucherRedemptionLoaderEvent.refreshed(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
color: AppColor.primary,
|
|
onRefresh: () => _onRefresh(context),
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) {
|
|
final metrics = notification.metrics;
|
|
if (metrics.pixels >= metrics.maxScrollExtent - 200) {
|
|
context.read<VoucherRedemptionLoaderBloc>().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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|