runable
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../models/product_model.dart';
|
||||
|
||||
/// Product local data source using Hive
|
||||
abstract class ProductLocalDataSource {
|
||||
Future<List<ProductModel>> getAllProducts();
|
||||
Future<ProductModel?> getProductById(String id);
|
||||
Future<void> cacheProducts(List<ProductModel> products);
|
||||
Future<void> clearProducts();
|
||||
}
|
||||
|
||||
class ProductLocalDataSourceImpl implements ProductLocalDataSource {
|
||||
final Box<ProductModel> box;
|
||||
|
||||
ProductLocalDataSourceImpl(this.box);
|
||||
|
||||
@override
|
||||
Future<List<ProductModel>> getAllProducts() async {
|
||||
return box.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductModel?> getProductById(String id) async {
|
||||
return box.get(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cacheProducts(List<ProductModel> products) async {
|
||||
final productMap = {for (var p in products) p.id: p};
|
||||
await box.putAll(productMap);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearProducts() async {
|
||||
await box.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import '../models/product_model.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/constants/api_constants.dart';
|
||||
|
||||
/// Product remote data source using API
|
||||
abstract class ProductRemoteDataSource {
|
||||
Future<List<ProductModel>> getAllProducts();
|
||||
Future<ProductModel> getProductById(String id);
|
||||
Future<List<ProductModel>> searchProducts(String query);
|
||||
}
|
||||
|
||||
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
final DioClient client;
|
||||
|
||||
ProductRemoteDataSourceImpl(this.client);
|
||||
|
||||
@override
|
||||
Future<List<ProductModel>> getAllProducts() async {
|
||||
final response = await client.get(ApiConstants.products);
|
||||
final List<dynamic> data = response.data['products'] ?? [];
|
||||
return data.map((json) => ProductModel.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductModel> getProductById(String id) async {
|
||||
final response = await client.get(ApiConstants.productById(id));
|
||||
return ProductModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProductModel>> searchProducts(String query) async {
|
||||
final response = await client.get(
|
||||
ApiConstants.searchProducts,
|
||||
queryParameters: {'q': query},
|
||||
);
|
||||
final List<dynamic> data = response.data['products'] ?? [];
|
||||
return data.map((json) => ProductModel.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
115
lib/features/products/data/models/product_model.dart
Normal file
115
lib/features/products/data/models/product_model.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../../../core/constants/storage_constants.dart';
|
||||
|
||||
part 'product_model.g.dart';
|
||||
|
||||
@HiveType(typeId: StorageConstants.productTypeId)
|
||||
class ProductModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
@HiveField(2)
|
||||
final String description;
|
||||
|
||||
@HiveField(3)
|
||||
final double price;
|
||||
|
||||
@HiveField(4)
|
||||
final String? imageUrl;
|
||||
|
||||
@HiveField(5)
|
||||
final String categoryId;
|
||||
|
||||
@HiveField(6)
|
||||
final int stockQuantity;
|
||||
|
||||
@HiveField(7)
|
||||
final bool isAvailable;
|
||||
|
||||
@HiveField(8)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(9)
|
||||
final DateTime updatedAt;
|
||||
|
||||
ProductModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.price,
|
||||
this.imageUrl,
|
||||
required this.categoryId,
|
||||
required this.stockQuantity,
|
||||
required this.isAvailable,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Convert to domain entity
|
||||
Product toEntity() {
|
||||
return Product(
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
price: price,
|
||||
imageUrl: imageUrl,
|
||||
categoryId: categoryId,
|
||||
stockQuantity: stockQuantity,
|
||||
isAvailable: isAvailable,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from domain entity
|
||||
factory ProductModel.fromEntity(Product product) {
|
||||
return ProductModel(
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: product.price,
|
||||
imageUrl: product.imageUrl,
|
||||
categoryId: product.categoryId,
|
||||
stockQuantity: product.stockQuantity,
|
||||
isAvailable: product.isAvailable,
|
||||
createdAt: product.createdAt,
|
||||
updatedAt: product.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory ProductModel.fromJson(Map<String, dynamic> json) {
|
||||
return ProductModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
price: (json['price'] as num).toDouble(),
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
categoryId: json['categoryId'] as String,
|
||||
stockQuantity: json['stockQuantity'] as int,
|
||||
isAvailable: json['isAvailable'] as bool,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'price': price,
|
||||
'imageUrl': imageUrl,
|
||||
'categoryId': categoryId,
|
||||
'stockQuantity': stockQuantity,
|
||||
'isAvailable': isAvailable,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
68
lib/features/products/data/models/product_model.g.dart
Normal file
68
lib/features/products/data/models/product_model.g.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'product_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
||||
@override
|
||||
final typeId = 0;
|
||||
|
||||
@override
|
||||
ProductModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ProductModel(
|
||||
id: fields[0] as String,
|
||||
name: fields[1] as String,
|
||||
description: fields[2] as String,
|
||||
price: (fields[3] as num).toDouble(),
|
||||
imageUrl: fields[4] as String?,
|
||||
categoryId: fields[5] as String,
|
||||
stockQuantity: (fields[6] as num).toInt(),
|
||||
isAvailable: fields[7] as bool,
|
||||
createdAt: fields[8] as DateTime,
|
||||
updatedAt: fields[9] as DateTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ProductModel obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.name)
|
||||
..writeByte(2)
|
||||
..write(obj.description)
|
||||
..writeByte(3)
|
||||
..write(obj.price)
|
||||
..writeByte(4)
|
||||
..write(obj.imageUrl)
|
||||
..writeByte(5)
|
||||
..write(obj.categoryId)
|
||||
..writeByte(6)
|
||||
..write(obj.stockQuantity)
|
||||
..writeByte(7)
|
||||
..write(obj.isAvailable)
|
||||
..writeByte(8)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(9)
|
||||
..write(obj.updatedAt);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProductModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/repositories/product_repository.dart';
|
||||
import '../datasources/product_local_datasource.dart';
|
||||
import '../datasources/product_remote_datasource.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
|
||||
class ProductRepositoryImpl implements ProductRepository {
|
||||
final ProductLocalDataSource localDataSource;
|
||||
final ProductRemoteDataSource remoteDataSource;
|
||||
|
||||
ProductRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
required this.remoteDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<Product>>> getAllProducts() async {
|
||||
try {
|
||||
final products = await localDataSource.getAllProducts();
|
||||
return Right(products.map((model) => model.toEntity()).toList());
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async {
|
||||
try {
|
||||
final allProducts = await localDataSource.getAllProducts();
|
||||
final filtered = allProducts.where((p) => p.categoryId == categoryId).toList();
|
||||
return Right(filtered.map((model) => model.toEntity()).toList());
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<Product>>> searchProducts(String query) async {
|
||||
try {
|
||||
final allProducts = await localDataSource.getAllProducts();
|
||||
final filtered = allProducts.where((p) =>
|
||||
p.name.toLowerCase().contains(query.toLowerCase()) ||
|
||||
p.description.toLowerCase().contains(query.toLowerCase())
|
||||
).toList();
|
||||
return Right(filtered.map((model) => model.toEntity()).toList());
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Product>> getProductById(String id) async {
|
||||
try {
|
||||
final product = await localDataSource.getProductById(id);
|
||||
if (product == null) {
|
||||
return Left(NotFoundFailure('Product not found'));
|
||||
}
|
||||
return Right(product.toEntity());
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<Product>>> syncProducts() async {
|
||||
try {
|
||||
final products = await remoteDataSource.getAllProducts();
|
||||
await localDataSource.cacheProducts(products);
|
||||
return Right(products.map((model) => model.toEntity()).toList());
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/features/products/domain/entities/product.dart
Normal file
42
lib/features/products/domain/entities/product.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Product domain entity
|
||||
class Product extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final double price;
|
||||
final String? imageUrl;
|
||||
final String categoryId;
|
||||
final int stockQuantity;
|
||||
final bool isAvailable;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Product({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.price,
|
||||
this.imageUrl,
|
||||
required this.categoryId,
|
||||
required this.stockQuantity,
|
||||
required this.isAvailable,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
imageUrl,
|
||||
categoryId,
|
||||
stockQuantity,
|
||||
isAvailable,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/product.dart';
|
||||
|
||||
/// Product repository interface
|
||||
abstract class ProductRepository {
|
||||
/// Get all products from cache
|
||||
Future<Either<Failure, List<Product>>> getAllProducts();
|
||||
|
||||
/// Get products by category
|
||||
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId);
|
||||
|
||||
/// Search products
|
||||
Future<Either<Failure, List<Product>>> searchProducts(String query);
|
||||
|
||||
/// Get product by ID
|
||||
Future<Either<Failure, Product>> getProductById(String id);
|
||||
|
||||
/// Sync products from remote
|
||||
Future<Either<Failure, List<Product>>> syncProducts();
|
||||
}
|
||||
15
lib/features/products/domain/usecases/get_all_products.dart
Normal file
15
lib/features/products/domain/usecases/get_all_products.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/product.dart';
|
||||
import '../repositories/product_repository.dart';
|
||||
|
||||
/// Use case to get all products
|
||||
class GetAllProducts {
|
||||
final ProductRepository repository;
|
||||
|
||||
GetAllProducts(this.repository);
|
||||
|
||||
Future<Either<Failure, List<Product>>> call() async {
|
||||
return await repository.getAllProducts();
|
||||
}
|
||||
}
|
||||
15
lib/features/products/domain/usecases/search_products.dart
Normal file
15
lib/features/products/domain/usecases/search_products.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/product.dart';
|
||||
import '../repositories/product_repository.dart';
|
||||
|
||||
/// Use case to search products
|
||||
class SearchProducts {
|
||||
final ProductRepository repository;
|
||||
|
||||
SearchProducts(this.repository);
|
||||
|
||||
Future<Either<Failure, List<Product>>> call(String query) async {
|
||||
return await repository.searchProducts(query);
|
||||
}
|
||||
}
|
||||
200
lib/features/products/presentation/pages/products_page.dart
Normal file
200
lib/features/products/presentation/pages/products_page.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../widgets/product_grid.dart';
|
||||
import '../widgets/product_search_bar.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../providers/selected_category_provider.dart' as product_providers;
|
||||
import '../providers/filtered_products_provider.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../../categories/presentation/providers/categories_provider.dart';
|
||||
|
||||
/// Products page - displays all products in a grid
|
||||
class ProductsPage extends ConsumerStatefulWidget {
|
||||
const ProductsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProductsPage> createState() => _ProductsPageState();
|
||||
}
|
||||
|
||||
class _ProductsPageState extends ConsumerState<ProductsPage> {
|
||||
ProductSortOption _sortOption = ProductSortOption.nameAsc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
final selectedCategory = ref.watch(product_providers.selectedCategoryProvider);
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
|
||||
// Get filtered products from the provider
|
||||
final filteredProducts = productsAsync.when(
|
||||
data: (products) => products,
|
||||
loading: () => <Product>[],
|
||||
error: (_, __) => <Product>[],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Products'),
|
||||
actions: [
|
||||
// Sort button
|
||||
PopupMenuButton<ProductSortOption>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: 'Sort products',
|
||||
onSelected: (option) {
|
||||
setState(() {
|
||||
_sortOption = option;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: ProductSortOption.nameAsc,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.sort_by_alpha),
|
||||
SizedBox(width: 8),
|
||||
Text('Name (A-Z)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProductSortOption.nameDesc,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.sort_by_alpha),
|
||||
SizedBox(width: 8),
|
||||
Text('Name (Z-A)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProductSortOption.priceAsc,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.attach_money),
|
||||
SizedBox(width: 8),
|
||||
Text('Price (Low to High)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProductSortOption.priceDesc,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.attach_money),
|
||||
SizedBox(width: 8),
|
||||
Text('Price (High to Low)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProductSortOption.newest,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.access_time),
|
||||
SizedBox(width: 8),
|
||||
Text('Newest First'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProductSortOption.oldest,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.access_time),
|
||||
SizedBox(width: 8),
|
||||
Text('Oldest First'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(120),
|
||||
child: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: ProductSearchBar(),
|
||||
),
|
||||
// Category filter chips
|
||||
categoriesAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (categories) {
|
||||
if (categories.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SizedBox(
|
||||
height: 50,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
children: [
|
||||
// All categories chip
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: FilterChip(
|
||||
label: const Text('All'),
|
||||
selected: selectedCategory == null,
|
||||
onSelected: (_) {
|
||||
ref
|
||||
.read(product_providers.selectedCategoryProvider.notifier)
|
||||
.clearSelection();
|
||||
},
|
||||
),
|
||||
),
|
||||
// Category chips
|
||||
...categories.map(
|
||||
(category) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: FilterChip(
|
||||
label: Text(category.name),
|
||||
selected: selectedCategory == category.id,
|
||||
onSelected: (_) {
|
||||
ref
|
||||
.read(product_providers.selectedCategoryProvider.notifier)
|
||||
.selectCategory(category.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.refresh(productsProvider.future);
|
||||
await ref.refresh(categoriesProvider.future);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
// Results count
|
||||
if (filteredProducts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'${filteredProducts.length} product${filteredProducts.length == 1 ? '' : 's'} found',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Product grid
|
||||
Expanded(
|
||||
child: ProductGrid(
|
||||
sortOption: _sortOption,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import 'products_provider.dart';
|
||||
import 'search_query_provider.dart' as search_providers;
|
||||
import 'selected_category_provider.dart';
|
||||
|
||||
part 'filtered_products_provider.g.dart';
|
||||
|
||||
/// Filtered products provider
|
||||
/// Combines products, search query, and category filter to provide filtered results
|
||||
@riverpod
|
||||
class FilteredProducts extends _$FilteredProducts {
|
||||
@override
|
||||
List<Product> build() {
|
||||
// Watch all products
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
final products = productsAsync.when(
|
||||
data: (data) => data,
|
||||
loading: () => <Product>[],
|
||||
error: (_, __) => <Product>[],
|
||||
);
|
||||
|
||||
// Watch search query
|
||||
final searchQuery = ref.watch(search_providers.searchQueryProvider);
|
||||
|
||||
// Watch selected category
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
|
||||
// Apply filters
|
||||
return _applyFilters(products, searchQuery, selectedCategory);
|
||||
}
|
||||
|
||||
/// Apply search and category filters to products
|
||||
List<Product> _applyFilters(
|
||||
List<Product> products,
|
||||
String searchQuery,
|
||||
String? categoryId,
|
||||
) {
|
||||
var filtered = products;
|
||||
|
||||
// Filter by category if selected
|
||||
if (categoryId != null) {
|
||||
filtered = filtered.where((p) => p.categoryId == categoryId).toList();
|
||||
}
|
||||
|
||||
// Filter by search query if present
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final lowerQuery = searchQuery.toLowerCase();
|
||||
filtered = filtered.where((p) {
|
||||
return p.name.toLowerCase().contains(lowerQuery) ||
|
||||
p.description.toLowerCase().contains(lowerQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// Get available products count
|
||||
int get availableCount => state.where((p) => p.isAvailable).length;
|
||||
|
||||
/// Get out of stock products count
|
||||
int get outOfStockCount => state.where((p) => !p.isAvailable).length;
|
||||
}
|
||||
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
@riverpod
|
||||
class SortedProducts extends _$SortedProducts {
|
||||
@override
|
||||
List<Product> build(ProductSortOption sortOption) {
|
||||
final filteredProducts = ref.watch(filteredProductsProvider);
|
||||
|
||||
return _sortProducts(filteredProducts, sortOption);
|
||||
}
|
||||
|
||||
List<Product> _sortProducts(List<Product> products, ProductSortOption option) {
|
||||
final sorted = List<Product>.from(products);
|
||||
|
||||
switch (option) {
|
||||
case ProductSortOption.nameAsc:
|
||||
sorted.sort((a, b) => a.name.compareTo(b.name));
|
||||
break;
|
||||
case ProductSortOption.nameDesc:
|
||||
sorted.sort((a, b) => b.name.compareTo(a.name));
|
||||
break;
|
||||
case ProductSortOption.priceAsc:
|
||||
sorted.sort((a, b) => a.price.compareTo(b.price));
|
||||
break;
|
||||
case ProductSortOption.priceDesc:
|
||||
sorted.sort((a, b) => b.price.compareTo(a.price));
|
||||
break;
|
||||
case ProductSortOption.newest:
|
||||
sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
break;
|
||||
case ProductSortOption.oldest:
|
||||
sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
/// Product sort options
|
||||
enum ProductSortOption {
|
||||
nameAsc,
|
||||
nameDesc,
|
||||
priceAsc,
|
||||
priceDesc,
|
||||
newest,
|
||||
oldest,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'filtered_products_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Filtered products provider
|
||||
/// Combines products, search query, and category filter to provide filtered results
|
||||
|
||||
@ProviderFor(FilteredProducts)
|
||||
const filteredProductsProvider = FilteredProductsProvider._();
|
||||
|
||||
/// Filtered products provider
|
||||
/// Combines products, search query, and category filter to provide filtered results
|
||||
final class FilteredProductsProvider
|
||||
extends $NotifierProvider<FilteredProducts, List<Product>> {
|
||||
/// Filtered products provider
|
||||
/// Combines products, search query, and category filter to provide filtered results
|
||||
const FilteredProductsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'filteredProductsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$filteredProductsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
FilteredProducts create() => FilteredProducts();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<Product> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<Product>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$filteredProductsHash() => r'04d66ed1cb868008cf3e6aba6571f7928a48e814';
|
||||
|
||||
/// Filtered products provider
|
||||
/// Combines products, search query, and category filter to provide filtered results
|
||||
|
||||
abstract class _$FilteredProducts extends $Notifier<List<Product>> {
|
||||
List<Product> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<List<Product>, List<Product>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<List<Product>, List<Product>>,
|
||||
List<Product>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
|
||||
@ProviderFor(SortedProducts)
|
||||
const sortedProductsProvider = SortedProductsFamily._();
|
||||
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
final class SortedProductsProvider
|
||||
extends $NotifierProvider<SortedProducts, List<Product>> {
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
const SortedProductsProvider._({
|
||||
required SortedProductsFamily super.from,
|
||||
required ProductSortOption super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'sortedProductsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sortedProductsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'sortedProductsProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SortedProducts create() => SortedProducts();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<Product> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<Product>>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SortedProductsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$sortedProductsHash() => r'653f1e9af8c188631dadbfe9ed7d944c6876d2d3';
|
||||
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
|
||||
final class SortedProductsFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
SortedProducts,
|
||||
List<Product>,
|
||||
List<Product>,
|
||||
List<Product>,
|
||||
ProductSortOption
|
||||
> {
|
||||
const SortedProductsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'sortedProductsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
|
||||
SortedProductsProvider call(ProductSortOption sortOption) =>
|
||||
SortedProductsProvider._(argument: sortOption, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'sortedProductsProvider';
|
||||
}
|
||||
|
||||
/// Provider for sorted products
|
||||
/// Adds sorting capability on top of filtered products
|
||||
|
||||
abstract class _$SortedProducts extends $Notifier<List<Product>> {
|
||||
late final _$args = ref.$arg as ProductSortOption;
|
||||
ProductSortOption get sortOption => _$args;
|
||||
|
||||
List<Product> build(ProductSortOption sortOption);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build(_$args);
|
||||
final ref = this.ref as $Ref<List<Product>, List<Product>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<List<Product>, List<Product>>,
|
||||
List<Product>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
|
||||
part 'products_provider.g.dart';
|
||||
|
||||
/// Provider for products list
|
||||
@riverpod
|
||||
class Products extends _$Products {
|
||||
@override
|
||||
Future<List<Product>> build() async {
|
||||
// TODO: Implement with repository
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
// TODO: Implement refresh logic
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Fetch products from repository
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncProducts() async {
|
||||
// TODO: Implement sync logic with remote data source
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Sync products from API
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for search query
|
||||
@riverpod
|
||||
class SearchQuery extends _$SearchQuery {
|
||||
@override
|
||||
String build() => '';
|
||||
|
||||
void setQuery(String query) {
|
||||
state = query;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for filtered products
|
||||
@riverpod
|
||||
List<Product> filteredProducts(Ref ref) {
|
||||
final products = ref.watch(productsProvider).value ?? [];
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
|
||||
if (query.isEmpty) return products;
|
||||
|
||||
return products.where((p) =>
|
||||
p.name.toLowerCase().contains(query.toLowerCase()) ||
|
||||
p.description.toLowerCase().contains(query.toLowerCase())
|
||||
).toList();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'products_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for products list
|
||||
|
||||
@ProviderFor(Products)
|
||||
const productsProvider = ProductsProvider._();
|
||||
|
||||
/// Provider for products list
|
||||
final class ProductsProvider
|
||||
extends $AsyncNotifierProvider<Products, List<Product>> {
|
||||
/// Provider for products list
|
||||
const ProductsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Products create() => Products();
|
||||
}
|
||||
|
||||
String _$productsHash() => r'9e1d3aaa1d9cf0b4ff03fdfaf4512a7a15336d51';
|
||||
|
||||
/// Provider for products list
|
||||
|
||||
abstract class _$Products extends $AsyncNotifier<List<Product>> {
|
||||
FutureOr<List<Product>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<List<Product>>, List<Product>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
|
||||
AsyncValue<List<Product>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for search query
|
||||
|
||||
@ProviderFor(SearchQuery)
|
||||
const searchQueryProvider = SearchQueryProvider._();
|
||||
|
||||
/// Provider for search query
|
||||
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
|
||||
/// Provider for search query
|
||||
const SearchQueryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'searchQueryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$searchQueryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SearchQuery create() => SearchQuery();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchQueryHash() => r'2c146927785523a0ddf51b23b777a9be4afdc092';
|
||||
|
||||
/// Provider for search query
|
||||
|
||||
abstract class _$SearchQuery 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for filtered products
|
||||
|
||||
@ProviderFor(filteredProducts)
|
||||
const filteredProductsProvider = FilteredProductsProvider._();
|
||||
|
||||
/// Provider for filtered products
|
||||
|
||||
final class FilteredProductsProvider
|
||||
extends $FunctionalProvider<List<Product>, List<Product>, List<Product>>
|
||||
with $Provider<List<Product>> {
|
||||
/// Provider for filtered products
|
||||
const FilteredProductsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'filteredProductsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$filteredProductsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<List<Product>> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
List<Product> create(Ref ref) {
|
||||
return filteredProducts(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<Product> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<Product>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$filteredProductsHash() => r'e4e0c549c454576fc599713a5237435a8dd4b277';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'search_query_provider.g.dart';
|
||||
|
||||
/// Search query state provider
|
||||
/// Manages the current search query string for product filtering
|
||||
@riverpod
|
||||
class SearchQuery extends _$SearchQuery {
|
||||
@override
|
||||
String build() {
|
||||
// Initialize with empty search query
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Update search query
|
||||
void setQuery(String query) {
|
||||
state = query.trim();
|
||||
}
|
||||
|
||||
/// Clear search query
|
||||
void clear() {
|
||||
state = '';
|
||||
}
|
||||
|
||||
/// Check if search is active
|
||||
bool get isSearching => state.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_query_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Search query state provider
|
||||
/// Manages the current search query string for product filtering
|
||||
|
||||
@ProviderFor(SearchQuery)
|
||||
const searchQueryProvider = SearchQueryProvider._();
|
||||
|
||||
/// Search query state provider
|
||||
/// Manages the current search query string for product filtering
|
||||
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
|
||||
/// Search query state provider
|
||||
/// Manages the current search query string for product filtering
|
||||
const SearchQueryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'searchQueryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$searchQueryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SearchQuery create() => SearchQuery();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchQueryHash() => r'62191c640ca9424065338a26c1af5c4695a46ef5';
|
||||
|
||||
/// Search query state provider
|
||||
/// Manages the current search query string for product filtering
|
||||
|
||||
abstract class _$SearchQuery 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'selected_category_provider.g.dart';
|
||||
|
||||
/// Selected category state provider
|
||||
/// Manages the currently selected category for product filtering
|
||||
@riverpod
|
||||
class SelectedCategory extends _$SelectedCategory {
|
||||
@override
|
||||
String? build() {
|
||||
// Initialize with no category selected (show all products)
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Select a category
|
||||
void selectCategory(String? categoryId) {
|
||||
state = categoryId;
|
||||
}
|
||||
|
||||
/// Clear category selection (show all products)
|
||||
void clearSelection() {
|
||||
state = null;
|
||||
}
|
||||
|
||||
/// Check if a category is selected
|
||||
bool get hasSelection => state != null;
|
||||
|
||||
/// Check if specific category is selected
|
||||
bool isSelected(String categoryId) => state == categoryId;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'selected_category_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Selected category state provider
|
||||
/// Manages the currently selected category for product filtering
|
||||
|
||||
@ProviderFor(SelectedCategory)
|
||||
const selectedCategoryProvider = SelectedCategoryProvider._();
|
||||
|
||||
/// Selected category state provider
|
||||
/// Manages the currently selected category for product filtering
|
||||
final class SelectedCategoryProvider
|
||||
extends $NotifierProvider<SelectedCategory, String?> {
|
||||
/// Selected category state provider
|
||||
/// Manages the currently selected category for product filtering
|
||||
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'73e33604e69d2e9f9127f21e6784c5fe8ddf4869';
|
||||
|
||||
/// Selected category state provider
|
||||
/// Manages the currently selected category for product filtering
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
81
lib/features/products/presentation/widgets/product_card.dart
Normal file
81
lib/features/products/presentation/widgets/product_card.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../../../shared/widgets/price_display.dart';
|
||||
|
||||
/// Product card widget
|
||||
class ProductCard extends StatelessWidget {
|
||||
final Product product;
|
||||
|
||||
const ProductCard({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Navigate to product details or add to cart
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: product.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: product.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
PriceDisplay(price: product.price),
|
||||
if (product.stockQuantity < 5)
|
||||
Text(
|
||||
'Low stock: ${product.stockQuantity}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/features/products/presentation/widgets/product_grid.dart
Normal file
86
lib/features/products/presentation/widgets/product_grid.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/filtered_products_provider.dart';
|
||||
import 'product_card.dart';
|
||||
import '../../../../core/widgets/empty_state.dart';
|
||||
|
||||
/// Product grid widget
|
||||
class ProductGrid extends ConsumerWidget {
|
||||
final ProductSortOption sortOption;
|
||||
|
||||
const ProductGrid({
|
||||
super.key,
|
||||
this.sortOption = ProductSortOption.nameAsc,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filteredProducts = ref.watch(filteredProductsProvider);
|
||||
|
||||
// Apply sorting
|
||||
final sortedProducts = _sortProducts(filteredProducts, sortOption);
|
||||
|
||||
if (sortedProducts.isEmpty) {
|
||||
return const EmptyState(
|
||||
message: 'No products found',
|
||||
subMessage: 'Try adjusting your filters',
|
||||
icon: Icons.inventory_2_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: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: sortedProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RepaintBoundary(
|
||||
child: ProductCard(product: sortedProducts[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<dynamic> _sortProducts(List<dynamic> products, ProductSortOption option) {
|
||||
final sorted = List.from(products);
|
||||
|
||||
switch (option) {
|
||||
case ProductSortOption.nameAsc:
|
||||
sorted.sort((a, b) => a.name.compareTo(b.name));
|
||||
break;
|
||||
case ProductSortOption.nameDesc:
|
||||
sorted.sort((a, b) => b.name.compareTo(a.name));
|
||||
break;
|
||||
case ProductSortOption.priceAsc:
|
||||
sorted.sort((a, b) => a.price.compareTo(b.price));
|
||||
break;
|
||||
case ProductSortOption.priceDesc:
|
||||
sorted.sort((a, b) => b.price.compareTo(a.price));
|
||||
break;
|
||||
case ProductSortOption.newest:
|
||||
sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
break;
|
||||
case ProductSortOption.oldest:
|
||||
sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
|
||||
/// Product search bar widget
|
||||
class ProductSearchBar extends ConsumerStatefulWidget {
|
||||
const ProductSearchBar({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProductSearchBar> createState() => _ProductSearchBarState();
|
||||
}
|
||||
|
||||
class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search products...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
ref.read(searchQueryProvider.notifier).setQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(searchQueryProvider.notifier).setQuery(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
6
lib/features/products/presentation/widgets/widgets.dart
Normal file
6
lib/features/products/presentation/widgets/widgets.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
// Product Feature Widgets
|
||||
export 'product_card.dart';
|
||||
export 'product_grid.dart';
|
||||
export 'product_search_bar.dart';
|
||||
|
||||
// This file provides a central export point for all product widgets
|
||||
Reference in New Issue
Block a user