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:
co-authored by
Claude Opus 5.5
parent
5ba60747a8
commit
e273785268
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../common/theme/theme.dart';
|
||||
|
||||
/// Kotak putih berjudul untuk mengelompokkan isi halaman detail.
|
||||
class DetailSection extends StatelessWidget {
|
||||
const DetailSection({super.key, this.title, required this.child});
|
||||
|
||||
final String? title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Satu langkah bernomor, mis. untuk "Cara dapat" atau "Cara beli".
|
||||
class NumberedStep extends StatelessWidget {
|
||||
const NumberedStep({super.key, required this.number, required this.text});
|
||||
|
||||
final int number;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$number',
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: AppStyle.md.copyWith(height: 1.4))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -62,10 +62,7 @@ class HomeServiceSection extends StatelessWidget {
|
||||
_HomeMenu(
|
||||
label: 'EnakShop',
|
||||
icon: Assets.icons.enakshopIcon,
|
||||
route: ComingSoonRoute(
|
||||
title: 'EnakShop',
|
||||
iconPath: Assets.icons.enakshopIcon.path,
|
||||
),
|
||||
route: const ShopRoute(),
|
||||
),
|
||||
_HomeMenu(
|
||||
label: 'EnakNews',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../../../common/theme/theme.dart';
|
||||
import '../../../../../sample/promo_sample_data.dart';
|
||||
import '../../../../components/button/button.dart';
|
||||
import '../../../../components/card/detail_section.dart';
|
||||
import '../../../../router/app_router.gr.dart';
|
||||
import '../../promo_ui.dart';
|
||||
|
||||
@@ -29,7 +30,7 @@ class PromoDetailPage extends StatelessWidget {
|
||||
_buildUpcomingNote(),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
DetailSection(
|
||||
child: Column(
|
||||
children: [
|
||||
_InfoRow(
|
||||
@@ -54,7 +55,7 @@ class PromoDetailPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
DetailSection(
|
||||
title: 'Tentang promo',
|
||||
child: Text(
|
||||
promo.description,
|
||||
@@ -65,18 +66,18 @@ class PromoDetailPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
DetailSection(
|
||||
title: 'Cara dapat',
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < promo.steps.length; i++)
|
||||
_StepRow(number: i + 1, text: promo.steps[i]),
|
||||
NumberedStep(number: i + 1, text: promo.steps[i]),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (promo.terms.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
DetailSection(
|
||||
title: 'Syarat & Ketentuan',
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -201,38 +202,6 @@ class PromoDetailPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
const _Section({this.title, required this.child});
|
||||
|
||||
final String? title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({
|
||||
required this.icon,
|
||||
@@ -282,43 +251,6 @@ class _InfoRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _StepRow extends StatelessWidget {
|
||||
const _StepRow({required this.number, required this.text});
|
||||
|
||||
final int number;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$number',
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: AppStyle.md.copyWith(height: 1.4))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BulletRow extends StatelessWidget {
|
||||
const _BulletRow({required this.text});
|
||||
|
||||
|
||||
@@ -104,7 +104,8 @@ class _PromoPageState extends State<PromoPage> {
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 168,
|
||||
// Ikut skala font sistem supaya teks banner tidak terpotong.
|
||||
height: MediaQuery.textScalerOf(context).scale(168),
|
||||
child: PageView.builder(
|
||||
controller: _bannerController,
|
||||
itemCount: featured.length,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../common/theme/theme.dart';
|
||||
import '../../../../../sample/shop_sample_data.dart';
|
||||
import '../../../../components/delegate/filter_bar_delegate.dart';
|
||||
import '../../../../router/app_router.gr.dart';
|
||||
import '../../shop_ui.dart';
|
||||
import '../../widgets/shop_product_card.dart';
|
||||
|
||||
enum _ShopSort {
|
||||
popular('Terlaris'),
|
||||
newest('Terbaru'),
|
||||
cheapest('Termurah'),
|
||||
priciest('Termahal');
|
||||
|
||||
const _ShopSort(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
int compare(ShopProduct a, ShopProduct b) => switch (this) {
|
||||
_ShopSort.popular => b.soldCount.compareTo(a.soldCount),
|
||||
_ShopSort.newest => b.addedAt.compareTo(a.addedAt),
|
||||
_ShopSort.cheapest => a.finalPrice.compareTo(b.finalPrice),
|
||||
_ShopSort.priciest => b.finalPrice.compareTo(a.finalPrice),
|
||||
};
|
||||
}
|
||||
|
||||
/// Daftar produk satu kategori, semua produk (`category` kosong), atau hanya
|
||||
/// yang sedang diskon ([promoOnly]).
|
||||
@RoutePage()
|
||||
class ShopCategoryPage extends StatefulWidget {
|
||||
const ShopCategoryPage({super.key, this.category, this.promoOnly = false});
|
||||
|
||||
final ShopCategory? category;
|
||||
final bool promoOnly;
|
||||
|
||||
@override
|
||||
State<ShopCategoryPage> createState() => _ShopCategoryPageState();
|
||||
}
|
||||
|
||||
class _ShopCategoryPageState extends State<ShopCategoryPage> {
|
||||
_ShopSort _sort = _ShopSort.popular;
|
||||
|
||||
late final List<ShopProduct> _products = shopSamples
|
||||
.where(
|
||||
(p) =>
|
||||
(widget.category == null || p.category == widget.category) &&
|
||||
(!widget.promoOnly || p.isDiscounted),
|
||||
)
|
||||
.toList();
|
||||
|
||||
String get _title {
|
||||
if (widget.promoOnly) return 'Lagi Promo';
|
||||
return widget.category?.label ?? 'Semua Produk';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sorted = [..._products]..sort(_sort.compare);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: Text(_title)),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: FilterBarDelegate<_ShopSort>(
|
||||
values: _ShopSort.values,
|
||||
selected: _sort,
|
||||
labelOf: (sort) => sort.label,
|
||||
onSelected: (sort) => setState(() => _sort = sort),
|
||||
),
|
||||
),
|
||||
if (sorted.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmpty())
|
||||
else ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: Text(
|
||||
'${sorted.length} produk',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: shopProductGridDelegate(context),
|
||||
itemCount: sorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = sorted[index];
|
||||
return ShopProductCard(
|
||||
product: product,
|
||||
onTap: () => context.router.push(
|
||||
ShopProductDetailRoute(product: product),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 32, 32, 64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.category?.emoji ?? '🛍️',
|
||||
style: const TextStyle(fontSize: 44),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Belum ada produk',
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Produk baru akan muncul di sini.',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../common/theme/theme.dart';
|
||||
import '../../../../../sample/shop_sample_data.dart';
|
||||
import '../../../../components/card/detail_section.dart';
|
||||
import '../../../../router/app_router.gr.dart';
|
||||
import '../../shop_ui.dart';
|
||||
import '../../widgets/shop_product_card.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ShopProductDetailPage extends StatefulWidget {
|
||||
const ShopProductDetailPage({super.key, required this.product});
|
||||
|
||||
final ShopProduct product;
|
||||
|
||||
@override
|
||||
State<ShopProductDetailPage> createState() => _ShopProductDetailPageState();
|
||||
}
|
||||
|
||||
class _ShopProductDetailPageState extends State<ShopProductDetailPage> {
|
||||
static const double _relatedCardWidth = 150;
|
||||
static const int _relatedCount = 6;
|
||||
static const int _lowStock = 5;
|
||||
|
||||
late String? _variant = widget.product.variants.firstOrNull;
|
||||
|
||||
late final List<ShopProduct> _related = _findRelated();
|
||||
|
||||
/// Kategori yang sama lebih dulu, lalu yang terlaris.
|
||||
List<ShopProduct> _findRelated() {
|
||||
final current = widget.product;
|
||||
final others = shopSamples.where((p) => p.id != current.id).toList()
|
||||
..sort((a, b) {
|
||||
final aSame = a.category == current.category;
|
||||
final bSame = b.category == current.category;
|
||||
if (aSame != bSame) return aSame ? -1 : 1;
|
||||
return b.soldCount.compareTo(a.soldCount);
|
||||
});
|
||||
return others.take(_relatedCount).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final product = widget.product;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('Detail Produk')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.2,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ShopProductImage(product: product, emojiSize: 120),
|
||||
if (product.tag != null)
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
child: ShopTagBadge(tag: product.tag!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildSummary(product),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (product.variants.isNotEmpty) ...[
|
||||
DetailSection(
|
||||
title: '${product.variantLabel}: $_variant',
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final variant in product.variants)
|
||||
_VariantOption(
|
||||
label: variant,
|
||||
isSelected: variant == _variant,
|
||||
onTap: () => setState(() => _variant = variant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
DetailSection(
|
||||
title: 'Info produk',
|
||||
child: Column(
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Kategori',
|
||||
value: product.category.label,
|
||||
),
|
||||
_InfoRow(label: 'Berat / isi', value: product.unit),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DetailSection(
|
||||
title: 'Deskripsi',
|
||||
child: Text(
|
||||
product.description,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_related.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||
child: Text(
|
||||
'Produk serupa',
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: _relatedCardWidth + shopCardInfoHeight(context),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _related.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final related = _related[index];
|
||||
return SizedBox(
|
||||
width: _relatedCardWidth,
|
||||
child: ShopProductCard(
|
||||
product: related,
|
||||
onTap: () => context.router.push(
|
||||
ShopProductDetailRoute(product: related),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummary(ShopProduct product) {
|
||||
final isLowStock = product.stock <= _lowStock;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppColor.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShopPrice(product: product, large: true),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
product.name,
|
||||
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: formatSoldCount(product.soldCount)),
|
||||
const TextSpan(text: ' · '),
|
||||
TextSpan(
|
||||
text: isLowStock
|
||||
? 'Sisa ${product.stock}'
|
||||
: 'Stok ${product.stock}',
|
||||
style: isLowStock
|
||||
? const TextStyle(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VariantOption extends StatelessWidget {
|
||||
const _VariantOption({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: isSelected ? AppColor.primary.withOpacity(0.06) : AppColor.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppColor.primary : AppColor.border,
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: isSelected ? AppColor.primary : AppColor.textPrimary,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../sample/shop_sample_data.dart';
|
||||
import '../../components/field/field.dart';
|
||||
import '../../router/app_router.gr.dart';
|
||||
import 'shop_ui.dart';
|
||||
import 'widgets/shop_category_grid.dart';
|
||||
import 'widgets/shop_product_card.dart';
|
||||
import 'widgets/shop_promo_banner.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ShopPage extends StatefulWidget {
|
||||
const ShopPage({super.key});
|
||||
|
||||
@override
|
||||
State<ShopPage> createState() => _ShopPageState();
|
||||
}
|
||||
|
||||
class _ShopPageState extends State<ShopPage> {
|
||||
static const double _rowCardWidth = 150;
|
||||
|
||||
final _searchController = TextEditingController();
|
||||
final _bannerController = PageController(viewportFraction: 0.9);
|
||||
int _bannerIndex = 0;
|
||||
|
||||
final List<ShopPromo> _promos = shopPromoSamples;
|
||||
|
||||
/// Terlaris di atas.
|
||||
final List<ShopProduct> _products = shopSamples
|
||||
..sort((a, b) => b.soldCount.compareTo(a.soldCount));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_bannerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openProduct(ShopProduct product) =>
|
||||
context.router.push(ShopProductDetailRoute(product: product));
|
||||
|
||||
void _openCategory({ShopCategory? category, bool promoOnly = false}) =>
|
||||
context.router.push(
|
||||
ShopCategoryRoute(category: category, promoOnly: promoOnly),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = _searchController.text.trim().toLowerCase();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('EnakShop')),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: SearchTextField(
|
||||
hintText: 'Cari di EnakShop',
|
||||
prefixIcon: Icons.search,
|
||||
controller: _searchController,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (query.isNotEmpty)
|
||||
..._buildSearchResults(query)
|
||||
else ...[
|
||||
SliverToBoxAdapter(child: _buildBanners()),
|
||||
const SliverToBoxAdapter(child: _SectionTitle(title: 'Kategori')),
|
||||
SliverToBoxAdapter(
|
||||
child: ShopCategoryGrid(
|
||||
onTap: (category) => _openCategory(category: category),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _SectionTitle(
|
||||
title: 'Lagi Promo',
|
||||
onSeeAll: () => _openCategory(promoOnly: true),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildProductRow(
|
||||
_products.where((p) => p.isDiscounted).toList(),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: _SectionTitle(title: 'Rekomendasi'),
|
||||
),
|
||||
_buildGrid(_products),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildSearchResults(String query) {
|
||||
final results = _products
|
||||
.where(
|
||||
(p) =>
|
||||
p.name.toLowerCase().contains(query) ||
|
||||
p.category.label.toLowerCase().contains(query),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (results.isEmpty) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 32, 32, 64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('🔍', style: TextStyle(fontSize: 44)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Produk tidak ditemukan',
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Coba kata kunci lain, mis. "sambal" atau "kopi".',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Text(
|
||||
'${results.length} produk ditemukan',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildGrid(results),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildBanners() {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
// Ikut skala font sistem supaya teks banner tidak terpotong.
|
||||
height: MediaQuery.textScalerOf(context).scale(150),
|
||||
child: PageView.builder(
|
||||
controller: _bannerController,
|
||||
itemCount: _promos.length,
|
||||
onPageChanged: (index) => setState(() => _bannerIndex = index),
|
||||
itemBuilder: (context, index) {
|
||||
final promo = _promos[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: ShopPromoBanner(
|
||||
promo: promo,
|
||||
onTap: () => _openCategory(category: promo.category),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_promos.length, (index) {
|
||||
final isActive = index == _bannerIndex;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: isActive ? 18 : 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColor.primary : AppColor.border,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductRow(List<ShopProduct> products) {
|
||||
return SizedBox(
|
||||
height: _rowCardWidth + shopCardInfoHeight(context),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: products.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return SizedBox(
|
||||
width: _rowCardWidth,
|
||||
child: ShopProductCard(
|
||||
product: product,
|
||||
onTap: () => _openProduct(product),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(List<ShopProduct> products) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: shopProductGridDelegate(context),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return ShopProductCard(
|
||||
product: product,
|
||||
onTap: () => _openProduct(product),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title, this.onSeeAll});
|
||||
|
||||
final String title;
|
||||
final VoidCallback? onSeeAll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, onSeeAll == null ? 16 : 4, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (onSeeAll != null)
|
||||
TextButton(
|
||||
onPressed: onSeeAll,
|
||||
child: Text(
|
||||
'Lihat semua',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../sample/shop_sample_data.dart';
|
||||
|
||||
/// "320 terjual" / "1,2rb terjual".
|
||||
String formatSoldCount(int count) {
|
||||
if (count < 1000) return '$count terjual';
|
||||
final thousands = (count / 100).floor() / 10;
|
||||
final text = thousands == thousands.roundToDouble()
|
||||
? thousands.toInt().toString()
|
||||
: thousands.toString().replaceAll('.', ',');
|
||||
return '${text}rb terjual';
|
||||
}
|
||||
|
||||
/// Grid 2 kolom produk; tinggi kartu = lebar gambar + bagian teks.
|
||||
SliverGridDelegate shopProductGridDelegate(BuildContext context) {
|
||||
final itemWidth = (MediaQuery.sizeOf(context).width - 32 - 12) / 2;
|
||||
return SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisExtent: itemWidth + shopCardInfoHeight(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tinggi bagian teks kartu produk, ikut skala font sistem supaya tidak
|
||||
/// terpotong.
|
||||
double shopCardInfoHeight(BuildContext context) =>
|
||||
MediaQuery.textScalerOf(context).scale(118);
|
||||
|
||||
/// Gambar produk: emoji di atas latar warna produk.
|
||||
class ShopProductImage extends StatelessWidget {
|
||||
const ShopProductImage({
|
||||
super.key,
|
||||
required this.product,
|
||||
this.emojiSize = 56,
|
||||
});
|
||||
|
||||
final ShopProduct product;
|
||||
final double emojiSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: Color(product.tint),
|
||||
child: Center(
|
||||
child: Text(product.emoji, style: TextStyle(fontSize: emojiSize)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Label pojok kartu seperti "Terlaris" atau "Baru".
|
||||
class ShopTagBadge extends StatelessWidget {
|
||||
const ShopTagBadge({super.key, required this.tag});
|
||||
|
||||
final ShopTag tag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: switch (tag) {
|
||||
ShopTag.bestSeller => AppColor.primary,
|
||||
ShopTag.newArrival => AppColor.info,
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
tag.label,
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Harga akhir, dengan harga coret dan persen potongan bila diskon.
|
||||
class ShopPrice extends StatelessWidget {
|
||||
const ShopPrice({super.key, required this.product, this.large = false});
|
||||
|
||||
final ShopProduct product;
|
||||
final bool large;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
product.finalPrice.currencyFormatRp,
|
||||
style: (large ? AppStyle.h5 : AppStyle.md).copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (product.isDiscounted) ...[
|
||||
SizedBox(height: large ? 4 : 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${product.discountPercent}%',
|
||||
style: (large ? AppStyle.sm : AppStyle.xs).copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
product.price.currencyFormatRp,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: (large ? AppStyle.sm : AppStyle.xs).copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,11 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: NewsRoute.page),
|
||||
AutoRoute(page: NewsDetailRoute.page),
|
||||
|
||||
// Shop
|
||||
AutoRoute(page: ShopRoute.page),
|
||||
AutoRoute(page: ShopCategoryRoute.page),
|
||||
AutoRoute(page: ShopProductDetailRoute.page),
|
||||
|
||||
// Account
|
||||
AutoRoute(page: AccountMyRoute.page),
|
||||
AutoRoute(page: AddressRoute.page),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
||||
// Models
|
||||
//
|
||||
// EnakShop adalah toko online. Tahap ini baru katalog; keranjang dan checkout
|
||||
// menyusul setelah API toko tersedia.
|
||||
enum ShopCategory {
|
||||
frozen('Makanan Beku', '🥟'),
|
||||
sauce('Sambal & Bumbu', '🌶️'),
|
||||
coffee('Kopi & Minuman', '☕'),
|
||||
noodle('Mie & Ramen', '🍜'),
|
||||
snack('Camilan', '🍪'),
|
||||
merch('Merchandise', '🛍️'),
|
||||
hampers('Hampers', '🎁');
|
||||
|
||||
const ShopCategory(this.label, this.emoji);
|
||||
|
||||
final String label;
|
||||
final String emoji;
|
||||
}
|
||||
|
||||
/// Label pojok kartu produk.
|
||||
enum ShopTag {
|
||||
bestSeller('Terlaris'),
|
||||
newArrival('Baru');
|
||||
|
||||
const ShopTag(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
class ShopProduct {
|
||||
final String id;
|
||||
final String name;
|
||||
final ShopCategory category;
|
||||
final int price;
|
||||
|
||||
/// Harga setelah diskon; `null` bila tidak sedang diskon.
|
||||
final int? discountPrice;
|
||||
final String emoji;
|
||||
|
||||
/// Warna latar gambar produk, ARGB.
|
||||
final int tint;
|
||||
|
||||
/// Berat atau isi, mis. "500 g" atau "Isi 25 butir".
|
||||
final String unit;
|
||||
final String description;
|
||||
|
||||
/// Judul pilihan varian, mis. "Ukuran" atau "Level pedas".
|
||||
final String variantLabel;
|
||||
final List<String> variants;
|
||||
final int soldCount;
|
||||
final int stock;
|
||||
final ShopTag? tag;
|
||||
final DateTime addedAt;
|
||||
|
||||
const ShopProduct({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.category,
|
||||
required this.price,
|
||||
required this.emoji,
|
||||
required this.tint,
|
||||
required this.unit,
|
||||
required this.description,
|
||||
required this.soldCount,
|
||||
required this.stock,
|
||||
required this.addedAt,
|
||||
this.discountPrice,
|
||||
this.variantLabel = 'Varian',
|
||||
this.variants = const [],
|
||||
this.tag,
|
||||
});
|
||||
|
||||
bool get isDiscounted => discountPrice != null;
|
||||
|
||||
int get finalPrice => discountPrice ?? price;
|
||||
|
||||
/// Persen potongan, dibulatkan.
|
||||
int get discountPercent =>
|
||||
isDiscounted ? ((price - discountPrice!) * 100 / price).round() : 0;
|
||||
}
|
||||
|
||||
/// Banner promo di beranda toko; mengarah ke satu kategori.
|
||||
class ShopPromo {
|
||||
final String id;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final ShopCategory category;
|
||||
|
||||
/// Warna gradasi banner, ARGB.
|
||||
final List<int> gradient;
|
||||
|
||||
const ShopPromo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.category,
|
||||
required this.gradient,
|
||||
});
|
||||
}
|
||||
|
||||
// Sample Data
|
||||
DateTime _daysAgo(int days) => DateTime.now().subtract(Duration(days: days));
|
||||
|
||||
/// Getter, bukan `final`, supaya perubahan data langsung terbaca saat hot
|
||||
/// reload (variabel global yang sudah terisi tidak diisi ulang).
|
||||
List<ShopPromo> get shopPromoSamples => const [
|
||||
ShopPromo(
|
||||
id: 'sp1',
|
||||
title: 'Diskon s.d. 20% Makanan Beku',
|
||||
subtitle: 'Stok bakso & dimsum di rumah',
|
||||
category: ShopCategory.frozen,
|
||||
gradient: [0xFFD90000, 0xFF990000],
|
||||
),
|
||||
ShopPromo(
|
||||
id: 'sp2',
|
||||
title: 'Hampers Mulai Rp 299 Ribu',
|
||||
subtitle: 'Kirim rasa Nusantara untuk orang tersayang',
|
||||
category: ShopCategory.hampers,
|
||||
gradient: [0xFF43A047, 0xFF1B5E20],
|
||||
),
|
||||
ShopPromo(
|
||||
id: 'sp3',
|
||||
title: 'Merchandise Diskon s.d. 15%',
|
||||
subtitle: 'Tumbler, kaos, dan tote bag Enaklo',
|
||||
category: ShopCategory.merch,
|
||||
gradient: [0xFFF59E0B, 0xFFC2410C],
|
||||
),
|
||||
];
|
||||
|
||||
List<ShopProduct> get shopSamples => [
|
||||
ShopProduct(
|
||||
id: 's1',
|
||||
name: 'Bakso Sapi Frozen Bakso 343',
|
||||
category: ShopCategory.frozen,
|
||||
price: 65000,
|
||||
discountPrice: 52000,
|
||||
emoji: '🍲',
|
||||
tint: 0xFFF6D5D0,
|
||||
unit: 'Isi 25 butir',
|
||||
variantLabel: 'Ukuran',
|
||||
variants: ['Isi 25 butir', 'Isi 50 butir'],
|
||||
description:
|
||||
'Bakso sapi resep Bakso 343, dibekukan segera setelah dibuat. Cukup '
|
||||
'rebus 5 menit. Tahan 3 bulan di freezer.',
|
||||
soldCount: 320,
|
||||
stock: 48,
|
||||
tag: ShopTag.bestSeller,
|
||||
addedAt: _daysAgo(60),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's2',
|
||||
name: 'Dimsum Ayam Frozen',
|
||||
category: ShopCategory.frozen,
|
||||
price: 48000,
|
||||
emoji: '🥟',
|
||||
tint: 0xFFF3E6CF,
|
||||
unit: 'Isi 20 pcs',
|
||||
description:
|
||||
'Dimsum ayam udang dengan kulit tipis. Kukus 10 menit dari beku, '
|
||||
'sajikan dengan sambal.',
|
||||
soldCount: 85,
|
||||
stock: 30,
|
||||
tag: ShopTag.newArrival,
|
||||
addedAt: _daysAgo(5),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's3',
|
||||
name: 'Sambal Woku Pedas',
|
||||
category: ShopCategory.sauce,
|
||||
price: 35000,
|
||||
discountPrice: 29000,
|
||||
emoji: '🌶️',
|
||||
tint: 0xFFFBDCC8,
|
||||
unit: '200 g',
|
||||
variantLabel: 'Level pedas',
|
||||
variants: ['Level 2', 'Level 3', 'Level 5'],
|
||||
description:
|
||||
'Sambal khas Woku Pedas dengan cabai, kemangi, dan rempah Manado. '
|
||||
'Tanpa pengawet, simpan di kulkas setelah dibuka.',
|
||||
soldCount: 540,
|
||||
stock: 120,
|
||||
tag: ShopTag.bestSeller,
|
||||
addedAt: _daysAgo(90),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's4',
|
||||
name: 'Bumbu Rica-Rica Instan',
|
||||
category: ShopCategory.sauce,
|
||||
price: 18000,
|
||||
emoji: '🧂',
|
||||
tint: 0xFFF7E2C8,
|
||||
unit: '100 g',
|
||||
description:
|
||||
'Bumbu rica-rica siap pakai untuk 1 kg ayam atau ikan. Tinggal tumis '
|
||||
'dan masak bersama bahan utama.',
|
||||
soldCount: 140,
|
||||
stock: 75,
|
||||
addedAt: _daysAgo(40),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's5',
|
||||
name: 'Kopi Tumulu House Blend',
|
||||
category: ShopCategory.coffee,
|
||||
price: 85000,
|
||||
emoji: '☕',
|
||||
tint: 0xFFE8DCCF,
|
||||
unit: '250 g',
|
||||
variantLabel: 'Gilingan',
|
||||
variants: ['Biji utuh', 'Bubuk halus', 'Bubuk kasar'],
|
||||
description:
|
||||
'Campuran arabika Gayo dan robusta Temanggung yang dipakai di Tumulu '
|
||||
'Coffee. Cocok untuk kopi susu maupun kopi tubruk.',
|
||||
soldCount: 410,
|
||||
stock: 64,
|
||||
tag: ShopTag.bestSeller,
|
||||
addedAt: _daysAgo(120),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's6',
|
||||
name: 'Cold Brew Tumulu 1 Liter',
|
||||
category: ShopCategory.coffee,
|
||||
price: 75000,
|
||||
discountPrice: 65000,
|
||||
emoji: '🧋',
|
||||
tint: 0xFFE2D6CC,
|
||||
unit: '1 liter',
|
||||
description:
|
||||
'Kopi seduh dingin 18 jam, tinggal tuang ke es dan susu. Simpan di '
|
||||
'kulkas, habiskan dalam 7 hari.',
|
||||
soldCount: 95,
|
||||
stock: 20,
|
||||
addedAt: _daysAgo(20),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's7',
|
||||
name: 'Ramen Kit Shoyu Ramenmulu',
|
||||
category: ShopCategory.noodle,
|
||||
price: 79000,
|
||||
discountPrice: 69000,
|
||||
emoji: '🍜',
|
||||
tint: 0xFFF5E1C8,
|
||||
unit: '2 porsi',
|
||||
description:
|
||||
'Mie ramen segar, kuah shoyu pekat, chashu, dan telur ajitama untuk '
|
||||
'2 porsi. Siap dalam 10 menit.',
|
||||
soldCount: 150,
|
||||
stock: 18,
|
||||
tag: ShopTag.newArrival,
|
||||
addedAt: _daysAgo(3),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's8',
|
||||
name: 'Mie Ramen Kering',
|
||||
category: ShopCategory.noodle,
|
||||
price: 32000,
|
||||
emoji: '🍝',
|
||||
tint: 0xFFF4EBD3,
|
||||
unit: '500 g',
|
||||
description:
|
||||
'Mie ramen kering untuk 5 porsi, kenyal seperti mie segar. Rebus '
|
||||
'3 menit.',
|
||||
soldCount: 60,
|
||||
stock: 90,
|
||||
addedAt: _daysAgo(30),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's9',
|
||||
name: 'Keripik Kulit Ikan Pedas',
|
||||
category: ShopCategory.snack,
|
||||
price: 28000,
|
||||
emoji: '🍘',
|
||||
tint: 0xFFF6E7C1,
|
||||
unit: '100 g',
|
||||
description:
|
||||
'Keripik kulit ikan renyah dengan bumbu woku pedas. Camilan pas '
|
||||
'teman nonton.',
|
||||
soldCount: 210,
|
||||
stock: 55,
|
||||
addedAt: _daysAgo(50),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's10',
|
||||
name: 'Nastar Keju Premium',
|
||||
category: ShopCategory.snack,
|
||||
price: 95000,
|
||||
discountPrice: 85000,
|
||||
emoji: '🍪',
|
||||
tint: 0xFFF7E4B5,
|
||||
unit: 'Toples 500 g',
|
||||
description:
|
||||
'Nastar selai nanas homemade dengan taburan keju edam. Tahan 1 bulan '
|
||||
'dalam toples tertutup.',
|
||||
soldCount: 130,
|
||||
stock: 25,
|
||||
addedAt: _daysAgo(15),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's11',
|
||||
name: 'Tumbler Enaklo 500 ml',
|
||||
category: ShopCategory.merch,
|
||||
price: 129000,
|
||||
emoji: '🥤',
|
||||
tint: 0xFFF3D3D3,
|
||||
unit: '500 ml',
|
||||
variantLabel: 'Warna',
|
||||
variants: ['Merah', 'Hitam', 'Krem'],
|
||||
description:
|
||||
'Tumbler stainless dua lapis yang menjaga minuman tetap dingin sampai '
|
||||
'12 jam. Tutup anti tumpah.',
|
||||
soldCount: 75,
|
||||
stock: 40,
|
||||
tag: ShopTag.bestSeller,
|
||||
addedAt: _daysAgo(70),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's12',
|
||||
name: 'Kaos "Enak Lho!"',
|
||||
category: ShopCategory.merch,
|
||||
price: 119000,
|
||||
emoji: '👕',
|
||||
tint: 0xFFE9E9E9,
|
||||
unit: 'Katun combed 30s',
|
||||
variantLabel: 'Ukuran',
|
||||
variants: ['S', 'M', 'L', 'XL'],
|
||||
description:
|
||||
'Kaos katun yang adem dipakai seharian, dengan sablon "Enak Lho!" di '
|
||||
'bagian depan.',
|
||||
soldCount: 48,
|
||||
stock: 32,
|
||||
addedAt: _daysAgo(35),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's13',
|
||||
name: 'Tote Bag Kanvas Enaklo',
|
||||
category: ShopCategory.merch,
|
||||
price: 90000,
|
||||
discountPrice: 76500,
|
||||
emoji: '🛍️',
|
||||
tint: 0xFFEDE3CF,
|
||||
unit: '38 × 42 cm',
|
||||
variantLabel: 'Warna',
|
||||
variants: ['Natural', 'Hitam'],
|
||||
description:
|
||||
'Tote bag kanvas tebal dengan kantong dalam. Cukup untuk laptop 14 '
|
||||
'inci.',
|
||||
soldCount: 66,
|
||||
stock: 3,
|
||||
tag: ShopTag.newArrival,
|
||||
addedAt: _daysAgo(8),
|
||||
),
|
||||
ShopProduct(
|
||||
id: 's14',
|
||||
name: 'Hampers Rasa Nusantara',
|
||||
category: ShopCategory.hampers,
|
||||
price: 350000,
|
||||
discountPrice: 299000,
|
||||
emoji: '🎁',
|
||||
tint: 0xFFDCEBD6,
|
||||
unit: 'Isi 5 produk',
|
||||
description:
|
||||
'Sambal Woku Pedas, Kopi Tumulu House Blend, Nastar Keju Premium, '
|
||||
'Keripik Kulit Ikan Pedas, dan kartu ucapan dalam kotak kayu.',
|
||||
soldCount: 34,
|
||||
stock: 12,
|
||||
tag: ShopTag.newArrival,
|
||||
addedAt: _daysAgo(6),
|
||||
),
|
||||
];
|
||||
Reference in New Issue
Block a user