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 createState() => _HomeScreenState(); } class _HomeScreenState extends ConsumerState { 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 _instanceService() async { final echoService = ref.read(echoServiceProvider); await echoService.init( listener: (_, data) async { final tables = List.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 _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 _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 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 campaigns, required List 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 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 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 products, required String currentOutletId, required Map 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 products, required String currentOutletId, required Map 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; }