This commit is contained in:
Phuoc Nguyen
2025-10-10 16:38:07 +07:00
parent e5b247d622
commit b94c158004
177 changed files with 25080 additions and 152 deletions

View File

@@ -0,0 +1,37 @@
import 'package:hive_ce/hive.dart';
import '../models/category_model.dart';
/// Category local data source using Hive
abstract class CategoryLocalDataSource {
Future<List<CategoryModel>> getAllCategories();
Future<CategoryModel?> getCategoryById(String id);
Future<void> cacheCategories(List<CategoryModel> categories);
Future<void> clearCategories();
}
class CategoryLocalDataSourceImpl implements CategoryLocalDataSource {
final Box<CategoryModel> box;
CategoryLocalDataSourceImpl(this.box);
@override
Future<List<CategoryModel>> getAllCategories() async {
return box.values.toList();
}
@override
Future<CategoryModel?> getCategoryById(String id) async {
return box.get(id);
}
@override
Future<void> cacheCategories(List<CategoryModel> categories) async {
final categoryMap = {for (var c in categories) c.id: c};
await box.putAll(categoryMap);
}
@override
Future<void> clearCategories() async {
await box.clear();
}
}

View File

@@ -0,0 +1,112 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/category.dart';
import '../../../../core/constants/storage_constants.dart';
part 'category_model.g.dart';
@HiveType(typeId: StorageConstants.categoryTypeId)
class CategoryModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String? description;
@HiveField(3)
final String? iconPath;
@HiveField(4)
final String? color;
@HiveField(5)
final int productCount;
@HiveField(6)
final DateTime createdAt;
CategoryModel({
required this.id,
required this.name,
this.description,
this.iconPath,
this.color,
required this.productCount,
required this.createdAt,
});
/// Convert to domain entity
Category toEntity() {
return Category(
id: id,
name: name,
description: description,
iconPath: iconPath,
color: color,
productCount: productCount,
createdAt: createdAt,
);
}
/// Create from domain entity
factory CategoryModel.fromEntity(Category category) {
return CategoryModel(
id: category.id,
name: category.name,
description: category.description,
iconPath: category.iconPath,
color: category.color,
productCount: category.productCount,
createdAt: category.createdAt,
);
}
/// Create from JSON
factory CategoryModel.fromJson(Map<String, dynamic> json) {
return CategoryModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
iconPath: json['iconPath'] as String?,
color: json['color'] as String?,
productCount: json['productCount'] as int,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'iconPath': iconPath,
'color': color,
'productCount': productCount,
'createdAt': createdAt.toIso8601String(),
};
}
/// Create a copy with updated fields
CategoryModel copyWith({
String? id,
String? name,
String? description,
String? iconPath,
String? color,
int? productCount,
DateTime? createdAt,
}) {
return CategoryModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
iconPath: iconPath ?? this.iconPath,
color: color ?? this.color,
productCount: productCount ?? this.productCount,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
@override
final typeId = 1;
@override
CategoryModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CategoryModel(
id: fields[0] as String,
name: fields[1] as String,
description: fields[2] as String?,
iconPath: fields[3] as String?,
color: fields[4] as String?,
productCount: (fields[5] as num).toInt(),
createdAt: fields[6] as DateTime,
);
}
@override
void write(BinaryWriter writer, CategoryModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.description)
..writeByte(3)
..write(obj.iconPath)
..writeByte(4)
..write(obj.color)
..writeByte(5)
..write(obj.productCount)
..writeByte(6)
..write(obj.createdAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CategoryModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,49 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/category.dart';
import '../../domain/repositories/category_repository.dart';
import '../datasources/category_local_datasource.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
class CategoryRepositoryImpl implements CategoryRepository {
final CategoryLocalDataSource localDataSource;
CategoryRepositoryImpl({
required this.localDataSource,
});
@override
Future<Either<Failure, List<Category>>> getAllCategories() async {
try {
final categories = await localDataSource.getAllCategories();
return Right(categories.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, Category>> getCategoryById(String id) async {
try {
final category = await localDataSource.getCategoryById(id);
if (category == null) {
return Left(NotFoundFailure('Category not found'));
}
return Right(category.toEntity());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, List<Category>>> syncCategories() async {
try {
// For now, return cached categories
// In the future, implement remote sync
final categories = await localDataSource.getAllCategories();
return Right(categories.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}

View File

@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
/// Category domain entity
class Category extends Equatable {
final String id;
final String name;
final String? description;
final String? iconPath;
final String? color;
final int productCount;
final DateTime createdAt;
const Category({
required this.id,
required this.name,
this.description,
this.iconPath,
this.color,
required this.productCount,
required this.createdAt,
});
@override
List<Object?> get props => [
id,
name,
description,
iconPath,
color,
productCount,
createdAt,
];
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/category.dart';
/// Category repository interface
abstract class CategoryRepository {
/// Get all categories from cache
Future<Either<Failure, List<Category>>> getAllCategories();
/// Get category by ID
Future<Either<Failure, Category>> getCategoryById(String id);
/// Sync categories from remote
Future<Either<Failure, List<Category>>> syncCategories();
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/category.dart';
import '../repositories/category_repository.dart';
/// Use case to get all categories
class GetAllCategories {
final CategoryRepository repository;
GetAllCategories(this.repository);
Future<Either<Failure, List<Category>>> call() async {
return await repository.getAllCategories();
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/category_grid.dart';
import '../providers/categories_provider.dart';
import '../../../products/presentation/providers/selected_category_provider.dart' as product_providers;
/// Categories page - displays all categories in a grid
class CategoriesPage extends ConsumerWidget {
const CategoriesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Categories'),
actions: [
// Refresh button
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh categories',
onPressed: () {
ref.invalidate(categoriesProvider);
},
),
],
),
body: RefreshIndicator(
onRefresh: () async {
await ref.refresh(categoriesProvider.future);
},
child: categoriesAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading categories',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => ref.invalidate(categoriesProvider),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (categories) {
return Column(
children: [
// Categories count
if (categories.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${categories.length} categor${categories.length == 1 ? 'y' : 'ies'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
// Category grid
Expanded(
child: CategoryGrid(
onCategoryTap: (category) {
// Set selected category
ref
.read(product_providers.selectedCategoryProvider.notifier)
.selectCategory(category.id);
// Show snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Filtering products by ${category.name}',
),
duration: const Duration(seconds: 2),
action: SnackBarAction(
label: 'View',
onPressed: () {
// Navigate to products tab
// This will be handled by the parent widget
// For now, just show a message
},
),
),
);
},
),
),
],
);
},
),
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/category.dart';
part 'categories_provider.g.dart';
/// Provider for categories list
@riverpod
class Categories extends _$Categories {
@override
Future<List<Category>> build() async {
// TODO: Implement with repository
return [];
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Fetch categories from repository
return [];
});
}
Future<void> syncCategories() async {
// TODO: Implement sync logic with remote data source
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Sync categories from API
return [];
});
}
}
/// Provider for selected category
@riverpod
class SelectedCategory extends _$SelectedCategory {
@override
String? build() => null;
void select(String? categoryId) {
state = categoryId;
}
void clear() {
state = null;
}
}

View File

@@ -0,0 +1,119 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'categories_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for categories list
@ProviderFor(Categories)
const categoriesProvider = CategoriesProvider._();
/// Provider for categories list
final class CategoriesProvider
extends $AsyncNotifierProvider<Categories, List<Category>> {
/// Provider for categories list
const CategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'categoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoriesHash();
@$internal
@override
Categories create() => Categories();
}
String _$categoriesHash() => r'aa7afc38a5567b0f42ff05ca23b287baa4780cbe';
/// Provider for categories list
abstract class _$Categories extends $AsyncNotifier<List<Category>> {
FutureOr<List<Category>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Category>>, List<Category>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Category>>, List<Category>>,
AsyncValue<List<Category>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Provider for selected category
@ProviderFor(SelectedCategory)
const selectedCategoryProvider = SelectedCategoryProvider._();
/// Provider for selected category
final class SelectedCategoryProvider
extends $NotifierProvider<SelectedCategory, String?> {
/// Provider for selected category
const SelectedCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryHash();
@$internal
@override
SelectedCategory create() => SelectedCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryHash() => r'a47cd2de07ad285d4b73b2294ba954cb1cdd8e4c';
/// Provider for selected category
abstract class _$SelectedCategory extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/datasources/category_local_datasource.dart';
import '../../../../core/database/hive_database.dart';
import '../../data/models/category_model.dart';
part 'category_datasource_provider.g.dart';
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
@Riverpod(keepAlive: true)
CategoryLocalDataSource categoryLocalDataSource(Ref ref) {
final box = HiveDatabase.instance.getBox<CategoryModel>('categories');
return CategoryLocalDataSourceImpl(box);
}

View File

@@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_datasource_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
@ProviderFor(categoryLocalDataSource)
const categoryLocalDataSourceProvider = CategoryLocalDataSourceProvider._();
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
final class CategoryLocalDataSourceProvider
extends
$FunctionalProvider<
CategoryLocalDataSource,
CategoryLocalDataSource,
CategoryLocalDataSource
>
with $Provider<CategoryLocalDataSource> {
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
const CategoryLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'categoryLocalDataSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoryLocalDataSourceHash();
@$internal
@override
$ProviderElement<CategoryLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
CategoryLocalDataSource create(Ref ref) {
return categoryLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CategoryLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CategoryLocalDataSource>(value),
);
}
}
String _$categoryLocalDataSourceHash() =>
r'1f8412f2dc76a348873f1da4f76ae4a08991f269';

View File

@@ -0,0 +1,35 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../products/presentation/providers/products_provider.dart';
part 'category_product_count_provider.g.dart';
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
@riverpod
int categoryProductCount(Ref ref, String categoryId) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) => products.where((p) => p.categoryId == categoryId).length,
loading: () => 0,
error: (_, __) => 0,
);
}
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
@riverpod
Map<String, int> allCategoryProductCounts(Ref ref) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) {
// Group products by category and count
final counts = <String, int>{};
for (final product in products) {
counts[product.categoryId] = (counts[product.categoryId] ?? 0) + 1;
}
return counts;
},
loading: () => {},
error: (_, __) => {},
);
}

View File

@@ -0,0 +1,156 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_product_count_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
@ProviderFor(categoryProductCount)
const categoryProductCountProvider = CategoryProductCountFamily._();
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
final class CategoryProductCountProvider
extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
const CategoryProductCountProvider._({
required CategoryProductCountFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'categoryProductCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoryProductCountHash();
@override
String toString() {
return r'categoryProductCountProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
final argument = this.argument as String;
return categoryProductCount(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
@override
bool operator ==(Object other) {
return other is CategoryProductCountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$categoryProductCountHash() =>
r'2d51eea21a4d018964d10ee00d0957a2c38d28c6';
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
final class CategoryProductCountFamily extends $Family
with $FunctionalFamilyOverride<int, String> {
const CategoryProductCountFamily._()
: super(
retry: null,
name: r'categoryProductCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
CategoryProductCountProvider call(String categoryId) =>
CategoryProductCountProvider._(argument: categoryId, from: this);
@override
String toString() => r'categoryProductCountProvider';
}
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
@ProviderFor(allCategoryProductCounts)
const allCategoryProductCountsProvider = AllCategoryProductCountsProvider._();
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
final class AllCategoryProductCountsProvider
extends
$FunctionalProvider<
Map<String, int>,
Map<String, int>,
Map<String, int>
>
with $Provider<Map<String, int>> {
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
const AllCategoryProductCountsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'allCategoryProductCountsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$allCategoryProductCountsHash();
@$internal
@override
$ProviderElement<Map<String, int>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Map<String, int> create(Ref ref) {
return allCategoryProductCounts(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, int> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, int>>(value),
);
}
}
String _$allCategoryProductCountsHash() =>
r'a4ecc281916772ac74327333bd76e7b6463a0992';

View File

@@ -0,0 +1,4 @@
/// Export all category providers
export 'category_datasource_provider.dart';
export 'categories_provider.dart';
export 'category_product_count_provider.dart';

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../domain/entities/category.dart';
/// Category card widget
class CategoryCard extends StatelessWidget {
final Category category;
const CategoryCard({
super.key,
required this.category,
});
@override
Widget build(BuildContext context) {
final color = category.color != null
? Color(int.parse(category.color!.substring(1), radix: 16) + 0xFF000000)
: Theme.of(context).colorScheme.primaryContainer;
return Card(
color: color,
child: InkWell(
onTap: () {
// TODO: Filter products by category
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category,
size: 48,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(height: 8),
Text(
category.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${category.productCount} products',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/categories_provider.dart';
import '../../domain/entities/category.dart';
import 'category_card.dart';
import '../../../../core/widgets/loading_indicator.dart';
import '../../../../core/widgets/error_widget.dart';
import '../../../../core/widgets/empty_state.dart';
/// Category grid widget
class CategoryGrid extends ConsumerWidget {
final void Function(Category)? onCategoryTap;
const CategoryGrid({
super.key,
this.onCategoryTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
return categoriesAsync.when(
loading: () => const LoadingIndicator(message: 'Loading categories...'),
error: (error, stack) => ErrorDisplay(
message: error.toString(),
onRetry: () => ref.refresh(categoriesProvider),
),
data: (categories) {
if (categories.isEmpty) {
return const EmptyState(
message: 'No categories found',
subMessage: 'Categories will appear here once added',
icon: Icons.category_outlined,
);
}
return LayoutBuilder(
builder: (context, constraints) {
// Determine grid columns based on width
int crossAxisCount = 2;
if (constraints.maxWidth > 1200) {
crossAxisCount = 4;
} else if (constraints.maxWidth > 800) {
crossAxisCount = 3;
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return RepaintBoundary(
child: GestureDetector(
onTap: () => onCategoryTap?.call(category),
child: CategoryCard(category: category),
),
);
},
);
},
);
},
);
}
}

View File

@@ -0,0 +1,5 @@
// Category Feature Widgets
export 'category_card.dart';
export 'category_grid.dart';
// This file provides a central export point for all category widgets