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
Reference in New Issue
Block a user