feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
This commit is contained in:
@@ -1,108 +1,281 @@
|
||||
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 '../../../sample/reward_sample_data.dart';
|
||||
import '../../components/assets/assets.gen.dart';
|
||||
import '../../components/delegate/filter_bar_delegate.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 'reward_ui.dart';
|
||||
import 'widgets/reward_card.dart';
|
||||
import 'widgets/reward_detail_sheet.dart';
|
||||
|
||||
enum _RewardFilter {
|
||||
all('Semua'),
|
||||
active('Aktif'),
|
||||
used('Terpakai'),
|
||||
expired('Kedaluwarsa');
|
||||
|
||||
const _RewardFilter(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
bool matches(Reward reward) => switch (this) {
|
||||
_RewardFilter.all => true,
|
||||
_RewardFilter.active => reward.status == RewardStatus.active,
|
||||
_RewardFilter.used => reward.status == RewardStatus.used,
|
||||
_RewardFilter.expired => reward.status == RewardStatus.expired,
|
||||
};
|
||||
}
|
||||
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 {
|
||||
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> {
|
||||
_RewardFilter _filter = _RewardFilter.all;
|
||||
class _RewardPageState extends State<RewardPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
/// Aktif lebih dulu, urut yang paling cepat berakhir; sisanya terbaru dulu.
|
||||
final List<Reward> _rewards = [...rewardSamples]
|
||||
..sort((a, b) {
|
||||
final aActive = a.status == RewardStatus.active;
|
||||
final bActive = b.status == RewardStatus.active;
|
||||
if (aActive != bActive) return aActive ? -1 : 1;
|
||||
if (aActive) return a.validUntil.compareTo(b.validUntil);
|
||||
return b.redeemedAt.compareTo(a.redeemedAt);
|
||||
});
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Tombol Tukar nonaktif bila saldo EnakPoint kurang.
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
}
|
||||
|
||||
List<Reward> get _activeRewards =>
|
||||
_rewards.where(_RewardFilter.active.matches).toList();
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _countOf(_RewardFilter filter) => _rewards.where(filter.matches).length;
|
||||
/// 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) {
|
||||
final visible = _rewards.where(_filter.matches).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('Reward Saya')),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildSummary()),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: FilterBarDelegate<_RewardFilter>(
|
||||
values: _RewardFilter.values,
|
||||
selected: _filter,
|
||||
labelOf: (filter) => filter.label,
|
||||
countOf: _countOf,
|
||||
onSelected: (filter) => setState(() => _filter = filter),
|
||||
),
|
||||
),
|
||||
if (visible.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmpty())
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: visible.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final reward = visible[index];
|
||||
return RewardCard(
|
||||
reward: reward,
|
||||
onTap: () => RewardDetailSheet.show(context, reward),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSummary() {
|
||||
final active = _activeRewards;
|
||||
// Sudah terurut: yang pertama adalah yang paling cepat berakhir.
|
||||
final expiringSoon = active.where(isRewardExpiringSoon).firstOrNull;
|
||||
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(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
@@ -111,183 +284,90 @@ class _RewardPageState extends State<RewardPage> {
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.primary.withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColor.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Assets.icons.enakrewardIcon.image(
|
||||
fit: BoxFit.contain,
|
||||
// Aset aslinya besar; decode seukuran tampilan saja.
|
||||
cacheWidth: (32 * MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Siap dipakai',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${active.length} reward',
|
||||
style: AppStyle.h5.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => context.router.push(const CoinRoute()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'Tukar Poin',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (expiringSoon != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Material(
|
||||
color: AppColor.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => RewardDetailSheet.show(context, expiringSoon),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 16,
|
||||
color: AppColor.textWhite,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${expiringSoon.name} · '
|
||||
'${rewardValidityLabel(expiringSoon).toLowerCase()}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 18,
|
||||
color: AppColor.textWhite,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
final (String emoji, String message) = switch (_filter) {
|
||||
_RewardFilter.all => ('🎁', 'Belum ada reward'),
|
||||
_RewardFilter.active => ('🎯', 'Belum ada reward aktif'),
|
||||
_RewardFilter.used => ('✅', 'Belum ada reward yang dipakai'),
|
||||
_RewardFilter.expired => ('⏰', 'Tidak ada reward yang kedaluwarsa'),
|
||||
};
|
||||
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.fromLTRB(32, 32, 32, 64),
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.06),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 44)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Icon(icon, size: 56, color: AppColor.textLight),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Tukar poin kamu untuk mendapatkan reward menarik',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
if (_filter == _RewardFilter.all ||
|
||||
_filter == _RewardFilter.active) ...[
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.router.push(const CoinRoute()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Tukar Poin Sekarang',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: actionLabel!,
|
||||
width: 220,
|
||||
onPressed: onAction,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -295,3 +375,31 @@ class _RewardPageState extends State<RewardPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user