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

View File

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/category/category.dart';
@ -11,6 +12,7 @@ part 'category_loader_event.dart';
part 'category_loader_state.dart';
part 'category_loader_bloc.freezed.dart';
@injectable
class CategoryLoaderBloc
extends Bloc<CategoryLoaderEvent, CategoryLoaderState> {
final ICategoryRepository _categoryRepository;

View File

@ -0,0 +1,15 @@
part of 'extension.dart';
extension DoubleExt on double {
String get currencyFormatRp => NumberFormat.currency(
locale: 'id',
symbol: 'Rp. ',
decimalDigits: 0,
).format(this);
String get currencyFormatRpV2 => NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
).format(this);
}

View File

@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
part 'build_context_extension.dart';
part 'int_extension.dart';
part 'double_extension.dart';

View File

@ -0,0 +1,15 @@
part of 'extension.dart';
extension IntegerExt on int {
String get currencyFormatRp => NumberFormat.currency(
locale: 'id',
symbol: 'Rp. ',
decimalDigits: 0,
).format(this);
String get currencyFormatRpV2 => NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
).format(this);
}

View File

@ -12,6 +12,8 @@
import 'package:apskel_pos_flutter_v2/application/auth/auth_bloc.dart' as _i343;
import 'package:apskel_pos_flutter_v2/application/auth/login_form/login_form_bloc.dart'
as _i46;
import 'package:apskel_pos_flutter_v2/application/category/category_loader/category_loader_bloc.dart'
as _i1018;
import 'package:apskel_pos_flutter_v2/application/outlet/outlet_loader/outlet_loader_bloc.dart'
as _i76;
import 'package:apskel_pos_flutter_v2/application/product/product_loader/product_loader_bloc.dart'
@ -147,6 +149,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i46.LoginFormBloc>(
() => _i46.LoginFormBloc(gh<_i776.IAuthRepository>()),
);
gh.factory<_i1018.CategoryLoaderBloc>(
() => _i1018.CategoryLoaderBloc(gh<_i502.ICategoryRepository>()),
);
gh.factory<_i343.AuthBloc>(
() => _i343.AuthBloc(
gh<_i776.IAuthRepository>(),

View File

@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../application/auth/auth_bloc.dart';
import '../application/category/category_loader/category_loader_bloc.dart';
import '../application/outlet/outlet_loader/outlet_loader_bloc.dart';
import '../application/product/product_loader/product_loader_bloc.dart';
import '../common/theme/theme.dart';
import '../common/constant/app_constant.dart';
import '../injection.dart';
@ -27,6 +28,7 @@ class _AppWidgetState extends State<AppWidget> {
BlocProvider(create: (context) => getIt<AuthBloc>()),
BlocProvider(create: (context) => getIt<OutletLoaderBloc>()),
BlocProvider(create: (context) => getIt<CategoryLoaderBloc>()),
BlocProvider(create: (context) => getIt<ProductLoaderBloc>()),
],
child: MaterialApp.router(
debugShowCheckedModeBanner: false,

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../button/button.dart';
class ErrorCard extends StatelessWidget {
final String title;
final String message;
final Function() onTap;
const ErrorCard({
super.key,
required this.title,
required this.message,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red.shade400),
SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
),
SizedBox(height: 16),
AppElevatedButton.filled(
width: 120,
onPressed: onTap,
label: 'Coba Lagi',
),
],
),
);
}
}

View File

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/product/product.dart';
import '../image/image.dart';
import '../spaces/space.dart';
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: AppColor.disabled),
),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
child: product.imageUrl == ""
? Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(
color: AppColor.disabled.withOpacity(0.4),
),
child: const Icon(
Icons.image,
color: AppColor.textSecondary,
),
)
: AppNetworkImage(
url: product.imageUrl,
fit: BoxFit.fill,
width: double.infinity,
height: 140,
),
),
const Spacer(),
Text(
product.name,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SpaceHeight(4),
Align(
alignment: Alignment.center,
child: Text(
product.price.currencyFormatRp,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
SpaceHeight(4),
],
),
),
Positioned(
top: 4,
right: 4,
child: Container(
width: 40,
height: 40,
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9.0)),
color: AppColor.primary,
),
child: Center(
child: Text(
0.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
),
if (product.isActive == false)
Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
color: AppColor.disabled.withOpacity(0.5),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../../../common/theme/theme.dart';
part 'network_image.dart';

View File

@ -0,0 +1,77 @@
part of 'image.dart';
class AppNetworkImage extends StatelessWidget {
final String? url;
final double? height;
final double? width;
final double? borderRadius;
final BoxFit? fit;
final bool? isCanZoom;
final VoidCallback? onTap;
const AppNetworkImage({
super.key,
this.url,
this.height,
this.width,
this.borderRadius = 0,
this.fit = BoxFit.cover,
this.isCanZoom = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
Widget customPhoto(
double? heightx,
double? widthx,
BoxFit? fitx,
double? radius,
) {
return CachedNetworkImage(
imageUrl: url.toString(),
memCacheHeight: 120,
memCacheWidth: 120,
placeholder: (context, url) => Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
height: height,
width: width,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(radius ?? 0),
),
),
),
errorWidget: (context, url, error) {
FirebaseCrashlytics.instance.recordError(
error,
StackTrace.current,
reason: 'Failed to load image from: $url',
fatal: false,
);
return Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(
color: AppColor.disabled.withOpacity(0.4),
),
child: const Icon(Icons.image, color: AppColor.disabled),
);
},
height: heightx,
width: widthx,
fit: fitx,
);
}
return GestureDetector(
onTap: onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius!),
child: customPhoto(height, width, BoxFit.fill, borderRadius),
),
);
}
}

View File

@ -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,
);
}

View File

@ -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,
),
),
],
);
}
}

View File

@ -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]),
),
),
),
],
);
}

View File

@ -56,7 +56,7 @@ class HomeRoute extends _i10.PageRouteInfo<void> {
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i2.HomePage();
return _i10.WrappedRoute(child: const _i2.HomePage());
},
);
}
@ -152,7 +152,7 @@ class SyncRoute extends _i10.PageRouteInfo<void> {
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i8.SyncPage();
return _i10.WrappedRoute(child: const _i8.SyncPage());
},
);
}

View File

@ -161,6 +161,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.12.0"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
@ -406,6 +430,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.1.1"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_gen_core:
dependency: transitive
description:
@ -704,6 +736,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
@ -848,6 +888,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@ -920,6 +968,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
@ -949,6 +1005,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
@ -1069,6 +1133,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:

View File

@ -34,6 +34,8 @@ dependencies:
bloc: ^9.1.0
flutter_bloc: ^9.1.1
sqflite: ^2.4.2
cached_network_image: ^3.4.1
shimmer: ^3.0.0
dev_dependencies:
flutter_test: