EnakPoint & EnakCoin wallet, PIN, outlet, order, community, reward

- Wallet EnakPoint & EnakCoin: beranda, riwayat, saldo kedaluwarsa, kode
  bayar, tukar EnakCoin, transfer
- PIN (buat, ganti, lupa), outlet, riwayat order, game spin
- Halaman community, webview, coming soon, dan ikon menu beranda baru
- Redesign Reward Saya: kartu tiket, filter chip, detail kode + QR
- Hapus customer point loader, menu, dan checkout lama
- Base URL ke enaklo-pos-api.altru.id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-01 01:43:22 +07:00
co-authored by Claude Opus 5.5
parent 5954510b93
commit f5e3075205
253 changed files with 47212 additions and 17437 deletions
@@ -4,12 +4,14 @@ import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../application/auth/auth_bloc.dart';
import '../../../../../application/customer/customer_point_loader/customer_point_loader_bloc.dart';
import '../../../../../common/constant/app_constant.dart';
import '../../../../../application/outlet/outlet_loader/outlet_loader_bloc.dart';
import '../../../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
// import '../../../../../common/constant/app_constant.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../components/image/image.dart';
import '../../../../router/app_router.gr.dart';
import 'widgets/banner_card.dart';
// import 'widgets/banner_card.dart';
import 'widgets/outlet_section.dart';
import 'widgets/point_card.dart';
import 'widgets/popular_merchant_section.dart';
import 'widgets/service_section.dart';
@@ -38,9 +40,8 @@ class _HomePageState extends State<HomePage> {
void initState() {
super.initState();
if (context.read<AuthBloc>().state.isAuthenticated) {
context.read<CustomerPointLoaderBloc>().add(
CustomerPointLoaderEvent.fetched(),
);
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
context.read<OutletLoaderBloc>().add(const OutletLoaderEvent.fetched());
}
}
@@ -52,16 +53,17 @@ class _HomePageState extends State<HomePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderSection(),
const SizedBox(height: 70),
const SizedBox(height: 8),
HomeServiceSection(),
HomeBanner(
title: '🎰 My Rewards',
subtitle:
'Main dan tepat menangkan produk gratis dari ${AppConstant.appName}.',
actionText: 'Mainkan Sekarang',
onTap: () => context.router.push(DrawRoute()),
),
// HomeBanner(
// title: '🎰 My Rewards',
// subtitle:
// 'Main dan tepat menangkan produk gratis dari ${AppConstant.appName}.',
// actionText: 'Mainkan Sekarang',
// onTap: () => context.router.push(DrawRoute()),
// ),
HomePopularMerchantSection(),
const HomeOutletSection(),
],
),
),
@@ -81,18 +83,11 @@ class _HomePageState extends State<HomePage> {
top: 225,
child: _buildCarouselIndicators(),
),
Positioned(
left: 16,
right: 16,
top: 240,
child: HomePointCard(
point: context
.read<CustomerPointLoaderBloc>()
.state
.customerPoint
.totalPoints
.toString(),
),
// Tidak di-Positioned supaya tinggi kartu ikut dihitung, sehingga jarak
// ke menu di bawahnya tetap walau isi kartu bertambah.
const Padding(
padding: EdgeInsets.fromLTRB(16, 240, 16, 0),
child: HomePointCard(),
),
],
);
@@ -0,0 +1,54 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/auth/auth_bloc.dart';
import '../../../../../../application/outlet/outlet_loader/outlet_loader_bloc.dart';
import '../../../../../router/app_router.gr.dart';
import '../../../../outlet/widgets/outlet_tile.dart';
import 'section_header.dart';
/// Seksi "Outlet Kami" (§4.4). Hanya untuk customer yang sudah masuk, karena
/// daftar outlet mengikuti organisasi akunnya.
class HomeOutletSection extends StatelessWidget {
const HomeOutletSection({super.key});
static const int _maxShown = 3;
@override
Widget build(BuildContext context) {
final isAuthenticated = context.select<AuthBloc, bool>(
(bloc) => bloc.state.isAuthenticated,
);
if (!isAuthenticated) return const SizedBox.shrink();
return BlocBuilder<OutletLoaderBloc, OutletLoaderState>(
buildWhen: (p, c) => p.outlets != c.outlets,
builder: (context, state) {
if (state.outlets.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionHeader(
eyebrow: 'Outlet',
title: 'Outlet Kami',
onSeeAll: state.outlets.length > _maxShown
? () => context.router.push(const OutletRoute())
: null,
),
const SizedBox(height: 12),
for (final outlet in state.outlets.take(_maxShown))
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: OutletTile(outlet: outlet),
),
],
),
);
},
);
}
}
@@ -3,165 +3,64 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/auth/auth_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 '../../../../../router/app_router.gr.dart';
import '../../../../wallet/wallet_ui.dart';
class HomePointCard extends StatelessWidget {
final String point;
const HomePointCard({super.key, required this.point});
const HomePointCard({super.key});
@override
Widget build(BuildContext context) {
final summary = context.watch<WalletLoaderBloc>().state.summary;
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return GestureDetector(
onTap: () => state.isAuthenticated
? context.router.push(CoinRoute())
: context.router.push(OnboardingRoute()),
child: Container(
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.15),
spreadRadius: 0,
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Stack(
children: [
_buildCoinPattern(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
!state.isAuthenticated
? Expanded(
child: Text(
'Hi, Selamat Datang Di Enaklo',
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
)
: Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColor.primary,
borderRadius: BorderRadius.circular(
25,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.stars,
color: AppColor.white,
size: 18,
),
SizedBox(width: 8),
Text(
'$point Poin',
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 4),
Text(
'Kamu punya $point poin',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontSize: 11,
),
),
],
),
),
final isAuthenticated = state.isAuthenticated;
SizedBox(
width: 120,
height: 40,
child: Stack(
children: [
_buildCoin(
right: 0,
top: 0,
size: 24,
color: Colors.amber,
),
_buildCoin(
right: 20,
top: 8,
size: 20,
color: Colors.orange,
),
_buildCoin(
right: 40,
top: 4,
size: 18,
color: Colors.amber,
),
_buildCoin(
right: 60,
top: 12,
size: 16,
color: Colors.orange,
),
_buildCoin(
right: 80,
top: 8,
size: 14,
color: Colors.amber,
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Text(
state.isAuthenticated
? 'Tukarkan poinmu dengan hadiah menarik'
: 'Silahkan login untuk tukarkan poinmu',
style: AppStyle.sm.copyWith(
color: AppColor.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
Icon(
Icons.arrow_forward_ios,
return Material(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
shadowColor: AppColor.textLight.withOpacity(0.3),
elevation: 6,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => isAuthenticated
? context.router.push(WalletRoute())
: context.router.push(OnboardingRoute()),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
children: [
isAuthenticated ? _buildBalances(summary) : _buildGuest(),
const SizedBox(height: 12),
const Divider(height: 1, color: AppColor.borderLight),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
isAuthenticated
? 'Bayar di kasir, tukar, transfer & main game'
: 'Silahkan login untuk melihat EnakPoint kamu',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
size: 16,
fontWeight: FontWeight.w500,
),
],
),
),
const Icon(
Icons.arrow_forward_ios_rounded,
color: AppColor.textSecondary,
size: 14,
),
],
),
),
],
],
),
),
),
);
@@ -169,90 +68,129 @@ class HomePointCard extends StatelessWidget {
);
}
Widget _buildCoinPattern() {
return Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
Positioned(
right: -20,
top: -10,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
shape: BoxShape.circle,
),
),
Widget _buildBalances(WalletSummary summary) {
return IntrinsicHeight(
child: Row(
children: [
Expanded(
child: _BalanceTile(
currency: WalletCurrency.point,
balance: summary.pointBalance,
caption: pointDiscountLabel(summary.pointDiscountValue),
),
Positioned(
right: 40,
top: 30,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.15),
shape: BoxShape.circle,
),
),
),
const VerticalDivider(
width: 24,
thickness: 1,
color: AppColor.borderLight,
),
Expanded(
child: _BalanceTile(
currency: WalletCurrency.coin,
balance: summary.coinBalance,
),
Positioned(
left: -15,
bottom: -20,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.08),
shape: BoxShape.circle,
),
),
),
Positioned(
left: 60,
bottom: 10,
child: Container(
width: 15,
height: 15,
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.12),
shape: BoxShape.circle,
),
),
),
],
),
),
],
),
);
}
Widget _buildCoin({
required double right,
required double top,
required double size,
required Color color,
}) {
return Positioned(
right: right,
top: top,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Center(
Widget _buildGuest() {
return Row(
children: [
Expanded(
child: Text(
'\$',
style: TextStyle(
color: Colors.white,
fontSize: size * 0.5,
fontWeight: FontWeight.bold,
'Hi, Selamat Datang Di Enaklo',
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 12),
const SizedBox(
width: 64,
height: 40,
child: Stack(
children: [
Positioned(
left: 0,
top: 4,
child: WalletCurrencyIcon(
currency: WalletCurrency.coin,
size: 32,
),
),
Positioned(
right: 0,
top: 0,
child: WalletCurrencyIcon(
currency: WalletCurrency.point,
size: 36,
),
),
],
),
),
],
);
}
}
/// Satu kolom saldo: ikon, nama mata uang, dan jumlahnya.
class _BalanceTile extends StatelessWidget {
const _BalanceTile({
required this.currency,
required this.balance,
this.caption,
});
final WalletCurrency currency;
final int balance;
final String? caption;
@override
Widget build(BuildContext context) {
return Row(
children: [
WalletCurrencyIcon(currency: currency, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currency.label,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
balance.thousandFormat,
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
),
),
if (caption != null)
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
caption!,
style: AppStyle.xs.copyWith(
fontSize: 10,
color: AppColor.textSecondary,
),
),
),
],
),
),
],
);
}
}
@@ -1,156 +1,82 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../sample/sample_data.dart';
import '../../../../../components/image/image.dart';
/// Banner brand partner di seksi "Brand Kami".
class HomePopularMerchantCard extends StatelessWidget {
final MerchantModel merchant;
final double width;
final double height;
final VoidCallback? onTap;
const HomePopularMerchantCard({
super.key,
this.onTap,
required this.merchant,
required this.width,
required this.height,
this.onTap,
});
static const double _radius = 12;
Widget _image(BoxFit fit) => merchant.imageUrl.startsWith('http')
? Image.network(
merchant.imageUrl,
fit: fit,
errorBuilder: (_, _, _) => const ImagePlaceholder(),
)
: Image.asset(
merchant.imageUrl,
fit: fit,
errorBuilder: (_, _, _) => const ImagePlaceholder(),
);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
return Semantics(
button: true,
label: merchant.name,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
width: width,
height: height,
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(_radius),
border: Border.all(color: AppColor.borderLight),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 2),
color: AppColor.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
// Image Container
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: AppColor.border,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: merchant.imageUrl.startsWith('http')
? Image.network(
merchant.imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return ImagePlaceholder(width: 60, height: 60);
},
)
: Image.asset(
merchant.imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return ImagePlaceholder(width: 60, height: 60);
},
),
),
),
const SizedBox(width: 12),
// Title and Category
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
merchant.name,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
merchant.category,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// Rating and Status
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: merchant.isOpen ? AppColor.success : AppColor.error,
borderRadius: BorderRadius.circular(6),
),
child: Text(
merchant.isOpen ? 'OPEN' : 'CLOSED',
style: AppStyle.xs.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: Stack(
fit: StackFit.expand,
children: [
// Logo brand ada yang persegi: tampilkan utuh, sisa ruangnya
// diisi versi blur dari gambar yang sama agar tidak terpotong.
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 18,
sigmaY: 18,
tileMode: TileMode.clamp,
),
const SizedBox(height: 8),
// Rating
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star, size: 12, color: AppColor.warning),
const SizedBox(width: 2),
Text(
merchant.rating.toString(),
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.primary,
),
),
],
),
),
],
),
],
child: _image(BoxFit.cover),
),
_image(BoxFit.contain),
Material(
type: MaterialType.transparency,
child: InkWell(onTap: onTap),
),
],
),
),
),
);
@@ -1,53 +1,51 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../sample/sample_data.dart';
import '../../../../../router/app_router.gr.dart';
import 'popular_merchant_card.dart';
import 'section_header.dart';
/// Seksi "Brand Kami": banner brand partner yang bisa digeser.
class HomePopularMerchantSection extends StatelessWidget {
const HomePopularMerchantSection({super.key});
static const double _cardWidth = 240;
static const double _cardHeight = 136;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
padding: const EdgeInsets.only(top: 8, bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Popular Merchants',
style: AppStyle.xl.copyWith(fontWeight: FontWeight.bold),
),
Spacer(),
InkWell(
onTap: () => context.router.push(MerchantRoute()),
child: Row(
children: [
Text(
'Lihat Semua',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.primary,
),
),
SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: AppColor.primary,
),
],
),
),
],
HomeSectionHeader(
eyebrow: 'Partner',
title: 'Brand Kami',
onSeeAll: () => context.router.push(MerchantRoute()),
),
SizedBox(height: 16),
...List.generate(
merchants.length,
(index) => HomePopularMerchantCard(merchant: merchants[index]),
const SizedBox(height: 12),
SizedBox(
// Ruang ekstra agar bayangan kartu tidak terpotong.
height: _cardHeight + 16,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
itemCount: merchants.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final merchant = merchants[index];
return HomePopularMerchantCard(
merchant: merchant,
width: _cardWidth,
height: _cardHeight,
onTap: () => context.router.push(
MerchantDetailRoute(merchant: merchant),
),
);
},
),
),
],
),
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../../../../../../common/theme/theme.dart';
/// Judul seksi Home: pemisah abu-abu, label kecil merah, judul, dan
/// "Lihat Semua" opsional.
class HomeSectionHeader extends StatelessWidget {
const HomeSectionHeader({
super.key,
required this.eyebrow,
required this.title,
this.onSeeAll,
});
final String eyebrow;
final String title;
final VoidCallback? onSeeAll;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 6, color: AppColor.borderLight),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.only(left: 16, right: 8),
child: Row(
children: [
Container(
width: 3,
height: 36,
decoration: BoxDecoration(
color: AppColor.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
eyebrow.toUpperCase(),
style: AppStyle.xs.copyWith(
fontSize: 10,
color: AppColor.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
Text(
title,
style: AppStyle.xl.copyWith(
height: 1.2,
fontWeight: FontWeight.w700,
),
),
],
),
),
if (onSeeAll != null)
TextButton(
onPressed: onSeeAll,
style: TextButton.styleFrom(
foregroundColor: AppColor.primaryDark,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Lihat Semua',
style: AppStyle.sm.copyWith(
color: AppColor.primaryDark,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_right_alt_rounded, size: 20),
],
),
),
],
),
),
],
);
}
}
@@ -1,71 +1,137 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../common/data/service_data.dart';
import '../../../../../../application/auth/auth_bloc.dart';
import '../../../../../../common/constant/app_constant.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../components/card/gradient_card.dart';
import '../../../../../components/assets/assets.gen.dart';
import '../../../../../components/toast/flushbar.dart';
import '../../../../../router/app_router.gr.dart';
class _HomeMenu {
const _HomeMenu({
required this.label,
required this.icon,
this.route,
this.requiresLogin = false,
});
final String label;
final AssetGenImage icon;
/// `null` berarti fitur belum tersedia.
final PageRouteInfo? route;
final bool requiresLogin;
}
class HomeServiceSection extends StatelessWidget {
const HomeServiceSection({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(services.length, (index) {
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: index == 0 ? 12 : 0),
child: InkWell(
onTap: () =>
context.router.push(MenuRoute(service: services[index])),
child: GradientCard(
padding: EdgeInsets.zero,
child: _content(services[index]),
),
),
),
);
}),
static final List<_HomeMenu> _menus = [
_HomeMenu(
label: 'EnakGame',
icon: Assets.icons.enakgameIcon,
route: WebViewRoute(
title: 'EnakGame',
url: AppConstant.gameUrl,
fullscreen: true,
backgroundColor: AppConstant.gameBackgroundColor,
),
);
),
_HomeMenu(
label: 'EnakTransfer',
icon: Assets.icons.enaktransferIcon,
route: const TransferRoute(),
requiresLogin: true,
),
_HomeMenu(
label: 'EnakReward',
icon: Assets.icons.enakrewardIcon,
route: const RewardRoute(),
),
_HomeMenu(
label: 'EnakPromo',
icon: Assets.icons.enakpromoIcon,
route: ComingSoonRoute(
title: 'EnakPromo',
iconPath: Assets.icons.enakpromoIcon.path,
),
),
_HomeMenu(
label: 'EnakShop',
icon: Assets.icons.enakshopIcon,
route: ComingSoonRoute(
title: 'EnakShop',
iconPath: Assets.icons.enakshopIcon.path,
),
),
_HomeMenu(
label: 'EnakNews',
icon: Assets.icons.enaknewsIcon,
route: ComingSoonRoute(
title: 'EnakNews',
iconPath: Assets.icons.enaknewsIcon.path,
),
),
_HomeMenu(
label: 'EnakCommunity',
icon: Assets.icons.enakcommunityIcon,
route: const CommunityRoute(),
),
];
void _open(BuildContext context, _HomeMenu menu) {
final route = menu.route;
if (route == null) {
AppFlushbar.showSuccess(context, '${menu.label} segera hadir');
return;
}
final isAuthenticated = context.read<AuthBloc>().state.isAuthenticated;
if (menu.requiresLogin && !isAuthenticated) {
context.router.push(OnboardingRoute());
return;
}
context.router.push(route);
}
Stack _content(Service service) {
return Stack(
children: [
Positioned(
right: 4,
top: 4,
child: Image.asset(service.imagePath, width: 60, height: 60),
),
Padding(
padding: const EdgeInsets.all(16),
@override
Widget build(BuildContext context) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 32, 16, 16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 8,
mainAxisExtent: 76,
),
itemCount: _menus.length,
itemBuilder: (context, index) {
final menu = _menus[index];
return InkWell(
borderRadius: BorderRadius.circular(AppValue.borderRadius),
onTap: () => _open(context, menu),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
service.name,
style: AppStyle.lg.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
service.description,
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w500,
const SizedBox(height: 4),
menu.icon.image(width: 44, height: 44, fit: BoxFit.contain),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
menu.label,
maxLines: 1,
style: AppStyle.xs.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
),
],
);
},
);
}
}
@@ -1,355 +1,215 @@
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/auth/auth_bloc.dart';
import '../../../../../application/order/order_loader/order_loader_bloc.dart';
import '../../../../../common/theme/theme.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/order_card.dart';
// Model untuk Order
class Order {
final String id;
final String customerName;
final DateTime orderDate;
final List<OrderItem> items;
final double totalAmount;
final OrderStatus status;
final String? notes;
final String? phoneNumber;
final String? address;
Order({
required this.id,
required this.customerName,
required this.orderDate,
required this.items,
required this.totalAmount,
required this.status,
this.notes,
this.phoneNumber,
this.address,
});
}
class OrderItem {
final String name;
final int quantity;
final double price;
final String? imageUrl;
final String? notes;
OrderItem({
required this.name,
required this.quantity,
required this.price,
this.imageUrl,
this.notes,
});
}
enum OrderStatus { pending, processing, completed, cancelled }
/// Riwayat order customer (§4.5). Order hanya bisa dilihat; app tidak membuat
/// atau membayar order.
@RoutePage()
class OrderPage extends StatefulWidget {
class OrderPage extends StatefulWidget implements AutoRouteWrapper {
const OrderPage({super.key});
@override
State<OrderPage> createState() => _OrderPageState();
@override
Widget wrappedRoute(BuildContext context) =>
BlocProvider(create: (_) => getIt<OrderLoaderBloc>(), child: this);
}
class _OrderPageState extends State<OrderPage> with TickerProviderStateMixin {
late TabController _tabController;
bool _isLoading = true;
List<Order> _orders = [];
// Filter states
class _OrderPageState extends State<OrderPage> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 5, vsync: this);
_loadOrders();
if (context.read<AuthBloc>().state.isAuthenticated) _refresh();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
super.dispose();
}
void _loadOrders() {
// Simulate loading
Future.delayed(const Duration(seconds: 2), () {
setState(() {
_isLoading = false;
// Uncomment untuk testing dengan data
_orders = _generateSampleOrders();
});
});
void _refresh() {
context.read<OrderLoaderBloc>().add(const OrderLoaderEvent.refreshed());
}
List<Order> _generateSampleOrders() {
return [
Order(
id: "ORD-001",
customerName: "John Doe",
orderDate: DateTime.now().subtract(const Duration(hours: 2)),
address: "Jl. Malioboro No. 123, Yogyakarta",
items: [
OrderItem(
name: "Nasi Gudeg",
quantity: 2,
price: 25000,
notes: "Pedas sedang",
),
OrderItem(name: "Es Teh Manis", quantity: 2, price: 8000),
OrderItem(name: "Kerupuk", quantity: 1, price: 5000),
],
totalAmount: 71000,
status: OrderStatus.pending,
notes: "Tolong diantar sebelum jam 2 siang",
),
Order(
id: "ORD-002",
customerName: "Jane Smith",
orderDate: DateTime.now().subtract(const Duration(hours: 1)),
address: "Jl. Sultan Agung No. 45, Yogyakarta",
items: [
OrderItem(
name: "Ayam Bakar",
quantity: 1,
price: 35000,
notes: "Tidak pedas",
),
OrderItem(name: "Nasi Putih", quantity: 1, price: 5000),
OrderItem(name: "Lalapan", quantity: 1, price: 8000),
],
totalAmount: 48000,
status: OrderStatus.processing,
),
Order(
id: "ORD-003",
customerName: "Bob Wilson",
orderDate: DateTime.now().subtract(const Duration(minutes: 30)),
phoneNumber: "+62 811-2345-6789",
items: [
OrderItem(name: "Gado-gado", quantity: 2, price: 20000),
OrderItem(name: "Lontong", quantity: 2, price: 3000),
OrderItem(name: "Es Jeruk", quantity: 2, price: 10000),
],
totalAmount: 66000,
status: OrderStatus.completed,
),
];
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) {
context.read<OrderLoaderBloc>().add(const OrderLoaderEvent.loadedMore());
}
}
@override
Widget build(BuildContext context) {
final isAuthenticated = context.select<AuthBloc, bool>(
(bloc) => bloc.state.isAuthenticated,
);
return Scaffold(
appBar: _buildAppBar(),
body: Column(
children: [
_buildTabBar(),
Expanded(
child: _isLoading ? _buildLoadingState() : _buildOrderContent(),
),
],
backgroundColor: AppColor.background,
appBar: AppBar(
title: const Text('Pesanan'),
automaticallyImplyLeading: false,
),
body: isAuthenticated ? _buildOrders() : _buildGuest(),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
elevation: 0,
backgroundColor: AppColor.white,
automaticallyImplyLeading: false,
title: Text('Pesanan'),
actions: [
IconButton(
onPressed: _showFilterDialog,
icon: const Icon(Icons.filter_list, color: AppColor.textSecondary),
),
IconButton(
onPressed: _refreshOrders,
icon: const Icon(Icons.refresh, color: AppColor.textSecondary),
),
],
);
}
Widget _buildTabBar() {
return Container(
color: AppColor.white,
child: TabBar(
controller: _tabController,
isScrollable: true,
labelColor: AppColor.primary,
unselectedLabelColor: AppColor.textSecondary,
indicatorColor: AppColor.primary,
indicatorWeight: 3,
labelStyle: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
tabAlignment: TabAlignment.start,
unselectedLabelStyle: AppStyle.md,
tabs: const [
Tab(text: 'Semua'),
Tab(text: 'Menunggu'),
Tab(text: 'Diproses'),
Tab(text: 'Selesai'),
Tab(text: 'Dibatalkan'),
],
Widget _buildOrders() {
return BlocConsumer<OrderLoaderBloc, OrderLoaderState>(
listenWhen: (p, c) =>
p.failureOption != c.failureOption && c.items.isNotEmpty,
listener: (context, state) => state.failureOption.fold(
() {},
(f) => AppFlushbar.showError(context, walletFailureMessage(f)),
),
);
}
Widget _buildOrderContent() {
if (_orders.isEmpty) {
return _buildEmptyState();
}
return TabBarView(
controller: _tabController,
children: [
_buildOrderList(_orders),
_buildOrderList(
_orders.where((o) => o.status == OrderStatus.pending).toList(),
),
_buildOrderList(
_orders.where((o) => o.status == OrderStatus.processing).toList(),
),
_buildOrderList(
_orders.where((o) => o.status == OrderStatus.completed).toList(),
),
_buildOrderList(
_orders.where((o) => o.status == OrderStatus.cancelled).toList(),
),
],
);
}
Widget _buildOrderList(List<Order> orders) {
if (orders.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: _refreshOrders,
color: AppColor.primary,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: orders.length,
itemBuilder: (context, index) {
return OrderCard(order: orders[index]);
},
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(60),
builder: (context, state) {
if (!state.hasLoaded) {
return state.failureOption.fold(
() => const Center(
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
),
child: Icon(
Icons.receipt_long,
size: 60,
color: AppColor.primary.withOpacity(0.5),
(f) => _Message(
icon: Icons.wifi_off_rounded,
title: 'Gagal memuat pesanan',
message: walletFailureMessage(f),
actionLabel: 'Coba lagi',
onAction: _refresh,
),
),
const SizedBox(height: 24),
Text(
'Belum Ada Pesanan',
style: AppStyle.h6.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Pesanan akan muncul di sini setelah\npelanggan mulai memesan.',
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: _refreshOrders,
icon: const Icon(Icons.refresh, size: 20),
label: const Text('Muat Ulang'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primary,
foregroundColor: AppColor.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
);
}
);
}
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColor.primary),
),
const SizedBox(height: 16),
Text(
'Memuat pesanan...',
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
);
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(
'Filter Pesanan',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Add filter options here
Text('Opsi filter akan segera hadir...', style: AppStyle.md),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Tutup',
style: AppStyle.md.copyWith(color: AppColor.primary),
),
),
],
return RefreshIndicator(
color: AppColor.primary,
onRefresh: () async {
_refresh();
await context.read<OrderLoaderBloc>().stream.firstWhere(
(s) => !s.isFetching,
);
},
child: state.items.isEmpty
? ListView(
children: const [
SizedBox(height: 80),
_Message(
icon: Icons.receipt_long_rounded,
title: 'Belum ada pesanan',
message:
'Pesanan akan muncul di sini setelah kasir '
'mengaitkannya ke akunmu.',
),
],
)
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: SpinKitThreeBounce(
color: AppColor.primary,
size: 18,
),
);
}
return OrderCard(order: state.items[index]);
},
),
);
},
);
}
Future<void> _refreshOrders() async {
setState(() {
_isLoading = true;
});
await Future.delayed(const Duration(seconds: 1));
setState(() {
_isLoading = false;
// Uncomment untuk testing dengan data
_orders = _generateSampleOrders();
});
Widget _buildGuest() {
return _Message(
icon: Icons.receipt_long_rounded,
title: 'Lihat pesananmu',
message:
'Masuk untuk melihat riwayat pesanan dan EnakPoint yang kamu '
'dapat dari setiap belanja.',
actionLabel: 'Masuk',
onAction: () => context.router.push(OnboardingRoute()),
);
}
}
class _Message extends StatelessWidget {
const _Message({
required this.icon,
required this.title,
required this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: AppColor.primaryWithOpacity(0.08),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 44,
color: AppColor.primaryWithOpacity(0.6),
),
),
const SizedBox(height: 20),
Text(
title,
textAlign: TextAlign.center,
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
if (actionLabel != null) ...[
const SizedBox(height: 24),
AppElevatedButton(
title: actionLabel!,
width: 180,
onPressed: onAction,
),
],
],
),
),
);
}
}
@@ -1,125 +1,140 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../../../common/extension/extension.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../domain/wallet/wallet.dart';
import '../../../../../router/app_router.gr.dart';
import '../order_page.dart';
import '../../../../order/order_ui.dart';
import '../../../../wallet/wallet_ui.dart';
class OrderCard extends StatelessWidget {
final Order order;
final CustomerOrder order;
const OrderCard({super.key, required this.order});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.router.push(OrderDetailRoute(order: order)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 0),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppColor.border.withOpacity(0.3),
width: 1,
),
),
),
child: Row(
children: [
// Left side - Order info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Order ID
Text(
order.id,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
const SizedBox(height: 4),
// Order Date
Text(
DateFormat('dd MMM yyyy • HH:mm').format(order.orderDate),
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
),
// Right side - Status and Total
Column(
crossAxisAlignment: CrossAxisAlignment.end,
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(AppValue.borderRadius),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(AppValue.borderRadius),
onTap: () => context.router.push(OrderDetailRoute(order: order)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
children: [
// Status
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getStatusColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getStatusColor().withOpacity(0.2),
width: 1,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.primaryWithOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: orderTypeIcon(order).image(fit: BoxFit.contain),
),
),
child: Text(
_getStatusText(),
style: AppStyle.xs.copyWith(
color: _getStatusColor(),
fontWeight: FontWeight.w600,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
order.outletName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${order.orderNumber} · '
'${formatOrderTime(order.createdAt)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
const SizedBox(height: 2),
Text(
'${order.itemCount} item · '
'${orderTypeLabel(order.orderType)}',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
),
const SizedBox(width: 8),
OrderStatusBadge(order: order),
],
),
const SizedBox(height: 6),
// Total Amount
Text(
'Rp ${_formatCurrency(order.totalAmount)}',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
const SizedBox(height: 12),
const Divider(height: 1, color: AppColor.borderLight),
const SizedBox(height: 10),
Row(
children: [
Text(
order.totalAmount.currencyFormatRp,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
),
const Spacer(),
if (order.pointsEarned > 0)
_Earned(
currency: WalletCurrency.point,
amount: order.pointsEarned,
),
if (order.pointsEarned > 0 && order.coinsEarned > 0)
const SizedBox(width: 10),
if (order.coinsEarned > 0)
_Earned(
currency: WalletCurrency.coin,
amount: order.coinsEarned,
),
],
),
],
),
],
),
),
),
);
}
}
Color _getStatusColor() {
switch (order.status) {
case OrderStatus.pending:
return AppColor.warning;
case OrderStatus.processing:
return AppColor.info;
case OrderStatus.completed:
return AppColor.success;
case OrderStatus.cancelled:
return AppColor.error;
}
}
/// "+865" dengan ikon mata uang: saldo yang didapat dari order.
class _Earned extends StatelessWidget {
const _Earned({required this.currency, required this.amount});
String _getStatusText() {
switch (order.status) {
case OrderStatus.pending:
return 'Menunggu';
case OrderStatus.processing:
return 'Diproses';
case OrderStatus.completed:
return 'Selesai';
case OrderStatus.cancelled:
return 'Dibatalkan';
}
}
final WalletCurrency currency;
final int amount;
String _formatCurrency(double amount) {
final formatter = NumberFormat('#,###');
return formatter.format(amount);
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
WalletCurrencyIcon(currency: currency, size: 16),
const SizedBox(width: 4),
Text(
'+${amount.thousandFormat}',
style: AppStyle.sm.copyWith(
color: AppColor.success,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
@@ -4,6 +4,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../application/auth/auth_bloc.dart';
import '../../../../../application/auth/logout_form/logout_form_bloc.dart';
import '../../../../../application/outlet/outlet_loader/outlet_loader_bloc.dart';
import '../../../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../injection.dart';
import '../../../../components/card/gradient_card.dart';
@@ -22,7 +24,15 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper {
() => null,
(either) => either.fold(
(f) => AppFlushbar.showAuthFailureToast(context, f),
(_) => context.router.replaceAll([OnboardingRoute()]),
(_) {
context.read<WalletLoaderBloc>().add(
const WalletLoaderEvent.cleared(),
);
context.read<OutletLoaderBloc>().add(
const OutletLoaderEvent.cleared(),
);
context.router.replaceAll([OnboardingRoute()]);
},
),
);
},
@@ -146,6 +156,12 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper {
title: 'Pembayaran',
onTap: () => context.router.push(PaymentRoute()),
),
_buildMenuItem(
icon: Icons.lock_outline,
title: 'PIN',
onTap: () =>
context.router.push(const PinSettingsRoute()),
),
_buildMenuItem(
icon: Icons.help_outline,
title: 'Pusat Bantuan',