product and category loader

This commit is contained in:
efrilm
2025-10-25 00:13:43 +07:00
parent 7bcf54c555
commit 754048b565
16 changed files with 712 additions and 6 deletions
@@ -1,12 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../application/category/category_loader/category_loader_bloc.dart';
import '../../../../../application/product/product_loader/product_loader_bloc.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../injection.dart';
import 'widgets/home_left_panel.dart';
import 'widgets/home_right_panel.dart';
@RoutePage()
class HomePage extends StatelessWidget {
class HomePage extends StatelessWidget implements AutoRouteWrapper {
const HomePage({super.key});
@override
@@ -30,4 +34,20 @@ class HomePage extends StatelessWidget {
),
);
}
@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
getIt<CategoryLoaderBloc>()
..add(CategoryLoaderEvent.getCategories()),
),
BlocProvider(
create: (context) =>
getIt<ProductLoaderBloc>()..add(ProductLoaderEvent.getProduct()),
),
],
child: this,
);
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/product/product_loader/product_loader_bloc.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../domain/category/category.dart';
class CategoryTabBar extends StatefulWidget {
final List<Category> categories;
final List<Widget> tabViews;
const CategoryTabBar({
super.key,
required this.categories,
required this.tabViews,
});
@override
State<CategoryTabBar> createState() => _CategoryTabBarState();
}
class _CategoryTabBarState extends State<CategoryTabBar>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String? selectedCategoryId;
@override
void initState() {
super.initState();
_tabController = TabController(
length: widget.categories.length,
vsync: this,
);
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_tabController.index == 0) {
context.read<ProductLoaderBloc>().add(
ProductLoaderEvent.getProduct(),
);
} else {
selectedCategoryId = widget.categories[_tabController.index].id;
context.read<ProductLoaderBloc>().add(
ProductLoaderEvent.getProduct(categoryId: selectedCategoryId),
);
}
}
});
}
@override
void didUpdateWidget(CategoryTabBar oldWidget) {
super.didUpdateWidget(oldWidget);
// ✅ Update TabController when categories length changes
if (oldWidget.categories.length != widget.categories.length) {
_tabController.dispose();
_tabController = TabController(
length: widget.categories.length,
vsync: this,
initialIndex: 0, // Reset to first tab
);
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_tabController.index == 0) {
context.read<ProductLoaderBloc>().add(
ProductLoaderEvent.getProduct(),
);
} else {
selectedCategoryId = widget.categories[_tabController.index].id;
context.read<ProductLoaderBloc>().add(
ProductLoaderEvent.getProduct(categoryId: selectedCategoryId),
);
}
}
});
}
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Material(
elevation: 0,
color: Colors.white,
borderOnForeground: false,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelColor: AppColor.primary,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
dividerColor: AppColor.primary,
unselectedLabelColor: AppColor.primary,
indicatorSize: TabBarIndicatorSize.label,
indicatorWeight: 4,
indicatorColor: AppColor.primary,
tabs: widget.categories
.map(
(category) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Tab(text: category.name),
),
)
.toList(),
),
),
),
Expanded(
// ✅ ini bagian penting
child: TabBarView(
controller: _tabController,
children: widget.tabViews,
),
),
],
);
}
}
@@ -1,15 +1,214 @@
import 'package:flutter/widgets.dart';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import '../../../../../../application/category/category_loader/category_loader_bloc.dart';
import '../../../../../../application/product/product_loader/product_loader_bloc.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../domain/category/category.dart';
import '../../../../../components/card/error_card.dart';
import '../../../../../components/card/product_card.dart';
import '../../../../../components/loader/loader_with_text.dart';
import 'category_tabbar.dart';
import 'home_title.dart';
class HomeLeftPanel extends StatelessWidget {
class HomeLeftPanel extends StatefulWidget {
const HomeLeftPanel({super.key});
@override
State<HomeLeftPanel> createState() => _HomeLeftPanelState();
}
class _HomeLeftPanelState extends State<HomeLeftPanel> {
final searchController = TextEditingController();
final ScrollController scrollController = ScrollController();
String searchQuery = '';
bool _handleScrollNotification(
ScrollNotification notification,
String? categoryId,
) {
if (notification is ScrollEndNotification &&
scrollController.position.extentAfter == 0) {
log('📄 Loading more products...');
context.read<ProductLoaderBloc>().add(ProductLoaderEvent.loadMore());
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CategoryLoaderBloc, CategoryLoaderState>(
builder: (context, state) {
if (state.isLoadingMore) {
return Center(child: LoaderWithText());
}
return state.failureOptionCategory.fold(
() => _buildContent(context, state.categories),
(f) => f.maybeMap(
orElse: () => ErrorCard(
title: 'Error Kategori',
message: 'Terjadi kesalahan saat memuat kategori',
onTap: () => context.read<CategoryLoaderBloc>().add(
CategoryLoaderEvent.getCategories(),
),
),
dynamicErrorMessage: (value) => ErrorCard(
title: 'Error Kategori',
message: value.erroMessage,
onTap: () => context.read<CategoryLoaderBloc>().add(
CategoryLoaderEvent.getCategories(),
),
),
localStorageError: (value) => ErrorCard(
title: 'Error Kategori',
message: value.erroMessage,
onTap: () => context.read<CategoryLoaderBloc>().add(
CategoryLoaderEvent.getCategories(),
),
),
serverError: (value) => ErrorCard(
title: 'Error Kategori',
message: 'Terjadi kesalahan saat memuat kategori',
onTap: () => context.read<CategoryLoaderBloc>().add(
CategoryLoaderEvent.getCategories(),
),
),
unexpectedError: (value) => ErrorCard(
title: 'Error Kategori',
message: 'Terjadi kesalahan saat memuat kategori',
onTap: () => context.read<CategoryLoaderBloc>().add(
CategoryLoaderEvent.getCategories(),
),
),
empty: (value) => ErrorCard(
title: 'Error Kategori',
message: 'Kategori kosong',
onTap: () => context.read<CategoryLoaderBloc>().add(
CategoryLoaderEvent.getCategories(),
),
),
),
);
},
);
}
Column _buildContent(BuildContext context, List<Category> categories) {
return Column(
children: [
HomeTitle(controller: TextEditingController(), onChanged: (value) {}),
HomeTitle(
controller: searchController,
onChanged: (value) {
setState(() {
searchQuery = value;
});
// Fast local search
Future.delayed(Duration(milliseconds: 200), () {
if (value == searchController.text) {
log('🔍 Local search: "$value"');
context.read<ProductLoaderBloc>().add(
ProductLoaderEvent.searchProduct(query: value),
);
}
});
},
),
Expanded(
child: BlocBuilder<ProductLoaderBloc, ProductLoaderState>(
builder: (context, state) {
return CategoryTabBar(
key: ValueKey(categories.length),
categories: categories,
tabViews: categories.map((category) {
return SizedBox(
child: state.failureOptionProduct.fold(
() => _buildProductGrid(
state.products,
state.hasReachedMax,
state.isLoadingMore,
state.categoryId,
state.page,
),
(f) => f.maybeMap(
orElse: () => ErrorCard(
title: 'Error Produk',
message: 'Terjadi kesalahan saat memuat produk',
onTap: () => context.read<ProductLoaderBloc>().add(
ProductLoaderEvent.getProduct(),
),
),
),
),
);
}).toList(),
);
},
),
),
],
);
}
Widget _buildProductGrid(
List products,
bool hasReachedMax,
bool isLoadingMore,
String? categoryId,
int currentPage,
) {
return Column(
children: [
if (products.isNotEmpty)
Container(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 0,
).copyWith(bottom: 6),
child: Row(
children: [
Text(
'${products.length} produk ditemukan',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
Spacer(),
if (isLoadingMore)
SizedBox(
width: 12,
height: 12,
child: SpinKitFadingCircle(
color: AppColor.primary,
size: 12,
),
),
],
),
),
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (notification) =>
_handleScrollNotification(notification, categoryId),
child: GridView.builder(
itemCount: products.length,
controller: scrollController,
padding: const EdgeInsets.all(16),
cacheExtent: 200.0,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 30,
crossAxisSpacing: 30,
childAspectRatio: 180 / 240,
),
itemBuilder: (context, index) =>
ProductCard(product: products[index]),
),
),
),
],
);
}