EnakShop: katalog toko online

- Beranda toko: pencarian, banner promo, grid kategori, produk promo,
  dan rekomendasi
- Halaman kategori dengan urutan terlaris/terbaru/termurah/termahal
- Detail produk: harga diskon, varian, info, deskripsi, produk serupa
- Data contoh 14 produk dan 3 banner; keranjang menyusul
- DetailSection dan NumberedStep jadi komponen bersama
- Tinggi banner EnakPromo dan EnakShop ikut skala font sistem

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-01 02:47:47 +07:00
co-authored by Claude Opus 5.5
parent 5ba60747a8
commit e273785268
14 changed files with 1911 additions and 334 deletions
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/shop_sample_data.dart';
/// Grid kategori toko, 4 kolom, diakhiri tile "Semua" (`null`).
class ShopCategoryGrid extends StatelessWidget {
const ShopCategoryGrid({super.key, required this.onTap});
final ValueChanged<ShopCategory?> onTap;
@override
Widget build(BuildContext context) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 4,
mainAxisExtent: MediaQuery.textScalerOf(context).scale(92),
),
itemCount: ShopCategory.values.length + 1,
itemBuilder: (context, index) {
final category = index < ShopCategory.values.length
? ShopCategory.values[index]
: null;
return InkWell(
borderRadius: BorderRadius.circular(AppValue.borderRadius),
onTap: () => onTap(category),
child: Column(
children: [
const SizedBox(height: 4),
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColor.borderLight),
),
child: Text(
category?.emoji ?? '🏪',
style: const TextStyle(fontSize: 26),
),
),
const SizedBox(height: 6),
Text(
category?.label ?? 'Semua',
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w600,
height: 1.2,
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/shop_sample_data.dart';
import '../shop_ui.dart';
/// Kartu produk untuk grid dan daftar geser. Tinggi bagian teks mengikuti
/// [shopCardInfoHeight].
class ShopProductCard extends StatelessWidget {
const ShopProductCard({
super.key,
required this.product,
required this.onTap,
});
final ShopProduct product;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
ShopProductImage(product: product),
if (product.tag != null)
Positioned(
top: 10,
left: 10,
child: ShopTagBadge(tag: product.tag!),
),
],
),
),
SizedBox(
height: shopCardInfoHeight(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
height: 1.3,
),
),
const Spacer(),
ShopPrice(product: product),
const SizedBox(height: 4),
Text(
formatSoldCount(product.soldCount),
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/shop_sample_data.dart';
/// Banner promo toko; mengarah ke kategori promo.
class ShopPromoBanner extends StatelessWidget {
const ShopPromoBanner({super.key, required this.promo, required this.onTap});
final ShopPromo promo;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = promo.gradient.map(Color.new).toList();
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Stack(
children: [
Positioned(
right: -20,
bottom: -24,
child: Container(
width: 130,
height: 130,
decoration: BoxDecoration(
color: AppColor.white.withOpacity(0.12),
shape: BoxShape.circle,
),
),
),
Positioned(
right: 18,
bottom: 14,
child: Text(
promo.category.emoji,
style: const TextStyle(fontSize: 64),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 110, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
promo.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.xl.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
const SizedBox(height: 4),
Text(
promo.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Lihat produk',
style: AppStyle.xs.copyWith(
color: colors.last,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
],
),
),
),
),
);
}
}