QueenFruits/Mobile Commerce/lib/features/home/presentation/screens/home_screen.dart

1503 lines
52 KiB
Dart

import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:niogu_ecommerce_v1/core/constant/app_asset.dart';
import 'package:niogu_ecommerce_v1/core/constant/app_color.dart';
import 'package:niogu_ecommerce_v1/core/constant/app_font_size.dart';
import 'package:niogu_ecommerce_v1/core/enums/campaign_type.dart';
import 'package:niogu_ecommerce_v1/core/errors/exceptions.dart';
import 'package:niogu_ecommerce_v1/core/providers/app_provider.dart';
import 'package:niogu_ecommerce_v1/core/router/app_route.dart';
import 'package:niogu_ecommerce_v1/core/system/system_setting.dart';
import 'package:niogu_ecommerce_v1/core/utils/currency_format.dart';
import 'package:niogu_ecommerce_v1/core/utils/extenstion_format.dart';
import 'package:niogu_ecommerce_v1/core/utils/log_message.dart';
import 'package:niogu_ecommerce_v1/core/utils/time_zone.dart';
import 'package:niogu_ecommerce_v1/core/widgets/custom_empty_screen.dart';
import 'package:niogu_ecommerce_v1/core/widgets/custom_snackbar.dart';
import 'package:niogu_ecommerce_v1/features/cart/presentation/providers/cart_provider.dart';
import 'package:niogu_ecommerce_v1/features/favorite/domain/entities/favorite.dart';
import 'package:niogu_ecommerce_v1/features/favorite/presentation/providers/favorite_provider.dart';
import 'package:niogu_ecommerce_v1/features/home/domain/entities/home.dart';
import 'package:niogu_ecommerce_v1/features/home/presentation/providers/home_provider.dart';
import 'package:niogu_ecommerce_v1/features/order/presentation/providers/order_provider.dart';
import 'package:niogu_ecommerce_v1/features/product/presentation/providers/product_provider.dart';
import 'package:shimmer/shimmer.dart';
import 'package:sizer/sizer.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:url_launcher/url_launcher.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
late ScrollController _recommendedController;
late Timer _recommendedTimer;
int _currentBannerIndex = 0;
@override
void initState() {
super.initState();
_recommendedController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
_instanceService();
_startChefAutoScroll();
});
}
@override
void dispose() {
_recommendedTimer.cancel();
_recommendedController.dispose();
super.dispose();
}
Future<void> _instanceService() async {
final echoService = ref.read(echoServiceProvider);
await echoService.init(
listener: (_, data) async {
final tables = List<String>.from(data['changed_tables']);
final currentRoute = GoRouter.of(
context,
).routerDelegate.currentConfiguration.last.matchedLocation;
final configureTables = ['tenants', 'tenant_payment_methods'];
final changedTables = [
'outlets',
'campaigns',
'categories',
'products',
'product_variants',
'outlet_inventories',
'orders',
];
if (tables.any((table) => configureTables.contains(table))) {
ref.read(configurationControllerProvider.notifier).refresh();
}
if (tables.any((table) => changedTables.contains(table))) {
if (currentRoute == '/home') {
ref.read(homeControllerProvider.notifier).refresh();
} else if (currentRoute == '/carts') {
ref.read(productBestSellerControllerProvider.notifier).refresh();
} else if (currentRoute == '/orders') {
ref.read(orderReportControllerProvider.notifier).refresh();
}
}
},
);
}
void _startChefAutoScroll() {
_recommendedTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
if (_recommendedController.hasClients) {
final maxScroll = _recommendedController.position.maxScrollExtent;
final currentScroll = _recommendedController.position.pixels;
final delta = 70.w;
if (currentScroll >= maxScroll - 10) {
_recommendedController.jumpTo(0);
} else {
_recommendedController.animateTo(
currentScroll + delta,
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOut,
);
}
}
});
}
Future<void> _launchWhatsApp() async {
final phoneNumber = ref.read(currentOutletPhoneProvider)!;
final Uri whatsappUri = Uri.parse(
"https://wa.me/${phoneNumber.normalizePhoneNumber()}",
);
try {
final bool launched = await launchUrl(
whatsappUri,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) {
CustomSnackbar.showError(context, "Tidak dapat membuka whatsApp");
}
} catch (e) {
LogMessage.log.e("Error launching whatsApp: $e");
if (mounted) {
CustomSnackbar.showError(
context,
"Terjadi kesalahan saat membuka whatsApp",
);
}
}
}
Future<void> _fetchProductById(String id) async {
try {
final productDetail = await ref
.read(productRepositoryProvider)
.fetchProductById(id);
if (productDetail == null) {
CustomSnackbar.showError(context, "Produk tidak ditemukan");
ref.read(homeControllerProvider.notifier).refresh();
return;
}
context.pushNamed(AppRoute.productDetailScreen, extra: productDetail);
} on ServerException catch (e, st) {
LogMessage.log.i(e.toString(), error: e, stackTrace: st);
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final configureState = ref.watch(configurationControllerProvider);
final homeState = ref.watch(homeControllerProvider);
final currentOutletId = ref.watch(currentOutletIdProvider);
final currentOutletPhone = ref.watch(currentOutletPhoneProvider);
final favoriteState = ref.watch(favoriteControllerProvider);
return SafeArea(
top: false,
bottom: true,
right: false,
left: false,
child: Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
homeState.when(
data: (home) {
if (home == null) return _buildErrorState();
final currentOutlet = home.currentOutlet;
final campaigns = home.campaigns;
final categories = home.categories;
final otherOutlets = home.otherOutlets;
final recommendations = home.recommendations;
final allProducts = home.allProducts;
return RefreshIndicator(
onRefresh: () async {
await ref
.read(homeControllerProvider.notifier)
.refresh();
},
color: AppColor.primaryColor,
backgroundColor: Colors.white,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
_buildSliverAppBar(
location: currentOutlet.location,
name: currentOutlet.name,
),
_buildSliverSearch(categories),
SliverToBoxAdapter(
child: Column(
children: [
_buildCampaign(
campaigns: campaigns,
categories: categories,
),
_buildCategory(categories: categories),
_buildOutletSection(outlets: otherOutlets),
_buildRecommendation(
products: recommendations,
currentOutletId: currentOutletId!,
favorites: favoriteState,
),
],
),
),
_buildAllProducts(
products: allProducts,
currentOutletId: currentOutletId,
favorites: favoriteState,
),
SliverToBoxAdapter(child: SizedBox(height: 15.h)),
],
),
);
},
error: (error, stackTrace) => _buildErrorState(),
loading: () => _buildHomeLoading(),
),
if (configureState.isRefreshing || homeState.isRefreshing)
Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: const CircularProgressIndicator(
color: AppColor.primaryColor,
backgroundColor: Colors.white,
),
),
),
if (configureState.hasValue && homeState.hasValue)
AnimatedPositioned(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
bottom: -1.85.h,
left: 0,
right: 0,
child: _buildClosedBanner(
onlineOpenTime: configureState.value!.onlineOpenTime!,
onlineCloseTime: configureState.value!.onlineCloseTime!,
isCloseService: configureState.value!.isCloseService!,
isActive: homeState.value!.currentOutlet.isActive,
),
),
],
),
floatingActionButton: currentOutletPhone == null
? null
: Padding(
padding: EdgeInsets.only(bottom: 2.h),
child: FloatingActionButton(
onPressed: _launchWhatsApp,
backgroundColor: Colors.transparent,
shape: const CircleBorder(),
elevation: 0,
child: Image.asset(
AppAsset.WHATSAPP,
width: 12.5.w,
height: 12.5.w,
fit: BoxFit.cover,
),
),
),
),
);
},
);
}
Widget _buildSliverAppBar({String? location, required String name}) {
return SliverAppBar(
pinned: true,
backgroundColor: AppColor.primaryColor,
expandedHeight: kIsWeb ? 12.h : 8.5.h,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.fromLTRB(4.w, 5.h, 4.w, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (location != null)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Lokasi Outlet",
style: TextStyle(
color: Colors.white,
fontSize: AppFontSize.small.sp,
),
),
Row(
children: [
Icon(
Icons.location_on,
color: Colors.white,
size: 5.w,
),
Expanded(
child: Text(
location,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
),
],
),
],
),
),
SizedBox(width: 8.w),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"Outlet Saat Ini",
style: TextStyle(
color: Colors.white,
fontSize: AppFontSize.small.sp,
),
),
Text(
name,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
],
),
],
),
),
),
);
}
Widget _buildSliverSearch(List<CategoryItem> categories) {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverSearchDelegate(
child: Center(
child: Stack(
clipBehavior: Clip.none,
children: [
Container(height: 6.h, color: AppColor.primaryColor),
Positioned(
top: 1.h,
left: 4.w,
right: 4.w,
child: Container(
height: 6.5.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: TextField(
onTap: () => context.pushNamed(
AppRoute.searchScreen,
extra: categories,
),
style: TextStyle(fontSize: AppFontSize.small.sp),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: "Cari produk",
hintStyle: TextStyle(fontSize: AppFontSize.small.sp),
prefixIcon: Icon(Icons.search, size: 3.5.w),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
readOnly: true,
),
),
),
],
),
),
),
);
}
Widget _buildCampaign({
required List<CampaignByOutlet> campaigns,
required List<CategoryItem> categories,
}) {
if (campaigns.isEmpty) {
campaigns.add(CampaignByOutlet(image: AppAsset.BANNER_MOCK_2));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 1.h),
child: Text(
"Informasi Hari Ini",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
color: Color(0xFF102851),
),
),
),
CarouselSlider(
options: CarouselOptions(
height: 20.h,
viewportFraction: 0.92,
enlargeCenterPage: false,
autoPlay: true,
onPageChanged: (index, reason) {
setState(() {
_currentBannerIndex = index;
});
},
),
items: campaigns.map((banner) {
return GestureDetector(
onTap: () async {
final campaignType = banner.campaignType;
if (campaignType != null) {
switch (campaignType) {
case CampaignType.product:
await _fetchProductById(banner.actionRefId!);
break;
case CampaignType.category:
final category = categories.firstWhere(
(category) => category.id == banner.actionRefId,
);
context.pushNamed(
AppRoute.productCategoryScreen,
extra: category,
);
break;
}
}
},
child: CachedNetworkImage(
imageUrl: banner.image ?? 'error',
imageBuilder: (context, imageProvider) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 1.w),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(2.5.w),
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
);
},
placeholder: (context, url) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 1.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
);
},
errorWidget: (context, url, error) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 1.w),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(2.5.w),
image: DecorationImage(
image: AssetImage(AppAsset.BANNER_MOCK_2),
fit: BoxFit.cover,
),
),
);
},
),
);
}).toList(),
),
SizedBox(height: 2.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: campaigns.asMap().entries.map((entry) {
final index = entry.key;
return Container(
width: _currentBannerIndex == index ? 15.0 : 6.0,
height: 6.0,
margin: const EdgeInsets.symmetric(horizontal: 3.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(1.w),
color: _currentBannerIndex == index
? AppColor.primaryColor
: Colors.grey.shade300,
),
);
}).toList(),
),
],
);
}
Widget _buildCategory({required List<CategoryItem> categories}) {
return Column(
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 2.h),
child: Text(
"Kategori Produk",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
color: Color(0xFF102851),
),
),
),
if (categories.isEmpty)
CustomEmptyScreen(
icon: Icons.grid_off_outlined,
title: "Kategori Belum Tersedia",
subtitle: "Nantikan pilihan kategori menarik segera",
height: 18.h,
)
else
SizedBox(
height: 14.h,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 4.w),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return GestureDetector(
onTap: () {
context.pushNamed(
AppRoute.productCategoryScreen,
extra: category,
);
},
child: Padding(
padding: EdgeInsets.only(right: 5.w),
child: Column(
children: [
CachedNetworkImage(
imageUrl: category.image ?? 'error',
imageBuilder: (context, imageProvider) {
return Container(
height: 18.w,
width: 18.w,
decoration: BoxDecoration(
color: Colors.white,
border: BoxBorder.all(
color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(2.5.w),
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
),
],
),
);
},
placeholder: (context, url) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
height: 18.w,
width: 18.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
);
},
errorWidget: (context, url, error) {
return Container(
height: 18.w,
width: 18.w,
decoration: BoxDecoration(
color: Colors.white,
border: BoxBorder.all(
color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(2.5.w),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
),
],
),
child: Icon(
Icons.image_outlined,
size: 5.w,
color: Colors.grey.shade300,
),
);
},
),
SizedBox(height: 1.h),
Text(
category.name,
style: TextStyle(
fontSize: (AppFontSize.small - 2).sp,
color: Color(0xFF102851),
),
),
],
),
),
);
},
),
),
],
);
}
Widget _buildOutletSection({required List<OtherOutlet> outlets}) {
if (outlets.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(4.w),
child: Text(
"Kunjungi Outlet Lain",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
),
CustomEmptyScreen(
icon: Icons.storefront_outlined,
title: "Hanya 1 Outlet Tersedia",
subtitle: "Belum ada outlet lain di wilayah ini",
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Kunjungi Outlet Lain",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
GestureDetector(
onTap: () async {
LocationPermission permission =
await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
Position position = await Geolocator.getCurrentPosition();
final userLocation = LatLng(
position.latitude,
position.longitude,
);
final outlets = await ref
.read(homeRepositoryProvider)
.fetchOutlets();
if (outlets.isEmpty) return;
context.pushNamed(
AppRoute.outletMapScreen,
extra: {'user_location': userLocation, 'outlets': outlets},
);
},
child: Container(
padding: EdgeInsets.all(1.5.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(1.w),
border: BoxBorder.all(color: AppColor.primaryColor),
),
child: Row(
children: [
Icon(
Icons.location_on_outlined,
size: 3.5.w,
color: AppColor.primaryColor,
),
SizedBox(width: 2.w),
Text(
"Lihat Dipeta",
style: TextStyle(
color: AppColor.primaryColor,
fontWeight: FontWeight.bold,
fontSize: (AppFontSize.small - 1.25).sp,
),
),
],
),
),
),
],
),
),
SizedBox(
height: 20.h,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 4.w),
itemCount: outlets.length,
itemBuilder: (context, index) {
final outlet = outlets[index];
return GestureDetector(
onTap: () async {
await SystemSetting.switchOutlet(
outletId: outlet.id,
outletName: outlet.name,
outletPhone: outlet.phoneNumber,
outletLocation: outlet.location,
outletCoordinate: outlet.coordinate,
);
ref.read(currentOutletIdProvider.notifier).state = outlet.id;
ref.read(currentOutletNameProvider.notifier).state =
outlet.name;
ref.read(currentOutletPhoneProvider.notifier).state =
outlet.phoneNumber;
ref.read(currentOutletLocationProvider.notifier).state =
outlet.location;
ref.read(currentOutletCoordinateProvider.notifier).state =
outlet.coordinate;
await ref.read(homeControllerProvider.notifier).refresh();
CustomSnackbar.showSuccess(
context,
"Berhasil mengunjungi ${outlet.name}",
);
},
child: CachedNetworkImage(
imageUrl: outlet.image ?? 'error',
imageBuilder: (context, imageProvider) {
return Stack(
children: [
Container(
width: 75.w,
margin: EdgeInsets.only(right: 4.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.5.w),
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.5.w),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
padding: EdgeInsets.all(4.w),
child: _buildBranchInformation(
name: outlet.name,
location: outlet.location,
),
),
),
],
);
},
placeholder: (context, url) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
width: 75.w,
margin: EdgeInsets.only(right: 4.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
);
},
errorWidget: (context, url, error) {
return Container(
width: 75.w,
margin: EdgeInsets.only(right: 4.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.5.w),
image: DecorationImage(
image: AssetImage(AppAsset.OUTLET_MOCK),
fit: BoxFit.cover,
),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.5.w),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
padding: EdgeInsets.all(4.w),
child: _buildBranchInformation(
name: outlet.name,
location: outlet.location,
),
),
);
},
),
);
},
),
),
],
);
}
Widget _buildBranchInformation({required String name, String? location}) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
if (location != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
Icon(Icons.location_on, size: 4.w, color: Colors.white70),
SizedBox(width: 0.75.w),
Expanded(
child: Text(
location,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white70,
fontSize: (AppFontSize.small - 2).sp,
),
),
),
],
),
),
/**
SizedBox(width: 2.5.w),
Row(
children: [
Icon(Icons.straighten, size: 4.w, color: Colors.white70),
SizedBox(width: 0.75.w),
Text(
"4km",
style: TextStyle(
color: Colors.white70,
fontSize: (AppFontSize.small - 2).sp,
),
),
],
),
*/
],
),
],
);
}
Widget _buildRecommendation({
required List<ProductItem> products,
required String currentOutletId,
required Map<String, SelectedFavorite> favorites,
}) {
if (products.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(4.w),
child: Text(
"Rekomendasi Produk",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
),
CustomEmptyScreen(
icon: Icons.auto_awesome_outlined,
title: "Belum Ada Rekomendasi",
subtitle: "Produk pilihan akan muncul di sini nanti",
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(4.w),
child: Text(
"Rekomendasi Produk",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
color: Color(0xFF102851),
),
),
),
SizedBox(
height: 32.h,
child: ListView.builder(
controller: _recommendedController,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 4.w),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
final isFavorite = favorites.containsKey(
"$currentOutletId-${product.id}",
);
return Container(
width: 70.w,
margin: EdgeInsets.only(right: 4.w),
child: _buildProductCard(product, currentOutletId, isFavorite),
);
},
),
),
],
);
}
Widget _buildAllProducts({
required List<ProductItem> products,
required String currentOutletId,
required Map<String, SelectedFavorite> favorites,
}) {
if (products.isEmpty) {
return SliverToBoxAdapter(
child: CustomEmptyScreen(
icon: Icons.inventory_outlined,
title: "Katalog Kosong",
subtitle: "Nantikan produk-produk terbaru kami",
height: 40.h,
),
);
}
return SliverMainAxisGroup(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _SliverSearchDelegate(
child: Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Semua Produk",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
Icon(Icons.filter_list),
],
),
),
),
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate((context, index) {
final product = products[index];
final isFavorite = favorites.containsKey(
"$currentOutletId-${product.id}",
);
return _buildProductCard(product, currentOutletId, isFavorite);
}, childCount: products.length),
),
),
],
);
}
Widget _buildProductCard(
ProductItem product,
String currentOutletId,
bool isFavorite,
) {
return GestureDetector(
onTap: () async => await _fetchProductById(product.id),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: CachedNetworkImage(
imageUrl: product.image ?? 'error',
imageBuilder: (context, imageProvider) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.vertical(
top: Radius.circular(2.5.w),
),
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
child: Stack(
children: [
Positioned(
top: 1.w,
right: 1.w,
child: GestureDetector(
onTap: () {
final currentOutletId = ref.read(
currentOutletIdProvider,
);
ref
.read(favoriteControllerProvider.notifier)
.toggle(product, currentOutletId!);
},
child: CircleAvatar(
radius: 14,
backgroundColor: Colors.white.withOpacity(0.9),
child: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_outline,
size: 5.w,
color: isFavorite ? Colors.red : Colors.grey,
),
),
),
),
],
),
);
},
placeholder: (context, url) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
width: double.infinity,
margin: EdgeInsets.only(right: 4.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(2.5.w),
),
),
),
);
},
errorWidget: (context, url, error) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.vertical(
top: Radius.circular(2.5.w),
),
),
child: Stack(
children: [
Center(
child: Icon(
Icons.image_outlined,
color: Colors.grey.shade300,
size: 10.w,
),
),
Positioned(
top: 1.w,
right: 1.w,
child: GestureDetector(
onTap: () {
ref
.read(favoriteControllerProvider.notifier)
.toggle(product, currentOutletId);
},
child: CircleAvatar(
radius: 14,
backgroundColor: Colors.white.withOpacity(0.9),
child: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_outline,
size: 5.w,
color: isFavorite ? Colors.red : Colors.grey,
),
),
),
),
],
),
);
},
),
),
Expanded(
flex: 2,
child: Padding(
padding: EdgeInsets.all(3.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: (AppFontSize.small - 1.25).sp,
color: const Color(0xFF102851),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${product.totalSold.toCompact} Terjual",
style: TextStyle(
color: Colors.black87,
fontWeight: FontWeight.bold,
fontSize: (AppFontSize.small - 1.25).sp,
),
),
Row(
children: [
Icon(Icons.star, color: Colors.orange, size: 3.5.w),
SizedBox(width: 1.w),
Text(
product.averageRating.toStringAsFixed(1),
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.grey.shade600,
),
),
],
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
CurrencyFormat.formatToIdr(product.sellingPrice, 0),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
color: AppColor.primaryColor,
),
),
Row(
children: [
Icon(
Icons.thumb_up,
color: Colors.red,
size: 3.5.w,
),
SizedBox(width: 1.w),
Text(
product.likes.toString(),
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.red,
),
),
],
),
],
),
],
),
),
),
],
),
),
);
}
Widget _buildErrorState() {
return RefreshIndicator(
onRefresh: () => ref.read(homeControllerProvider.notifier).refresh(),
color: AppColor.primaryColor,
backgroundColor: Colors.white,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: 80.h,
child: CustomEmptyScreen(
icon: Icons.cloud_off_outlined,
title: "Terjadi Kesalahan Koneksi",
subtitle: "Tarik ke bawah untuk mencoba lagi",
height: 40.h,
),
),
),
);
}
Widget _buildHomeLoading() {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 8.5.h, color: Colors.white),
Padding(
padding: EdgeInsets.all(4.w),
child: Container(
height: 6.5.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Container(
height: 20.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
),
SizedBox(height: 4.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
4,
(index) => Column(
children: [
Container(
width: 15.w,
height: 15.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
SizedBox(height: 1.h),
Container(
width: 12.w,
height: 1.5.h,
color: Colors.white,
),
],
),
),
),
),
SizedBox(height: 4.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Container(
height: 25.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.5.w),
),
),
),
],
),
),
);
}
Widget _buildClosedBanner({
required String onlineOpenTime,
required String onlineCloseTime,
required bool isCloseService,
required bool isActive,
}) {
final timeFormat = DateFormat('HH.mm');
final open = timeFormat.parse(onlineOpenTime).hour;
final close = timeFormat.parse(onlineCloseTime).hour;
final now = DateTime.now().hour;
if (!isCloseService && (now >= open && now < close) && isActive)
return const SizedBox();
final timeZone = TimeZone.getCurrentTimeZone();
final operatingHour =
"$onlineOpenTime $timeZone - $onlineCloseTime $timeZone";
return Container(
margin: EdgeInsets.symmetric(horizontal: 4.w, vertical: 1.h),
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 1.5.h),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.85),
borderRadius: BorderRadius.circular(3.w),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.white, size: 5.w),
SizedBox(width: 3.w),
Expanded(
child: Text(
!isActive
? "Outlet ini tutup sementara, kunjungi outlet lain kami"
: isCloseService
? "Toko sedang tutup sementara"
: "Toko sedang tutup. Jam layanan pembelian online: $operatingHour",
style: TextStyle(
color: Colors.white,
fontSize: AppFontSize.small.sp,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class _SliverSearchDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
_SliverSearchDelegate({required this.child});
@override
Widget build(BuildContext context, shrinkOffset, bool overlapsContent) =>
child;
@override
double get maxExtent => 6.h;
@override
double get minExtent => 6.h;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
}