Compare commits

..

3 Commits

Author SHA1 Message Date
Efril
4c12244d3c checkout page 2026-01-16 15:53:06 +07:00
Efril
f4f775b9ed menu detail page 2026-01-16 14:41:09 +07:00
Efril
44d48d41d4 menu page 2026-01-16 13:27:09 +07:00
33 changed files with 2017 additions and 380 deletions

BIN
assets/icons/dine_in.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
assets/icons/takeaway.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

View File

@ -1,5 +1,9 @@
import '../../sample/sample_data.dart';
class AppConstant {
static const String appName = "Enaklo";
static const String coinName = "EnakCoin";
static const String poinName = "EnakPoin";
}
MerchantModel merchant = merchants.first;

View File

@ -0,0 +1,26 @@
import '../../presentation/components/assets/assets.gen.dart';
class Service {
Service({
required this.name,
required this.description,
required this.imagePath,
});
final String name;
final String imagePath;
final String description;
}
List<Service> services = [
Service(
name: 'Dine In',
description: 'Makan langsung di tempat',
imagePath: Assets.icons.dineIn.path,
),
Service(
name: 'Take Away',
description: 'Pesan dan bawa pulang',
imagePath: Assets.icons.dineIn.path,
),
];

View File

@ -0,0 +1,28 @@
part of 'extension.dart';
extension DoubleExt on double {
String get currencyFormatRpV2 => NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
).format(this);
}
extension StringX on String {
String get currencyFormatRp {
final parsedValue = int.tryParse(this) ?? 0;
return NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
).format(parsedValue);
}
}
extension IntegerExt on int {
String get currencyFormatRp => NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
).format(this);
}

View File

@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
import '../../domain/auth/auth.dart';
part 'date_extension.dart';
part 'currency_extension.dart';
extension StringExt on String {
CheckPhoneStatus toCheckPhoneStatus() {

View File

@ -1,3 +1,7 @@
part of 'theme.dart';
class AppValue {}
class AppValue {
static const double padding = 16;
static const double margin = 16;
static const double borderRadius = 12;
}

View File

@ -43,7 +43,7 @@ class ThemeApp {
foregroundColor: AppColor.textPrimary,
elevation: 0,
titleTextStyle: AppStyle.xl.copyWith(
color: AppColor.primary,
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
centerTitle: true,
@ -55,7 +55,18 @@ class ThemeApp {
backgroundColor: AppColor.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppValue.borderRadius),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColor.primary,
shape: RoundedRectangleBorder(
side: BorderSide(color: AppColor.border),
borderRadius: BorderRadiusGeometry.circular(AppValue.borderRadius),
),
),
),
inputDecorationTheme: InputDecorationTheme(

View File

@ -43,6 +43,20 @@ class $AssetsAudioGen {
];
}
class $AssetsIconsGen {
const $AssetsIconsGen();
/// File path: assets/icons/dine_in.png
AssetGenImage get dineIn => const AssetGenImage('assets/icons/dine_in.png');
/// File path: assets/icons/takeaway.png
AssetGenImage get takeaway =>
const AssetGenImage('assets/icons/takeaway.png');
/// List of all assets
List<AssetGenImage> get values => [dineIn, takeaway];
}
class $AssetsImagesGen {
const $AssetsImagesGen();
@ -97,6 +111,7 @@ class Assets {
const Assets._();
static const $AssetsAudioGen audio = $AssetsAudioGen();
static const $AssetsIconsGen icons = $AssetsIconsGen();
static const $AssetsImagesGen images = $AssetsImagesGen();
}

View File

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class DashedDivider extends StatelessWidget {
final double height;
final double dashWidth;
final double dashSpacing;
final Color color;
const DashedDivider({
super.key,
this.height = 1,
this.dashWidth = 5,
this.dashSpacing = 3,
this.color = Colors.grey,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: LayoutBuilder(
builder: (context, constraints) {
final boxWidth = constraints.constrainWidth();
final dashCount = (boxWidth / (dashWidth + dashSpacing)).floor();
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(dashCount, (_) {
return SizedBox(
width: dashWidth,
height: height,
child: DecoratedBox(decoration: BoxDecoration(color: color)),
);
}),
);
},
),
);
}
}

View File

@ -4,3 +4,5 @@ import 'package:flutter_spinkit/flutter_spinkit.dart';
import '../../../common/theme/theme.dart';
part 'elevated_button.dart';
part 'outline_button.dart';
part 'qty_button.dart';

View File

@ -6,7 +6,7 @@ class AppElevatedButton extends StatelessWidget {
required this.onPressed,
required this.title,
this.width = double.infinity,
this.height = 48.0,
this.height = 44.0,
this.isLoading = false,
});
@ -41,7 +41,7 @@ class AppElevatedButton extends StatelessWidget {
)
: Text(
title,
style: AppStyle.lg.copyWith(
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w700,
),

View File

@ -0,0 +1,58 @@
part of 'button.dart';
class AppOutlineButton extends StatelessWidget {
const AppOutlineButton({
super.key,
required this.onPressed,
required this.title,
this.width = double.infinity,
this.height = 44.0,
this.isLoading = false,
this.borderColor,
});
final Function()? onPressed;
final String title;
final double width;
final double height;
final bool isLoading;
final Color? borderColor;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: height,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
side: BorderSide(color: borderColor ?? AppColor.primary),
),
child: isLoading
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SpinKitFadingCircle(color: AppColor.white, size: 24),
SizedBox(width: 8),
Text(
'Loading',
style: AppStyle.lg.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w700,
),
),
],
)
: Text(
title,
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w700,
),
),
),
);
}
}

View File

@ -0,0 +1,38 @@
part of 'button.dart';
class QtyButton extends StatelessWidget {
const QtyButton({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
InkWell(
child: Container(
height: 35,
width: 35,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(width: 1, color: AppColor.border),
),
child: Icon(Icons.remove),
),
),
SizedBox(width: 12),
Text('1', style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold)),
SizedBox(width: 12),
InkWell(
child: Container(
height: 35,
width: 35,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(width: 1, color: AppColor.border),
),
child: Icon(Icons.add),
),
),
],
);
}
}

View File

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/product_sample_data.dart';
class ProductCard extends StatelessWidget {
final Product product;
final Function()? onTap;
const ProductCard({super.key, required this.product, this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.06),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product image
Expanded(
flex: 3,
child: Stack(
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Center(
child: Icon(
Icons.fastfood,
size: 40,
color: AppColor.textLight,
),
),
),
// Availability overlay
if (!product.isAvailable)
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: AppColor.black.withOpacity(0.6),
borderRadius: BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Center(
child: Text(
"HABIS",
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
),
),
// Rating badge
Positioned(
top: 8,
right: 8,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star, size: 12, color: AppColor.warning),
SizedBox(width: 2),
Text(
"${product.rating}",
style: AppStyle.xs.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
),
// Product info
Expanded(
flex: 2,
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Spacer(),
// Price and sold count
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Rp ${product.price.currencyFormatRp}",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
Text(
"${product.soldCount} terjual",
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
],
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
class ProductEmptyCard extends StatelessWidget {
const ProductEmptyCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16),
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
),
child: Center(
child: Column(
children: [
Icon(
Icons.inventory_2_outlined,
size: 48,
color: AppColor.textLight,
),
SizedBox(height: 12),
Text(
"Belum ada produk",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
),
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../../../common/data/service_data.dart';
import '../../../common/theme/theme.dart';
import '../image/image.dart';
import 'gradient_card.dart';
class ServiceCard extends StatelessWidget {
final Service service;
const ServiceCard({super.key, required this.service});
@override
Widget build(BuildContext context) {
return GradientCard(
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadiusGeometry.circular(8),
child: Image.asset(
service.imagePath,
width: 60,
height: 60,
errorBuilder: (context, error, stackTrace) =>
ImagePlaceholder(width: 60, height: 60),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
service.name,
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.white,
),
),
Text(
service.description,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.white,
),
),
],
),
),
InkWell(
child: Text(
'Ubah',
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
class VariantCard extends StatelessWidget {
final String name;
final bool isSelected;
const VariantCard({super.key, required this.name, this.isSelected = false});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppValue.borderRadius),
color: isSelected ? AppColor.primary.withOpacity(0.1) : AppColor.white,
border: Border.all(
width: isSelected ? 2 : 1,
color: isSelected ? AppColor.primary : AppColor.border,
),
),
child: Row(
children: [
Expanded(
child: Text(
name,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
),
SizedBox(width: 12),
Text(
"+${"2000".currencyFormatRp}",
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:flutter/widgets.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/product_sample_data.dart';
class SliverCategoryDelegate extends SliverPersistentHeaderDelegate {
final List<ProductCategory> categories;
final String selectedCategoryId;
final Function(String) onCategoryTap;
SliverCategoryDelegate({
required this.categories,
required this.selectedCategoryId,
required this.onCategoryTap,
});
@override
double get minExtent => 60;
@override
double get maxExtent => 60;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(
height: 60,
decoration: BoxDecoration(
color: AppColor.surface,
border: Border(
bottom: BorderSide(color: AppColor.borderLight, width: 1),
),
),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = category.id == selectedCategoryId;
return GestureDetector(
onTap: () => onCategoryTap(category.id),
child: Container(
margin: EdgeInsets.only(right: 12),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColor.primary : AppColor.backgroundLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? AppColor.primary : AppColor.border,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(category.icon, style: AppStyle.md),
SizedBox(width: 8),
Text(
category.name,
style: AppStyle.md.copyWith(
color: isSelected
? AppColor.textWhite
: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
SizedBox(width: 4),
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSelected
? AppColor.textWhite.withOpacity(0.2)
: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
"${category.productCount}",
style: AppStyle.xs.copyWith(
color: isSelected
? AppColor.textWhite
: AppColor.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
},
),
);
}
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return oldDelegate is SliverCategoryDelegate &&
(oldDelegate.selectedCategoryId != selectedCategoryId ||
oldDelegate.categories != categories);
}
}

View File

@ -0,0 +1,225 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/data/service_data.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/product_sample_data.dart';
import '../../components/border/dashed_border.dart';
import '../../components/button/button.dart';
import '../../components/card/service_card.dart';
import 'widgets/checkout_item.dart';
import 'widgets/merchant.dart';
@RoutePage()
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Checkout')),
bottomNavigationBar: Container(
padding: EdgeInsets.all(AppValue.padding).copyWith(bottom: 24),
decoration: BoxDecoration(
color: AppColor.white,
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.1),
offset: Offset(2, 0),
blurRadius: 10,
),
],
),
child: AppElevatedButton(onPressed: () {}, title: 'Pesan Sekarang'),
),
body: ListView(
children: [
Container(
padding: EdgeInsets.all(AppValue.padding),
child: Column(
children: [
ServiceCard(service: services.first),
SizedBox(height: 16),
CheckoutMerchant(),
],
),
),
Divider(thickness: 4, color: AppColor.borderLight),
Container(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Pesananmu'),
SizedBox(height: 16),
CheckoutItem(product: products.first),
CheckoutItem(product: products.first),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ada Tambah lagi?',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 4),
Text(
'Kakmu bisa menambah menu lainnya, ya.',
style: AppStyle.md,
),
],
),
),
SizedBox(width: 12),
AppOutlineButton(
width: 100,
onPressed: () {},
title: '+ Tambah',
),
],
),
],
),
),
Divider(thickness: 4, color: AppColor.borderLight),
Container(
padding: EdgeInsets.all(AppValue.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Voucher'),
SizedBox(height: 16),
Row(
children: [
Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: AppColor.primary.withOpacity(0.1),
),
child: Icon(
Icons.confirmation_number,
color: AppColor.primary,
),
),
SizedBox(width: 12),
Expanded(
child: Text(
'Kamu bisa menggunakan voucher mu disini.',
style: AppStyle.md,
),
),
Icon(Icons.chevron_right, color: AppColor.textSecondary),
],
),
],
),
),
Divider(thickness: 4, color: AppColor.borderLight),
Container(
padding: EdgeInsets.all(AppValue.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Metode Pembayaran'),
SizedBox(height: 16),
Row(
children: [
Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: AppColor.primary.withOpacity(0.1),
),
child: Icon(Icons.wallet, color: AppColor.primary),
),
SizedBox(width: 12),
Expanded(
child: Text(
'Pilih metode pembayaran disini.',
style: AppStyle.md,
),
),
Icon(Icons.chevron_right, color: AppColor.textSecondary),
],
),
],
),
),
Divider(thickness: 4, color: AppColor.borderLight),
Container(
padding: EdgeInsets.all(AppValue.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Rincian Pembayaran'),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Harga',
style: AppStyle.md.copyWith(fontWeight: FontWeight.w500),
),
Text(
"40000".currencyFormatRp,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w500),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: DashedDivider(),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total Pembayaran',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
Text(
"40000".currencyFormatRp,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
],
),
],
),
),
Divider(thickness: 4, color: AppColor.borderLight),
Container(
padding: EdgeInsets.all(AppValue.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Kebijakan Pembatalan",
style: AppStyle.md.copyWith(fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text(
'Kamu tidak dapat melakukan pembatalan atau perubahan apapun pada pesanan setelah melakukan pembayaran.',
style: AppStyle.sm,
),
],
),
),
SizedBox(height: 40),
],
),
);
}
Text _sectionTitle(String title) {
return Text(
title,
style: AppStyle.xl.copyWith(fontWeight: FontWeight.bold),
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/product_sample_data.dart';
import '../../../components/button/button.dart';
import '../../../components/image/image.dart';
class CheckoutItem extends StatelessWidget {
final Product product;
const CheckoutItem({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(),
child: Column(
children: [
Row(
children: [
AppNetworkImage(url: product.imageUrl, width: 60, height: 60),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: AppStyle.md.copyWith(fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"1x ${product.price.currencyFormatRp}",
style: AppStyle.md,
),
Text("27000".currencyFormatRp, style: AppStyle.md),
],
),
],
),
),
],
),
SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'Catatan',
style: AppStyle.md.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 12),
QtyButton(),
],
),
],
),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../../common/constant/app_constant.dart';
import '../../../../common/theme/theme.dart';
class CheckoutMerchant extends StatelessWidget {
const CheckoutMerchant({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(merchant.imageUrl),
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
merchant.name,
style: AppStyle.h6.copyWith(fontWeight: FontWeight.bold),
),
Text(merchant.category, style: AppStyle.md.copyWith()),
Row(children: [SizedBox(width: 12)]),
],
),
),
],
);
}
}

View File

@ -8,10 +8,10 @@ import '../../../../../application/customer/customer_point_loader/customer_point
import '../../../../../common/theme/theme.dart';
import '../../../../components/image/image.dart';
import '../../../../router/app_router.gr.dart';
import 'widgets/feature_section.dart';
import 'widgets/lottery_card.dart';
import 'widgets/point_card.dart';
import 'widgets/popular_merchant_section.dart';
import 'widgets/service_section.dart';
@RoutePage()
class HomePage extends StatefulWidget {
@ -52,7 +52,7 @@ class _HomePageState extends State<HomePage> {
children: [
_buildHeaderSection(),
const SizedBox(height: 70),
HomeFeatureSection(),
HomeServiceSection(),
HomeLotteryBanner(onTap: () => context.router.push(DrawRoute())),
HomePopularMerchantSection(),
],

View File

@ -0,0 +1,61 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../../common/data/service_data.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../components/card/gradient_card.dart';
import '../../../../../router/app_router.gr.dart';
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(
child: _content(
services[index].name,
services[index].description,
),
),
),
),
);
}),
),
);
}
Column _content(String title, String subtitle) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.lg.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
subtitle,
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w500,
),
),
],
);
}
}

View File

@ -0,0 +1,132 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/data/service_data.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/product_sample_data.dart';
import '../../components/delegate/category_delegate.dart';
import 'widgets/header.dart';
import 'widgets/product_section.dart';
@RoutePage()
class MenuPage extends StatefulWidget {
final Service service;
const MenuPage({super.key, required this.service});
@override
State<MenuPage> createState() => _MenuPageState();
}
class _MenuPageState extends State<MenuPage> {
final ScrollController _scrollController = ScrollController();
final List<GlobalKey> _productSectionKeys = [];
String _selectedCategoryId = "";
@override
void initState() {
super.initState();
_selectedCategoryId = categories.isNotEmpty ? categories.first.id : "";
// Initialize keys for each category
for (int i = 0; i < categories.length; i++) {
_productSectionKeys.add(GlobalKey());
}
}
void _scrollToCategory(String categoryId) {
setState(() {
_selectedCategoryId = categoryId;
});
final categoryIndex = categories.indexWhere((cat) => cat.id == categoryId);
if (categoryIndex >= 0 && categoryIndex < _productSectionKeys.length) {
final key = _productSectionKeys[categoryIndex];
final context = key.currentContext;
if (context != null) {
Future.delayed(Duration(milliseconds: 100), () {
if (mounted) {
Scrollable.ensureVisible(
context,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
alignment: 0.1,
);
}
});
}
}
}
List<Product> _getProductsByCategory(String categoryId) {
return products
.where((product) => product.categoryId == categoryId)
.toList();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: CustomScrollView(
controller: _scrollController,
slivers: [
// App Bar with merchant info
SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: AppColor.primary,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: AppColor.textWhite),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
IconButton(
icon: Icon(Icons.share, color: AppColor.textWhite),
onPressed: () {},
),
IconButton(
icon: Icon(Icons.favorite_border, color: AppColor.textWhite),
onPressed: () {},
),
],
flexibleSpace: FlexibleSpaceBar(
background: MenuHeader(service: widget.service),
),
),
// Categories (will be pinned)
SliverPersistentHeader(
pinned: true,
delegate: SliverCategoryDelegate(
categories: categories,
selectedCategoryId: _selectedCategoryId,
onCategoryTap: _scrollToCategory,
),
),
// Product sections by category
...categories.map((category) {
final categoryProducts = _getProductsByCategory(category.id);
final categoryIndex = categories.indexOf(category);
return SliverToBoxAdapter(
key: _productSectionKeys[categoryIndex],
child: MenuProductSection(
category: category,
categoryProducts: categoryProducts,
),
);
}),
],
),
);
}
}

View File

@ -0,0 +1,166 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/extension/extension.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../sample/product_sample_data.dart';
import '../../../../components/button/button.dart';
import '../../../../components/card/variant_card.dart';
import '../../../../components/image/image.dart';
import '../../../../router/app_router.gr.dart';
@RoutePage()
class MenuDetailPage extends StatefulWidget {
final Product product;
const MenuDetailPage({super.key, required this.product});
@override
State<MenuDetailPage> createState() => _MenuDetailPageState();
}
class _MenuDetailPageState extends State<MenuDetailPage> {
final ScrollController _scrollController = ScrollController();
double _titleOpacity = 0.0;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
// Hitung opacity berdasarkan scroll offset
// Mulai muncul dari offset 150, full opacity di offset 220
double offset = _scrollController.offset;
double newOpacity = ((offset - 150) / 70).clamp(0.0, 1.0);
if (newOpacity != _titleOpacity) {
setState(() => _titleOpacity = newOpacity);
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: Container(
padding: EdgeInsets.all(AppValue.padding).copyWith(bottom: 24),
decoration: BoxDecoration(
color: AppColor.white,
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.1),
offset: Offset(2, 0),
blurRadius: 10,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(flex: 1, child: QtyButton()),
SizedBox(width: 16),
Expanded(
flex: 2,
child: AppElevatedButton(
onPressed: () => context.router.push(CheckoutRoute()),
title: '+ Keranjang ${"27000".currencyFormatRp}',
),
),
],
),
),
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: 240,
pinned: true,
backgroundColor: Colors.white,
title: AnimatedOpacity(
opacity: _titleOpacity,
duration: Duration(milliseconds: 200),
child: Text(
widget.product.name,
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Stack(
children: [
ImagePlaceholder(width: double.infinity, height: 240),
],
),
),
),
SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.product.name,
style: AppStyle.xl.copyWith(fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text(
widget.product.description,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w500),
),
SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
widget.product.price.currencyFormatRp,
style: AppStyle.xxl.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Container(
width: double.infinity,
height: 6,
decoration: BoxDecoration(color: AppColor.borderLight),
),
),
SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Varian',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
VariantCard(name: 'Small'),
SizedBox(height: 8),
VariantCard(name: 'Normal', isSelected: true),
SizedBox(height: 8),
VariantCard(name: 'Large'),
],
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../../../../common/constant/app_constant.dart';
import '../../../../common/data/service_data.dart';
import '../../../../common/theme/theme.dart';
class MenuHeader extends StatelessWidget {
final Service service;
const MenuHeader({super.key, required this.service});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColor.primary, AppColor.primary.withOpacity(0.8)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Stack(
children: [
// Background decoration
Positioned(
right: 20,
top: 60,
child: Opacity(
opacity: 0.1,
child: Icon(Icons.store, size: 100, color: AppColor.textWhite),
),
),
// Content
Positioned(
left: 20,
right: 20,
bottom: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Service
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
service.name,
style: AppStyle.lg.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
Text(
service.description,
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w500,
),
),
],
),
SizedBox(height: 16),
// Merchant
Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.2),
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(merchant.imageUrl),
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
merchant.name,
style: AppStyle.h6.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
Text(
merchant.category,
style: AppStyle.md.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
Row(children: [SizedBox(width: 12)]),
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: merchant.isOpen
? AppColor.success
: AppColor.error,
borderRadius: BorderRadius.circular(6),
),
child: Text(
merchant.isOpen ? "BUKA" : "TUTUP",
style: AppStyle.xs.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,77 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/product_sample_data.dart';
import '../../../components/card/product_card.dart';
import '../../../components/card/product_empty_card.dart';
import '../../../router/app_router.gr.dart';
class MenuProductSection extends StatelessWidget {
final ProductCategory category;
final List<Product> categoryProducts;
const MenuProductSection({
super.key,
required this.category,
required this.categoryProducts,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
children: [
Text(category.icon, style: AppStyle.h5),
SizedBox(width: 8),
Text(
category.name,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(width: 8),
Text(
"(${categoryProducts.length})",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
),
// Products grid
if (categoryProducts.isEmpty)
ProductEmptyCard()
else
GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: categoryProducts.length,
itemBuilder: (context, index) {
return ProductCard(
product: categoryProducts[index],
onTap: () => context.router.push(
MenuDetailRoute(product: categoryProducts[index]),
),
);
},
),
],
),
);
}
}

View File

@ -1,47 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../sample/product_sample_data.dart';
import '../../../../../sample/sample_data.dart';
// Models
class ProductCategory {
final String id;
final String name;
final String icon;
final int productCount;
ProductCategory({
required this.id,
required this.name,
required this.icon,
required this.productCount,
});
}
class Product {
final String id;
final String name;
final String description;
final int price;
final String categoryId;
final String imageUrl;
final bool isAvailable;
final double rating;
final int soldCount;
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.categoryId,
required this.imageUrl,
required this.isAvailable,
required this.rating,
required this.soldCount,
});
}
@RoutePage()
class MerchantDetailPage extends StatefulWidget {
final MerchantModel merchant;
@ -58,89 +20,6 @@ class _MerchantDetailPageState extends State<MerchantDetailPage> {
String _selectedCategoryId = "";
// Sample data
final List<ProductCategory> categories = [
ProductCategory(id: "1", name: "Makanan", icon: "🍽️", productCount: 8),
ProductCategory(id: "2", name: "Minuman", icon: "🥤", productCount: 6),
ProductCategory(id: "3", name: "Snack", icon: "🍿", productCount: 5),
ProductCategory(id: "4", name: "Es Krim", icon: "🍦", productCount: 4),
ProductCategory(id: "5", name: "Paket", icon: "📦", productCount: 3),
];
final List<Product> products = [
// Makanan
Product(
id: "1",
name: "Nasi Gudeg",
description: "Gudeg khas Yogyakarta dengan ayam dan telur",
price: 25000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.5,
soldCount: 50,
),
Product(
id: "2",
name: "Soto Ayam",
description: "Soto ayam kuning dengan nasi dan kerupuk",
price: 18000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.3,
soldCount: 75,
),
Product(
id: "3",
name: "Gado-gado",
description: "Gado-gado segar dengan bumbu kacang",
price: 15000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: false,
rating: 4.2,
soldCount: 30,
),
// Minuman
Product(
id: "4",
name: "Es Teh Manis",
description: "Es teh manis segar",
price: 5000,
categoryId: "2",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.0,
soldCount: 120,
),
Product(
id: "5",
name: "Jus Jeruk",
description: "Jus jeruk segar tanpa gula tambahan",
price: 12000,
categoryId: "2",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.4,
soldCount: 45,
),
// Snack
Product(
id: "6",
name: "Keripik Pisang",
description: "Keripik pisang renyah dan manis",
price: 8000,
categoryId: "3",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.1,
soldCount: 25,
),
];
@override
void initState() {
super.initState();

View File

@ -74,5 +74,12 @@ class AppRouter extends RootStackRouter {
// Mini Games
AutoRoute(page: FerrisWheelRoute.page),
AutoRoute(page: MisteryBoxRoute.page),
// Menu
AutoRoute(page: MenuRoute.page),
AutoRoute(page: MenuDetailRoute.page),
// Checkout
AutoRoute(page: CheckoutRoute.page),
];
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
// Models
class ProductCategory {
final String id;
final String name;
final String icon;
final int productCount;
ProductCategory({
required this.id,
required this.name,
required this.icon,
required this.productCount,
});
}
class Product {
final String id;
final String name;
final String description;
final int price;
final String categoryId;
final String imageUrl;
final bool isAvailable;
final double rating;
final int soldCount;
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.categoryId,
required this.imageUrl,
required this.isAvailable,
required this.rating,
required this.soldCount,
});
}
// Sample data
final List<ProductCategory> categories = [
ProductCategory(id: "1", name: "Makanan", icon: "🍽️", productCount: 8),
ProductCategory(id: "2", name: "Minuman", icon: "🥤", productCount: 6),
ProductCategory(id: "3", name: "Snack", icon: "🍿", productCount: 5),
ProductCategory(id: "4", name: "Es Krim", icon: "🍦", productCount: 4),
ProductCategory(id: "5", name: "Paket", icon: "📦", productCount: 3),
];
final List<Product> products = [
// Makanan
Product(
id: "1",
name: "Nasi Gudeg",
description: "Gudeg khas Yogyakarta dengan ayam dan telur",
price: 25000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.5,
soldCount: 50,
),
Product(
id: "2",
name: "Soto Ayam",
description: "Soto ayam kuning dengan nasi dan kerupuk",
price: 18000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.3,
soldCount: 75,
),
Product(
id: "3",
name: "Gado-gado",
description: "Gado-gado segar dengan bumbu kacang",
price: 15000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: false,
rating: 4.2,
soldCount: 30,
),
// Minuman
Product(
id: "4",
name: "Es Teh Manis",
description: "Es teh manis segar",
price: 5000,
categoryId: "2",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.0,
soldCount: 120,
),
Product(
id: "5",
name: "Jus Jeruk",
description: "Jus jeruk segar tanpa gula tambahan",
price: 12000,
categoryId: "2",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.4,
soldCount: 45,
),
// Snack
Product(
id: "6",
name: "Keripik Pisang",
description: "Keripik pisang renyah dan manis",
price: 8000,
categoryId: "3",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.1,
soldCount: 25,
),
];

View File

@ -724,10 +724,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@ -1121,10 +1121,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
time:
dependency: transitive
description: