Merge branch 'main' of https://git.renolation.com/renolation/retail
# Conflicts: # docs/API_RESPONSE_FIX.md # docs/AUTH_UI_SUMMARY.md # docs/AUTO_LOGIN_DEBUG.md # docs/AUTO_LOGIN_FIXED.md # docs/BUILD_STATUS.md # docs/CLEANUP_COMPLETE.md # docs/EXPORT_FILES_SUMMARY.md # docs/RIVERPOD_DI_MIGRATION.md # docs/TEST_AUTO_LOGIN.md # lib/features/categories/data/datasources/category_remote_datasource.dart # lib/features/categories/presentation/providers/categories_provider.dart # lib/features/categories/presentation/providers/categories_provider.g.dart # lib/features/products/data/datasources/product_remote_datasource.dart # lib/features/products/data/models/product_model.dart # lib/features/products/presentation/pages/products_page.dart # lib/features/products/presentation/providers/products_provider.dart # lib/features/products/presentation/providers/products_provider.g.dart
This commit is contained in:
@@ -1,25 +1,12 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../models/category_model.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/network/api_response.dart';
|
||||
import '../../../../core/constants/api_constants.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
|
||||
/// Category remote data source using API
|
||||
abstract class CategoryRemoteDataSource {
|
||||
/// Get all categories (public endpoint - no auth required)
|
||||
Future<List<CategoryModel>> getAllCategories();
|
||||
|
||||
/// Get single category by ID (public endpoint - no auth required)
|
||||
Future<CategoryModel> getCategoryById(String id);
|
||||
|
||||
/// Get category with its products with pagination (public endpoint)
|
||||
/// Returns Map with 'category' and 'products' with pagination info
|
||||
Future<Map<String, dynamic>> getCategoryWithProducts(
|
||||
String id,
|
||||
int page,
|
||||
int limit,
|
||||
);
|
||||
}
|
||||
|
||||
class CategoryRemoteDataSourceImpl implements CategoryRemoteDataSource {
|
||||
@@ -32,24 +19,15 @@ class CategoryRemoteDataSourceImpl implements CategoryRemoteDataSource {
|
||||
try {
|
||||
final response = await client.get(ApiConstants.categories);
|
||||
|
||||
// Parse API response using ApiResponse model
|
||||
final apiResponse = ApiResponse<List<CategoryModel>>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => (data as List<dynamic>)
|
||||
.map((json) => CategoryModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to fetch categories',
|
||||
);
|
||||
// API returns: { success: true, data: [...categories...] }
|
||||
if (response.data['success'] == true) {
|
||||
final List<dynamic> data = response.data['data'] ?? [];
|
||||
return data.map((json) => CategoryModel.fromJson(json)).toList();
|
||||
} else {
|
||||
throw ServerException(response.data['message'] ?? 'Failed to fetch categories');
|
||||
}
|
||||
|
||||
return apiResponse.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
if (e is ServerException) rethrow;
|
||||
throw ServerException('Failed to fetch categories: $e');
|
||||
}
|
||||
}
|
||||
@@ -59,108 +37,15 @@ class CategoryRemoteDataSourceImpl implements CategoryRemoteDataSource {
|
||||
try {
|
||||
final response = await client.get(ApiConstants.categoryById(id));
|
||||
|
||||
// Parse API response using ApiResponse model
|
||||
final apiResponse = ApiResponse<CategoryModel>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => CategoryModel.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to fetch category',
|
||||
);
|
||||
// API returns: { success: true, data: {...category...} }
|
||||
if (response.data['success'] == true) {
|
||||
return CategoryModel.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ServerException(response.data['message'] ?? 'Category not found');
|
||||
}
|
||||
|
||||
return apiResponse.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
if (e is ServerException) rethrow;
|
||||
throw ServerException('Failed to fetch category: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getCategoryWithProducts(
|
||||
String id,
|
||||
int page,
|
||||
int limit,
|
||||
) async {
|
||||
try {
|
||||
final response = await client.get(
|
||||
'${ApiConstants.categories}/$id/products',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
// Parse API response - data contains category with nested products
|
||||
final apiResponse = ApiResponse<Map<String, dynamic>>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to fetch category with products',
|
||||
);
|
||||
}
|
||||
|
||||
final responseData = apiResponse.data;
|
||||
|
||||
// Extract category info (excluding products array)
|
||||
final categoryData = Map<String, dynamic>.from(responseData);
|
||||
final products = categoryData.remove('products') as List<dynamic>? ?? [];
|
||||
|
||||
// Create category model from remaining data
|
||||
final category = CategoryModel.fromJson(categoryData);
|
||||
|
||||
return {
|
||||
'category': category,
|
||||
'products': products,
|
||||
'meta': apiResponse.meta?.toJson() ?? {},
|
||||
};
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
throw ServerException('Failed to fetch category with products: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Dio errors and convert to custom exceptions
|
||||
Exception _handleDioError(DioException error) {
|
||||
switch (error.response?.statusCode) {
|
||||
case ApiConstants.statusBadRequest:
|
||||
return ValidationException(
|
||||
error.response?.data['message'] ?? 'Invalid request',
|
||||
);
|
||||
case ApiConstants.statusUnauthorized:
|
||||
return UnauthorizedException(
|
||||
error.response?.data['message'] ?? 'Unauthorized access',
|
||||
);
|
||||
case ApiConstants.statusForbidden:
|
||||
return UnauthorizedException(
|
||||
error.response?.data['message'] ?? 'Access forbidden',
|
||||
);
|
||||
case ApiConstants.statusNotFound:
|
||||
return NotFoundException(
|
||||
error.response?.data['message'] ?? 'Category not found',
|
||||
);
|
||||
case ApiConstants.statusInternalServerError:
|
||||
case ApiConstants.statusBadGateway:
|
||||
case ApiConstants.statusServiceUnavailable:
|
||||
return ServerException(
|
||||
error.response?.data['message'] ?? 'Server error',
|
||||
);
|
||||
default:
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return NetworkException('Connection timeout');
|
||||
} else if (error.type == DioExceptionType.connectionError) {
|
||||
return NetworkException('No internet connection');
|
||||
}
|
||||
return ServerException('Unexpected error occurred');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../datasources/category_local_datasource.dart';
|
||||
import '../datasources/category_remote_datasource.dart';
|
||||
import '../repositories/category_repository_impl.dart';
|
||||
import '../models/category_model.dart';
|
||||
import '../../domain/repositories/category_repository.dart';
|
||||
import '../../../../core/providers/providers.dart';
|
||||
import '../../../../core/constants/storage_constants.dart';
|
||||
|
||||
part 'category_providers.g.dart';
|
||||
|
||||
/// Provider for category Hive box
|
||||
@riverpod
|
||||
Box<CategoryModel> categoryBox(Ref ref) {
|
||||
return Hive.box<CategoryModel>(StorageConstants.categoriesBox);
|
||||
}
|
||||
|
||||
/// Provider for category local data source
|
||||
@riverpod
|
||||
CategoryLocalDataSource categoryLocalDataSource(Ref ref) {
|
||||
final box = ref.watch(categoryBoxProvider);
|
||||
return CategoryLocalDataSourceImpl(box);
|
||||
}
|
||||
|
||||
/// Provider for category remote data source
|
||||
@riverpod
|
||||
CategoryRemoteDataSource categoryRemoteDataSource(Ref ref) {
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
return CategoryRemoteDataSourceImpl(dioClient);
|
||||
}
|
||||
|
||||
/// Provider for category repository
|
||||
@riverpod
|
||||
CategoryRepository categoryRepository(Ref ref) {
|
||||
final localDataSource = ref.watch(categoryLocalDataSourceProvider);
|
||||
final remoteDataSource = ref.watch(categoryRemoteDataSourceProvider);
|
||||
|
||||
return CategoryRepositoryImpl(
|
||||
localDataSource: localDataSource,
|
||||
remoteDataSource: remoteDataSource,
|
||||
);
|
||||
}
|
||||
220
lib/features/categories/data/providers/category_providers.g.dart
Normal file
220
lib/features/categories/data/providers/category_providers.g.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'category_providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for category Hive box
|
||||
|
||||
@ProviderFor(categoryBox)
|
||||
const categoryBoxProvider = CategoryBoxProvider._();
|
||||
|
||||
/// Provider for category Hive box
|
||||
|
||||
final class CategoryBoxProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
Box<CategoryModel>,
|
||||
Box<CategoryModel>,
|
||||
Box<CategoryModel>
|
||||
>
|
||||
with $Provider<Box<CategoryModel>> {
|
||||
/// Provider for category Hive box
|
||||
const CategoryBoxProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'categoryBoxProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$categoryBoxHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Box<CategoryModel>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Box<CategoryModel> create(Ref ref) {
|
||||
return categoryBox(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Box<CategoryModel> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Box<CategoryModel>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoryBoxHash() => r'cbcd3cf6f0673b13a5e0af6dba10ca10f32be70c';
|
||||
|
||||
/// Provider for category local data source
|
||||
|
||||
@ProviderFor(categoryLocalDataSource)
|
||||
const categoryLocalDataSourceProvider = CategoryLocalDataSourceProvider._();
|
||||
|
||||
/// Provider for category local data source
|
||||
|
||||
final class CategoryLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
CategoryLocalDataSource,
|
||||
CategoryLocalDataSource,
|
||||
CategoryLocalDataSource
|
||||
>
|
||||
with $Provider<CategoryLocalDataSource> {
|
||||
/// Provider for category local data source
|
||||
const CategoryLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'categoryLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
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'8d42c0dcfb986dfa0413e4267c4b08f24963ef50';
|
||||
|
||||
/// Provider for category remote data source
|
||||
|
||||
@ProviderFor(categoryRemoteDataSource)
|
||||
const categoryRemoteDataSourceProvider = CategoryRemoteDataSourceProvider._();
|
||||
|
||||
/// Provider for category remote data source
|
||||
|
||||
final class CategoryRemoteDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
CategoryRemoteDataSource,
|
||||
CategoryRemoteDataSource,
|
||||
CategoryRemoteDataSource
|
||||
>
|
||||
with $Provider<CategoryRemoteDataSource> {
|
||||
/// Provider for category remote data source
|
||||
const CategoryRemoteDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'categoryRemoteDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$categoryRemoteDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<CategoryRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
CategoryRemoteDataSource create(Ref ref) {
|
||||
return categoryRemoteDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(CategoryRemoteDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<CategoryRemoteDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoryRemoteDataSourceHash() =>
|
||||
r'60294160d6655f1455064fb01016d341570e9a5d';
|
||||
|
||||
/// Provider for category repository
|
||||
|
||||
@ProviderFor(categoryRepository)
|
||||
const categoryRepositoryProvider = CategoryRepositoryProvider._();
|
||||
|
||||
/// Provider for category repository
|
||||
|
||||
final class CategoryRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
CategoryRepository,
|
||||
CategoryRepository,
|
||||
CategoryRepository
|
||||
>
|
||||
with $Provider<CategoryRepository> {
|
||||
/// Provider for category repository
|
||||
const CategoryRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'categoryRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$categoryRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<CategoryRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
CategoryRepository create(Ref ref) {
|
||||
return categoryRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(CategoryRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<CategoryRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoryRepositoryHash() =>
|
||||
r'256a9f2aa52a1858bbb50a87f2f838c33552ef22';
|
||||
@@ -2,14 +2,17 @@ import 'package:dartz/dartz.dart';
|
||||
import '../../domain/entities/category.dart';
|
||||
import '../../domain/repositories/category_repository.dart';
|
||||
import '../datasources/category_local_datasource.dart';
|
||||
import '../datasources/category_remote_datasource.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
|
||||
class CategoryRepositoryImpl implements CategoryRepository {
|
||||
final CategoryLocalDataSource localDataSource;
|
||||
final CategoryRemoteDataSource remoteDataSource;
|
||||
|
||||
CategoryRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
required this.remoteDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -38,12 +41,13 @@ class CategoryRepositoryImpl implements CategoryRepository {
|
||||
@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();
|
||||
final categories = await remoteDataSource.getAllCategories();
|
||||
await localDataSource.cacheCategories(categories);
|
||||
return Right(categories.map((model) => model.toEntity()).toList());
|
||||
} on CacheException catch (e) {
|
||||
return Left(CacheFailure(e.message));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/entities/category.dart';
|
||||
import '../../../products/presentation/providers/products_provider.dart';
|
||||
import '../../../products/presentation/widgets/product_card.dart';
|
||||
import '../../../products/presentation/widgets/product_list_item.dart';
|
||||
|
||||
/// View mode for products display
|
||||
enum ViewMode { grid, list }
|
||||
|
||||
/// Category detail page showing products in the category
|
||||
class CategoryDetailPage extends ConsumerStatefulWidget {
|
||||
final Category category;
|
||||
|
||||
const CategoryDetailPage({
|
||||
super.key,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<CategoryDetailPage> createState() => _CategoryDetailPageState();
|
||||
}
|
||||
|
||||
class _CategoryDetailPageState extends ConsumerState<CategoryDetailPage> {
|
||||
ViewMode _viewMode = ViewMode.grid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.category.name),
|
||||
actions: [
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == ViewMode.grid
|
||||
? Icons.view_list_rounded
|
||||
: Icons.grid_view_rounded,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_viewMode =
|
||||
_viewMode == ViewMode.grid ? ViewMode.list : ViewMode.grid;
|
||||
});
|
||||
},
|
||||
tooltip: _viewMode == ViewMode.grid
|
||||
? 'Switch to list view'
|
||||
: 'Switch to grid view',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: productsAsync.when(
|
||||
data: (products) {
|
||||
// Filter products by category
|
||||
final categoryProducts = products
|
||||
.where((product) => product.categoryId == widget.category.id)
|
||||
.toList();
|
||||
|
||||
if (categoryProducts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No products in this category',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Products will appear here once added',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(productsProvider.notifier).syncProducts();
|
||||
},
|
||||
child: _viewMode == ViewMode.grid
|
||||
? _buildGridView(categoryProducts)
|
||||
: _buildListView(categoryProducts),
|
||||
);
|
||||
},
|
||||
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 products',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
ref.invalidate(productsProvider);
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build grid view
|
||||
Widget _buildGridView(List products) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ProductCard(product: products[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build list view
|
||||
Widget _buildListView(List products) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ProductListItem(
|
||||
product: products[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,193 +1,101 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../domain/entities/category.dart';
|
||||
import '../../data/models/category_model.dart';
|
||||
import '../../../products/data/models/product_model.dart';
|
||||
import '../../../products/domain/entities/product.dart';
|
||||
import 'category_remote_datasource_provider.dart';
|
||||
import '../../data/providers/category_providers.dart';
|
||||
import '../../../../core/providers/providers.dart';
|
||||
|
||||
part 'categories_provider.g.dart';
|
||||
|
||||
/// Provider for categories list
|
||||
/// Provider for categories list with API-first approach
|
||||
@riverpod
|
||||
class Categories extends _$Categories {
|
||||
@override
|
||||
Future<List<Category>> build() async {
|
||||
return await _fetchCategories();
|
||||
}
|
||||
// API-first: Try to load from API first
|
||||
final repository = ref.watch(categoryRepositoryProvider);
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
|
||||
Future<List<Category>> _fetchCategories() async {
|
||||
final datasource = ref.read(categoryRemoteDataSourceProvider);
|
||||
final categoryModels = await datasource.getAllCategories();
|
||||
return categoryModels.map((model) => model.toEntity()).toList();
|
||||
}
|
||||
// Check if online
|
||||
final isConnected = await networkInfo.isConnected;
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchCategories();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for single category by ID
|
||||
@riverpod
|
||||
Future<Category> category(Ref ref, String id) async {
|
||||
final datasource = ref.read(categoryRemoteDataSourceProvider);
|
||||
final categoryModel = await datasource.getCategoryById(id);
|
||||
return categoryModel.toEntity();
|
||||
}
|
||||
|
||||
/// Pagination state for category products
|
||||
class CategoryProductsState {
|
||||
final Category category;
|
||||
final List<Product> products;
|
||||
final int currentPage;
|
||||
final int totalPages;
|
||||
final int totalItems;
|
||||
final bool hasMore;
|
||||
final bool isLoadingMore;
|
||||
|
||||
const CategoryProductsState({
|
||||
required this.category,
|
||||
required this.products,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalItems,
|
||||
required this.hasMore,
|
||||
this.isLoadingMore = false,
|
||||
});
|
||||
|
||||
CategoryProductsState copyWith({
|
||||
Category? category,
|
||||
List<Product>? products,
|
||||
int? currentPage,
|
||||
int? totalPages,
|
||||
int? totalItems,
|
||||
bool? hasMore,
|
||||
bool? isLoadingMore,
|
||||
}) {
|
||||
return CategoryProductsState(
|
||||
category: category ?? this.category,
|
||||
products: products ?? this.products,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
totalItems: totalItems ?? this.totalItems,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for category with its products (with pagination)
|
||||
@riverpod
|
||||
class CategoryWithProducts extends _$CategoryWithProducts {
|
||||
static const int _limit = 20;
|
||||
|
||||
@override
|
||||
Future<CategoryProductsState> build(String categoryId) async {
|
||||
return await _fetchCategoryWithProducts(categoryId: categoryId, page: 1);
|
||||
}
|
||||
|
||||
Future<CategoryProductsState> _fetchCategoryWithProducts({
|
||||
required String categoryId,
|
||||
required int page,
|
||||
}) async {
|
||||
final datasource = ref.read(categoryRemoteDataSourceProvider);
|
||||
|
||||
final response = await datasource.getCategoryWithProducts(
|
||||
categoryId,
|
||||
page,
|
||||
_limit,
|
||||
);
|
||||
|
||||
// Extract data
|
||||
final CategoryModel categoryModel = response['category'] as CategoryModel;
|
||||
final List<dynamic> productsJson = response['products'] as List<dynamic>;
|
||||
final meta = response['meta'] as Map<String, dynamic>;
|
||||
|
||||
// Convert category to entity
|
||||
final category = categoryModel.toEntity();
|
||||
|
||||
// Convert products to entities
|
||||
final products = productsJson
|
||||
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
|
||||
.map((model) => model.toEntity())
|
||||
.toList();
|
||||
|
||||
// Extract pagination info
|
||||
final currentPage = meta['currentPage'] as int? ?? page;
|
||||
final totalPages = meta['totalPages'] as int? ?? 1;
|
||||
final totalItems = meta['totalItems'] as int? ?? products.length;
|
||||
final hasMore = currentPage < totalPages;
|
||||
|
||||
return CategoryProductsState(
|
||||
category: category,
|
||||
products: products,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: totalItems,
|
||||
hasMore: hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Load more products (next page)
|
||||
Future<void> loadMore() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null || !currentState.hasMore) return;
|
||||
|
||||
// Set loading more flag
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: true),
|
||||
);
|
||||
|
||||
// Fetch next page
|
||||
final nextPage = currentState.currentPage + 1;
|
||||
|
||||
try {
|
||||
final newState = await _fetchCategoryWithProducts(
|
||||
categoryId: currentState.category.id,
|
||||
page: nextPage,
|
||||
);
|
||||
|
||||
// Append new products to existing ones
|
||||
state = AsyncValue.data(
|
||||
newState.copyWith(
|
||||
products: [...currentState.products, ...newState.products],
|
||||
isLoadingMore: false,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
// Restore previous state on error
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: false),
|
||||
);
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
if (isConnected) {
|
||||
// Try API first
|
||||
try {
|
||||
final syncResult = await repository.syncCategories();
|
||||
return syncResult.fold(
|
||||
(failure) {
|
||||
// API failed, fallback to cache
|
||||
print('Categories API failed, falling back to cache: ${failure.message}');
|
||||
return _loadFromCache();
|
||||
},
|
||||
(categories) => categories,
|
||||
);
|
||||
} catch (e) {
|
||||
// API error, fallback to cache
|
||||
print('Categories API error, falling back to cache: $e');
|
||||
return _loadFromCache();
|
||||
}
|
||||
} else {
|
||||
// Offline, load from cache
|
||||
print('Offline, loading categories from cache');
|
||||
return _loadFromCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh category and products
|
||||
/// Load categories from local cache
|
||||
Future<List<Category>> _loadFromCache() async {
|
||||
final repository = ref.read(categoryRepositoryProvider);
|
||||
final result = await repository.getAllCategories();
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
print('Categories cache load failed: ${failure.message}');
|
||||
return <Category>[];
|
||||
},
|
||||
(categories) => categories,
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh categories from local storage
|
||||
Future<void> refresh() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null) return;
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(categoryRepositoryProvider);
|
||||
final result = await repository.getAllCategories();
|
||||
|
||||
return result.fold(
|
||||
(failure) => throw Exception(failure.message),
|
||||
(categories) => categories,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sync categories from API and update local storage
|
||||
Future<void> syncCategories() async {
|
||||
final networkInfo = ref.read(networkInfoProvider);
|
||||
final isConnected = await networkInfo.isConnected;
|
||||
|
||||
if (!isConnected) {
|
||||
throw Exception('No internet connection');
|
||||
}
|
||||
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchCategoryWithProducts(
|
||||
categoryId: currentState.category.id,
|
||||
page: 1,
|
||||
final repository = ref.read(categoryRepositoryProvider);
|
||||
final result = await repository.syncCategories();
|
||||
|
||||
return result.fold(
|
||||
(failure) => throw Exception(failure.message),
|
||||
(categories) => categories,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for selected category state
|
||||
/// This is used in the products feature for filtering
|
||||
/// Provider for selected category
|
||||
@riverpod
|
||||
class SelectedCategoryInCategories extends _$SelectedCategoryInCategories {
|
||||
class SelectedCategory extends _$SelectedCategory {
|
||||
@override
|
||||
String? build() {
|
||||
return null;
|
||||
}
|
||||
String? build() => null;
|
||||
|
||||
void select(String? categoryId) {
|
||||
state = categoryId;
|
||||
@@ -196,8 +104,4 @@ class SelectedCategoryInCategories extends _$SelectedCategoryInCategories {
|
||||
void clear() {
|
||||
state = null;
|
||||
}
|
||||
|
||||
bool get hasSelection => state != null;
|
||||
|
||||
bool isSelected(String categoryId) => state == categoryId;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ part of 'categories_provider.dart';
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for categories list
|
||||
/// Provider for categories list with API-first approach
|
||||
|
||||
@ProviderFor(Categories)
|
||||
const categoriesProvider = CategoriesProvider._();
|
||||
|
||||
/// Provider for categories list
|
||||
/// Provider for categories list with API-first approach
|
||||
final class CategoriesProvider
|
||||
extends $AsyncNotifierProvider<Categories, List<Category>> {
|
||||
/// Provider for categories list
|
||||
/// Provider for categories list with API-first approach
|
||||
const CategoriesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -36,9 +36,9 @@ final class CategoriesProvider
|
||||
Categories create() => Categories();
|
||||
}
|
||||
|
||||
String _$categoriesHash() => r'5156d31a6d7b9457c4735b66e170b262140758e2';
|
||||
String _$categoriesHash() => r'33c33b08f8926e5bbbd112285591c74a3ff0f61c';
|
||||
|
||||
/// Provider for categories list
|
||||
/// Provider for categories list with API-first approach
|
||||
|
||||
abstract class _$Categories extends $AsyncNotifier<List<Category>> {
|
||||
FutureOr<List<Category>> build();
|
||||
@@ -59,223 +59,32 @@ abstract class _$Categories extends $AsyncNotifier<List<Category>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for single category by ID
|
||||
/// Provider for selected category
|
||||
|
||||
@ProviderFor(category)
|
||||
const categoryProvider = CategoryFamily._();
|
||||
@ProviderFor(SelectedCategory)
|
||||
const selectedCategoryProvider = SelectedCategoryProvider._();
|
||||
|
||||
/// Provider for single category by ID
|
||||
|
||||
final class CategoryProvider
|
||||
extends
|
||||
$FunctionalProvider<AsyncValue<Category>, Category, FutureOr<Category>>
|
||||
with $FutureModifier<Category>, $FutureProvider<Category> {
|
||||
/// Provider for single category by ID
|
||||
const CategoryProvider._({
|
||||
required CategoryFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'categoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$categoryHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'categoryProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Category> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Category> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return category(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CategoryProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoryHash() => r'e26dd362e42a1217a774072f453a64c7a6195e73';
|
||||
|
||||
/// Provider for single category by ID
|
||||
|
||||
final class CategoryFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<Category>, String> {
|
||||
const CategoryFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'categoryProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for single category by ID
|
||||
|
||||
CategoryProvider call(String id) =>
|
||||
CategoryProvider._(argument: id, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'categoryProvider';
|
||||
}
|
||||
|
||||
/// Provider for category with its products (with pagination)
|
||||
|
||||
@ProviderFor(CategoryWithProducts)
|
||||
const categoryWithProductsProvider = CategoryWithProductsFamily._();
|
||||
|
||||
/// Provider for category with its products (with pagination)
|
||||
final class CategoryWithProductsProvider
|
||||
extends
|
||||
$AsyncNotifierProvider<CategoryWithProducts, CategoryProductsState> {
|
||||
/// Provider for category with its products (with pagination)
|
||||
const CategoryWithProductsProvider._({
|
||||
required CategoryWithProductsFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'categoryWithProductsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$categoryWithProductsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'categoryWithProductsProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
CategoryWithProducts create() => CategoryWithProducts();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CategoryWithProductsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoryWithProductsHash() =>
|
||||
r'a5ea35fad4e711ea855e4874f9135145d7d44b67';
|
||||
|
||||
/// Provider for category with its products (with pagination)
|
||||
|
||||
final class CategoryWithProductsFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
CategoryWithProducts,
|
||||
AsyncValue<CategoryProductsState>,
|
||||
CategoryProductsState,
|
||||
FutureOr<CategoryProductsState>,
|
||||
String
|
||||
> {
|
||||
const CategoryWithProductsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'categoryWithProductsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for category with its products (with pagination)
|
||||
|
||||
CategoryWithProductsProvider call(String categoryId) =>
|
||||
CategoryWithProductsProvider._(argument: categoryId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'categoryWithProductsProvider';
|
||||
}
|
||||
|
||||
/// Provider for category with its products (with pagination)
|
||||
|
||||
abstract class _$CategoryWithProducts
|
||||
extends $AsyncNotifier<CategoryProductsState> {
|
||||
late final _$args = ref.$arg as String;
|
||||
String get categoryId => _$args;
|
||||
|
||||
FutureOr<CategoryProductsState> build(String categoryId);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build(_$args);
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<AsyncValue<CategoryProductsState>, CategoryProductsState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<CategoryProductsState>,
|
||||
CategoryProductsState
|
||||
>,
|
||||
AsyncValue<CategoryProductsState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for selected category state
|
||||
/// This is used in the products feature for filtering
|
||||
|
||||
@ProviderFor(SelectedCategoryInCategories)
|
||||
const selectedCategoryInCategoriesProvider =
|
||||
SelectedCategoryInCategoriesProvider._();
|
||||
|
||||
/// Provider for selected category state
|
||||
/// This is used in the products feature for filtering
|
||||
final class SelectedCategoryInCategoriesProvider
|
||||
extends $NotifierProvider<SelectedCategoryInCategories, String?> {
|
||||
/// Provider for selected category state
|
||||
/// This is used in the products feature for filtering
|
||||
const SelectedCategoryInCategoriesProvider._()
|
||||
/// 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'selectedCategoryInCategoriesProvider',
|
||||
name: r'selectedCategoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedCategoryInCategoriesHash();
|
||||
String debugGetCreateSourceHash() => _$selectedCategoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedCategoryInCategories create() => SelectedCategoryInCategories();
|
||||
SelectedCategory create() => SelectedCategory();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
@@ -286,13 +95,11 @@ final class SelectedCategoryInCategoriesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedCategoryInCategoriesHash() =>
|
||||
r'510d79a73dcfeba5efa886f5f95f7470dbd09a47';
|
||||
String _$selectedCategoryHash() => r'a47cd2de07ad285d4b73b2294ba954cb1cdd8e4c';
|
||||
|
||||
/// Provider for selected category state
|
||||
/// This is used in the products feature for filtering
|
||||
/// Provider for selected category
|
||||
|
||||
abstract class _$SelectedCategoryInCategories extends $Notifier<String?> {
|
||||
abstract class _$SelectedCategory extends $Notifier<String?> {
|
||||
String? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/entities/category.dart';
|
||||
import '../pages/category_detail_page.dart';
|
||||
|
||||
/// Category card widget
|
||||
class CategoryCard extends StatelessWidget {
|
||||
@@ -20,7 +21,13 @@ class CategoryCard extends StatelessWidget {
|
||||
color: color,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Filter products by category
|
||||
// Navigate to category detail page
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryDetailPage(category: category),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
|
||||
@@ -6,6 +6,7 @@ abstract class ProductLocalDataSource {
|
||||
Future<List<ProductModel>> getAllProducts();
|
||||
Future<ProductModel?> getProductById(String id);
|
||||
Future<void> cacheProducts(List<ProductModel> products);
|
||||
Future<void> updateProduct(ProductModel product);
|
||||
Future<void> clearProducts();
|
||||
}
|
||||
|
||||
@@ -30,6 +31,11 @@ class ProductLocalDataSourceImpl implements ProductLocalDataSource {
|
||||
await box.putAll(productMap);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateProduct(ProductModel product) async {
|
||||
await box.put(product.id, product);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearProducts() async {
|
||||
await box.clear();
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../models/product_model.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/network/api_response.dart';
|
||||
import '../../../../core/constants/api_constants.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
|
||||
/// Product remote data source using API
|
||||
abstract class ProductRemoteDataSource {
|
||||
/// Get all products with pagination and filters
|
||||
/// Returns Map with 'data' (List of ProductModel) and 'meta' (pagination info)
|
||||
Future<Map<String, dynamic>> getAllProducts({
|
||||
Future<List<ProductModel>> getAllProducts({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
double? minPrice,
|
||||
double? maxPrice,
|
||||
bool? isAvailable,
|
||||
});
|
||||
|
||||
/// Get single product by ID
|
||||
Future<ProductModel> getProductById(String id);
|
||||
|
||||
/// Search products by query with pagination
|
||||
/// Returns Map with 'data' (List of ProductModel) and 'meta' (pagination info)
|
||||
Future<Map<String, dynamic>> searchProducts(
|
||||
String query,
|
||||
int page,
|
||||
int limit,
|
||||
);
|
||||
|
||||
/// Get products by category with pagination
|
||||
/// Returns Map with 'data' (List of ProductModel) and 'meta' (pagination info)
|
||||
Future<Map<String, dynamic>> getProductsByCategory(
|
||||
String categoryId,
|
||||
int page,
|
||||
int limit,
|
||||
);
|
||||
Future<List<ProductModel>> searchProducts(String query, {int page = 1, int limit = 20});
|
||||
Future<List<ProductModel>> getProductsByCategory(String categoryId, {int page = 1, int limit = 20});
|
||||
}
|
||||
|
||||
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
@@ -45,14 +22,11 @@ class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
ProductRemoteDataSourceImpl(this.client);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getAllProducts({
|
||||
Future<List<ProductModel>> getAllProducts({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
double? minPrice,
|
||||
double? maxPrice,
|
||||
bool? isAvailable,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
@@ -60,39 +34,28 @@ class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
// Add optional filters
|
||||
if (categoryId != null) queryParams['categoryId'] = categoryId;
|
||||
if (search != null) queryParams['search'] = search;
|
||||
if (minPrice != null) queryParams['minPrice'] = minPrice;
|
||||
if (maxPrice != null) queryParams['maxPrice'] = maxPrice;
|
||||
if (isAvailable != null) queryParams['isAvailable'] = isAvailable;
|
||||
if (categoryId != null) {
|
||||
queryParams['categoryId'] = categoryId;
|
||||
}
|
||||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
queryParams['search'] = search;
|
||||
}
|
||||
|
||||
final response = await client.get(
|
||||
ApiConstants.products,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
// Parse API response using ApiResponse model
|
||||
final apiResponse = ApiResponse<List<ProductModel>>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => (data as List<dynamic>)
|
||||
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to fetch products',
|
||||
);
|
||||
// API returns: { success: true, data: [...products...], meta: {...} }
|
||||
if (response.data['success'] == true) {
|
||||
final List<dynamic> data = response.data['data'] ?? [];
|
||||
return data.map((json) => ProductModel.fromJson(json)).toList();
|
||||
} else {
|
||||
throw ServerException(response.data['message'] ?? 'Failed to fetch products');
|
||||
}
|
||||
|
||||
return {
|
||||
'data': apiResponse.data,
|
||||
'meta': apiResponse.meta?.toJson() ?? {},
|
||||
};
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
if (e is ServerException) rethrow;
|
||||
throw ServerException('Failed to fetch products: $e');
|
||||
}
|
||||
}
|
||||
@@ -102,32 +65,20 @@ class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
try {
|
||||
final response = await client.get(ApiConstants.productById(id));
|
||||
|
||||
// Parse API response using ApiResponse model
|
||||
final apiResponse = ApiResponse<ProductModel>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => ProductModel.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to fetch product',
|
||||
);
|
||||
// API returns: { success: true, data: {...product...} }
|
||||
if (response.data['success'] == true) {
|
||||
return ProductModel.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ServerException(response.data['message'] ?? 'Product not found');
|
||||
}
|
||||
|
||||
return apiResponse.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
if (e is ServerException) rethrow;
|
||||
throw ServerException('Failed to fetch product: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> searchProducts(
|
||||
String query,
|
||||
int page,
|
||||
int limit,
|
||||
) async {
|
||||
Future<List<ProductModel>> searchProducts(String query, {int page = 1, int limit = 20}) async {
|
||||
try {
|
||||
final response = await client.get(
|
||||
ApiConstants.searchProducts,
|
||||
@@ -138,37 +89,21 @@ class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
},
|
||||
);
|
||||
|
||||
// Parse API response using ApiResponse model
|
||||
final apiResponse = ApiResponse<List<ProductModel>>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => (data as List<dynamic>)
|
||||
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to search products',
|
||||
);
|
||||
// API returns: { success: true, data: [...products...], meta: {...} }
|
||||
if (response.data['success'] == true) {
|
||||
final List<dynamic> data = response.data['data'] ?? [];
|
||||
return data.map((json) => ProductModel.fromJson(json)).toList();
|
||||
} else {
|
||||
throw ServerException(response.data['message'] ?? 'Failed to search products');
|
||||
}
|
||||
|
||||
return {
|
||||
'data': apiResponse.data,
|
||||
'meta': apiResponse.meta?.toJson() ?? {},
|
||||
};
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
if (e is ServerException) rethrow;
|
||||
throw ServerException('Failed to search products: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getProductsByCategory(
|
||||
String categoryId,
|
||||
int page,
|
||||
int limit,
|
||||
) async {
|
||||
Future<List<ProductModel>> getProductsByCategory(String categoryId, {int page = 1, int limit = 20}) async {
|
||||
try {
|
||||
final response = await client.get(
|
||||
ApiConstants.productsByCategory(categoryId),
|
||||
@@ -178,65 +113,16 @@ class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
|
||||
},
|
||||
);
|
||||
|
||||
// Parse API response using ApiResponse model
|
||||
final apiResponse = ApiResponse<List<ProductModel>>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
(data) => (data as List<dynamic>)
|
||||
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw ServerException(
|
||||
apiResponse.message ?? 'Failed to fetch products by category',
|
||||
);
|
||||
// API returns: { success: true, data: [...products...], meta: {...} }
|
||||
if (response.data['success'] == true) {
|
||||
final List<dynamic> data = response.data['data'] ?? [];
|
||||
return data.map((json) => ProductModel.fromJson(json)).toList();
|
||||
} else {
|
||||
throw ServerException(response.data['message'] ?? 'Failed to fetch products by category');
|
||||
}
|
||||
|
||||
return {
|
||||
'data': apiResponse.data,
|
||||
'meta': apiResponse.meta?.toJson() ?? {},
|
||||
};
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
if (e is ServerException) rethrow;
|
||||
throw ServerException('Failed to fetch products by category: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Dio errors and convert to custom exceptions
|
||||
Exception _handleDioError(DioException error) {
|
||||
switch (error.response?.statusCode) {
|
||||
case ApiConstants.statusBadRequest:
|
||||
return ValidationException(
|
||||
error.response?.data['message'] ?? 'Invalid request',
|
||||
);
|
||||
case ApiConstants.statusUnauthorized:
|
||||
return UnauthorizedException(
|
||||
error.response?.data['message'] ?? 'Unauthorized access',
|
||||
);
|
||||
case ApiConstants.statusForbidden:
|
||||
return UnauthorizedException(
|
||||
error.response?.data['message'] ?? 'Access forbidden',
|
||||
);
|
||||
case ApiConstants.statusNotFound:
|
||||
return NotFoundException(
|
||||
error.response?.data['message'] ?? 'Product not found',
|
||||
);
|
||||
case ApiConstants.statusInternalServerError:
|
||||
case ApiConstants.statusBadGateway:
|
||||
case ApiConstants.statusServiceUnavailable:
|
||||
return ServerException(
|
||||
error.response?.data['message'] ?? 'Server error',
|
||||
);
|
||||
default:
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return NetworkException('Connection timeout');
|
||||
} else if (error.type == DioExceptionType.connectionError) {
|
||||
return NetworkException('No internet connection');
|
||||
}
|
||||
return ServerException('Unexpected error occurred');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class ProductModel extends HiveObject {
|
||||
final String name;
|
||||
|
||||
@HiveField(2)
|
||||
final String? description;
|
||||
final String description;
|
||||
|
||||
@HiveField(3)
|
||||
final double price;
|
||||
@@ -39,7 +39,7 @@ class ProductModel extends HiveObject {
|
||||
ProductModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.description,
|
||||
required this.price,
|
||||
this.imageUrl,
|
||||
required this.categoryId,
|
||||
@@ -83,17 +83,11 @@ class ProductModel extends HiveObject {
|
||||
|
||||
/// Create from JSON
|
||||
factory ProductModel.fromJson(Map<String, dynamic> json) {
|
||||
// Handle price as string or number from API
|
||||
final priceValue = json['price'];
|
||||
final price = priceValue is String
|
||||
? double.parse(priceValue)
|
||||
: (priceValue as num).toDouble();
|
||||
|
||||
return ProductModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
price: price,
|
||||
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? ?? 0,
|
||||
@@ -101,7 +95,6 @@ class ProductModel extends HiveObject {
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
// Note: Nested 'category' object is ignored as we only need categoryId
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
|
||||
43
lib/features/products/data/providers/product_providers.dart
Normal file
43
lib/features/products/data/providers/product_providers.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../datasources/product_local_datasource.dart';
|
||||
import '../datasources/product_remote_datasource.dart';
|
||||
import '../repositories/product_repository_impl.dart';
|
||||
import '../models/product_model.dart';
|
||||
import '../../domain/repositories/product_repository.dart';
|
||||
import '../../../../core/providers/providers.dart';
|
||||
import '../../../../core/constants/storage_constants.dart';
|
||||
|
||||
part 'product_providers.g.dart';
|
||||
|
||||
/// Provider for product Hive box
|
||||
@riverpod
|
||||
Box<ProductModel> productBox(Ref ref) {
|
||||
return Hive.box<ProductModel>(StorageConstants.productsBox);
|
||||
}
|
||||
|
||||
/// Provider for product local data source
|
||||
@riverpod
|
||||
ProductLocalDataSource productLocalDataSource(Ref ref) {
|
||||
final box = ref.watch(productBoxProvider);
|
||||
return ProductLocalDataSourceImpl(box);
|
||||
}
|
||||
|
||||
/// Provider for product remote data source
|
||||
@riverpod
|
||||
ProductRemoteDataSource productRemoteDataSource(Ref ref) {
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
return ProductRemoteDataSourceImpl(dioClient);
|
||||
}
|
||||
|
||||
/// Provider for product repository
|
||||
@riverpod
|
||||
ProductRepository productRepository(Ref ref) {
|
||||
final localDataSource = ref.watch(productLocalDataSourceProvider);
|
||||
final remoteDataSource = ref.watch(productRemoteDataSourceProvider);
|
||||
|
||||
return ProductRepositoryImpl(
|
||||
localDataSource: localDataSource,
|
||||
remoteDataSource: remoteDataSource,
|
||||
);
|
||||
}
|
||||
219
lib/features/products/data/providers/product_providers.g.dart
Normal file
219
lib/features/products/data/providers/product_providers.g.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'product_providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for product Hive box
|
||||
|
||||
@ProviderFor(productBox)
|
||||
const productBoxProvider = ProductBoxProvider._();
|
||||
|
||||
/// Provider for product Hive box
|
||||
|
||||
final class ProductBoxProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
Box<ProductModel>,
|
||||
Box<ProductModel>,
|
||||
Box<ProductModel>
|
||||
>
|
||||
with $Provider<Box<ProductModel>> {
|
||||
/// Provider for product Hive box
|
||||
const ProductBoxProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productBoxProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productBoxHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Box<ProductModel>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Box<ProductModel> create(Ref ref) {
|
||||
return productBox(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Box<ProductModel> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Box<ProductModel>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productBoxHash() => r'68cd21ea28cfc716f34daef17849a0393cdb2b80';
|
||||
|
||||
/// Provider for product local data source
|
||||
|
||||
@ProviderFor(productLocalDataSource)
|
||||
const productLocalDataSourceProvider = ProductLocalDataSourceProvider._();
|
||||
|
||||
/// Provider for product local data source
|
||||
|
||||
final class ProductLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ProductLocalDataSource,
|
||||
ProductLocalDataSource,
|
||||
ProductLocalDataSource
|
||||
>
|
||||
with $Provider<ProductLocalDataSource> {
|
||||
/// Provider for product local data source
|
||||
const ProductLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ProductLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ProductLocalDataSource create(Ref ref) {
|
||||
return productLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProductLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProductLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productLocalDataSourceHash() =>
|
||||
r'ef4673055777e8dc8a8419a80548b319789d99f9';
|
||||
|
||||
/// Provider for product remote data source
|
||||
|
||||
@ProviderFor(productRemoteDataSource)
|
||||
const productRemoteDataSourceProvider = ProductRemoteDataSourceProvider._();
|
||||
|
||||
/// Provider for product remote data source
|
||||
|
||||
final class ProductRemoteDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ProductRemoteDataSource,
|
||||
ProductRemoteDataSource,
|
||||
ProductRemoteDataSource
|
||||
>
|
||||
with $Provider<ProductRemoteDataSource> {
|
||||
/// Provider for product remote data source
|
||||
const ProductRemoteDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productRemoteDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productRemoteDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ProductRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ProductRemoteDataSource create(Ref ref) {
|
||||
return productRemoteDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProductRemoteDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProductRemoteDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productRemoteDataSourceHash() =>
|
||||
r'954798907bb0c9baade27b84eaba612a5dec8f68';
|
||||
|
||||
/// Provider for product repository
|
||||
|
||||
@ProviderFor(productRepository)
|
||||
const productRepositoryProvider = ProductRepositoryProvider._();
|
||||
|
||||
/// Provider for product repository
|
||||
|
||||
final class ProductRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ProductRepository,
|
||||
ProductRepository,
|
||||
ProductRepository
|
||||
>
|
||||
with $Provider<ProductRepository> {
|
||||
/// Provider for product repository
|
||||
const ProductRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ProductRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ProductRepository create(Ref ref) {
|
||||
return productRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProductRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProductRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productRepositoryHash() => r'7c5c5b274ce459add6449c29be822ea04503d3dc';
|
||||
372
lib/features/products/presentation/pages/batch_update_page.dart
Normal file
372
lib/features/products/presentation/pages/batch_update_page.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../data/models/product_model.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../../data/providers/product_providers.dart';
|
||||
|
||||
/// Batch update page for updating multiple products
|
||||
class BatchUpdatePage extends ConsumerStatefulWidget {
|
||||
final List<Product> selectedProducts;
|
||||
|
||||
const BatchUpdatePage({
|
||||
super.key,
|
||||
required this.selectedProducts,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<BatchUpdatePage> createState() => _BatchUpdatePageState();
|
||||
}
|
||||
|
||||
class _BatchUpdatePageState extends ConsumerState<BatchUpdatePage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late List<ProductUpdateData> _productsData;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize update data for each product
|
||||
_productsData = widget.selectedProducts.map((product) {
|
||||
return ProductUpdateData(
|
||||
product: product,
|
||||
priceController: TextEditingController(text: product.price.toStringAsFixed(2)),
|
||||
stock: product.stockQuantity,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (var data in _productsData) {
|
||||
data.priceController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Edit ${widget.selectedProducts.length} Products'),
|
||||
actions: [
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Product',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Text(
|
||||
'Price',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
'Stock',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Products list
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _productsData.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildProductItem(_productsData[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Action buttons
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _handleSave,
|
||||
child: const Text('Save Changes'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build product item
|
||||
Widget _buildProductItem(ProductUpdateData data) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Product info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
data.product.name,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'\$${data.product.price.toStringAsFixed(2)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// Price field
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: TextFormField(
|
||||
controller: data.priceController,
|
||||
decoration: const InputDecoration(
|
||||
prefixText: '\$',
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return '';
|
||||
final number = double.tryParse(value);
|
||||
if (number == null || number < 0) return '';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// Stock controls
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Decrease button
|
||||
InkWell(
|
||||
onTap: data.stock > 0
|
||||
? () {
|
||||
setState(() {
|
||||
data.stock--;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
Icons.remove,
|
||||
size: 18,
|
||||
color: data.stock > 0
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Stock count
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 35),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${data.stock}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Increase button
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
data.stock++;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle save
|
||||
Future<void> _handleSave() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final localDataSource = ref.read(productLocalDataSourceProvider);
|
||||
|
||||
// Update each product
|
||||
for (var data in _productsData) {
|
||||
final newPrice = double.parse(data.priceController.text);
|
||||
final newStock = data.stock;
|
||||
|
||||
// Create updated product model
|
||||
final updatedProduct = ProductModel(
|
||||
id: data.product.id,
|
||||
name: data.product.name,
|
||||
description: data.product.description,
|
||||
price: newPrice,
|
||||
imageUrl: data.product.imageUrl,
|
||||
categoryId: data.product.categoryId,
|
||||
stockQuantity: newStock,
|
||||
isAvailable: data.product.isAvailable,
|
||||
createdAt: data.product.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Update in local storage
|
||||
await localDataSource.updateProduct(updatedProduct);
|
||||
}
|
||||
|
||||
// Refresh products provider
|
||||
ref.invalidate(productsProvider);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${_productsData.length} product${_productsData.length == 1 ? '' : 's'} updated successfully',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error updating products: $e'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Product update data class
|
||||
class ProductUpdateData {
|
||||
final Product product;
|
||||
final TextEditingController priceController;
|
||||
int stock;
|
||||
|
||||
ProductUpdateData({
|
||||
required this.product,
|
||||
required this.priceController,
|
||||
required this.stock,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../../categories/presentation/providers/categories_provider.dart';
|
||||
import '../../../../shared/widgets/price_display.dart';
|
||||
|
||||
/// Product detail page showing full product information
|
||||
class ProductDetailPage extends ConsumerWidget {
|
||||
final Product product;
|
||||
|
||||
const ProductDetailPage({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
|
||||
// Find category name
|
||||
final categoryName = categoriesAsync.whenOrNull(
|
||||
data: (categories) {
|
||||
final category = categories.firstWhere(
|
||||
(cat) => cat.id == product.categoryId,
|
||||
orElse: () => categories.first,
|
||||
);
|
||||
return category.name;
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Product Details'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
// TODO: Navigate to product edit page
|
||||
},
|
||||
tooltip: 'Edit product',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Image
|
||||
_buildProductImage(context),
|
||||
|
||||
// Product Info Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Name
|
||||
Text(
|
||||
product.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Category Badge
|
||||
if (categoryName != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.category, size: 16),
|
||||
label: Text(categoryName),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Price:',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
PriceDisplay(
|
||||
price: product.price,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Stock Information
|
||||
_buildStockSection(context),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Description Section
|
||||
_buildDescriptionSection(context),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Additional Information
|
||||
_buildAdditionalInfo(context),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build product image section
|
||||
Widget _buildProductImage(BuildContext context) {
|
||||
return Hero(
|
||||
tag: 'product-${product.id}',
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 300,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: product.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: product.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build stock information section
|
||||
Widget _buildStockSection(BuildContext context) {
|
||||
final stockColor = _getStockColor(context);
|
||||
final stockStatus = _getStockStatus();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory,
|
||||
color: stockColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Stock Information',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Quantity',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${product.stockQuantity} units',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: stockColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
stockStatus,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
product.isAvailable ? Icons.check_circle : Icons.cancel,
|
||||
size: 16,
|
||||
color: product.isAvailable ? Colors.green : Colors.red,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
product.isAvailable ? 'Available for sale' : 'Not available',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: product.isAvailable ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build description section
|
||||
Widget _buildDescriptionSection(BuildContext context) {
|
||||
if (product.description.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
product.description,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build additional information section
|
||||
Widget _buildAdditionalInfo(BuildContext context) {
|
||||
final dateFormat = DateFormat('MMM dd, yyyy');
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Additional Information',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
icon: Icons.fingerprint,
|
||||
label: 'Product ID',
|
||||
value: product.id,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
icon: Icons.calendar_today,
|
||||
label: 'Created',
|
||||
value: dateFormat.format(product.createdAt),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
icon: Icons.update,
|
||||
label: 'Last Updated',
|
||||
value: dateFormat.format(product.updatedAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build info row
|
||||
Widget _buildInfoRow(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build action buttons
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Add to Cart Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: product.isAvailable && product.stockQuantity > 0
|
||||
? () {
|
||||
// TODO: Add to cart functionality
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${product.name} added to cart'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.shopping_cart),
|
||||
label: const Text('Add to Cart'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Stock Adjustment Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Navigate to stock adjustment
|
||||
},
|
||||
icon: const Icon(Icons.inventory_2),
|
||||
label: const Text('Adjust Stock'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get stock color based on quantity
|
||||
Color _getStockColor(BuildContext context) {
|
||||
if (product.stockQuantity == 0) {
|
||||
return Colors.red;
|
||||
} else if (product.stockQuantity < 5) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.green;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stock status text
|
||||
String _getStockStatus() {
|
||||
if (product.stockQuantity == 0) {
|
||||
return 'Out of Stock';
|
||||
} else if (product.stockQuantity < 5) {
|
||||
return 'Low Stock';
|
||||
} else {
|
||||
return 'In Stock';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../widgets/product_grid.dart';
|
||||
import '../widgets/product_search_bar.dart';
|
||||
import '../widgets/product_list_item.dart';
|
||||
import '../widgets/product_card.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';
|
||||
import 'batch_update_page.dart';
|
||||
|
||||
/// Products page - displays all products in a grid
|
||||
/// View mode for products display
|
||||
enum ViewMode { grid, list }
|
||||
|
||||
/// Products page - displays all products in a grid or list
|
||||
class ProductsPage extends ConsumerStatefulWidget {
|
||||
const ProductsPage({super.key});
|
||||
|
||||
@@ -18,6 +24,11 @@ class ProductsPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _ProductsPageState extends ConsumerState<ProductsPage> {
|
||||
ProductSortOption _sortOption = ProductSortOption.nameAsc;
|
||||
ViewMode _viewMode = ViewMode.grid;
|
||||
|
||||
// Multi-select mode
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedProductIds = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -25,19 +36,117 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
||||
final selectedCategory = ref.watch(product_providers.selectedCategoryProvider);
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
|
||||
// Debug: Log product loading state
|
||||
productsAsync.whenOrNull(
|
||||
data: (products) => debugPrint('Products loaded: ${products.length} items'),
|
||||
loading: () => debugPrint('Products loading...'),
|
||||
error: (error, stack) => debugPrint('Products error: $error'),
|
||||
);
|
||||
|
||||
// Get filtered products from the provider
|
||||
final filteredProducts = productsAsync.when(
|
||||
data: (paginationState) => paginationState.products,
|
||||
data: (products) => products,
|
||||
loading: () => <Product>[],
|
||||
error: (_, __) => <Product>[],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Products'),
|
||||
leading: _isSelectionMode
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedProductIds.clear();
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
title: _isSelectionMode
|
||||
? Text('${_selectedProductIds.length} selected')
|
||||
: const Text('Products'),
|
||||
actions: [
|
||||
// Sort button
|
||||
PopupMenuButton<ProductSortOption>(
|
||||
if (_isSelectionMode) ...[
|
||||
// Select All / Deselect All
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_selectedProductIds.length == filteredProducts.length
|
||||
? Icons.deselect
|
||||
: Icons.select_all,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_selectedProductIds.length == filteredProducts.length) {
|
||||
_selectedProductIds.clear();
|
||||
} else {
|
||||
_selectedProductIds.addAll(
|
||||
filteredProducts.map((p) => p.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: _selectedProductIds.length == filteredProducts.length
|
||||
? 'Deselect all'
|
||||
: 'Select all',
|
||||
),
|
||||
// Batch Update button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: _selectedProductIds.isEmpty
|
||||
? null
|
||||
: () {
|
||||
final selectedProducts = filteredProducts
|
||||
.where((p) => _selectedProductIds.contains(p.id))
|
||||
.toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BatchUpdatePage(
|
||||
selectedProducts: selectedProducts,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedProductIds.clear();
|
||||
});
|
||||
});
|
||||
},
|
||||
tooltip: 'Batch update',
|
||||
),
|
||||
] else ...[
|
||||
// Multi-select mode button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.checklist),
|
||||
onPressed: filteredProducts.isEmpty
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
});
|
||||
},
|
||||
tooltip: 'Select products',
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == ViewMode.grid
|
||||
? Icons.view_list_rounded
|
||||
: Icons.grid_view_rounded,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_viewMode =
|
||||
_viewMode == ViewMode.grid ? ViewMode.list : ViewMode.grid;
|
||||
});
|
||||
},
|
||||
tooltip: _viewMode == ViewMode.grid
|
||||
? 'Switch to list view'
|
||||
: 'Switch to grid view',
|
||||
),
|
||||
// Sort button
|
||||
PopupMenuButton<ProductSortOption>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: 'Sort products',
|
||||
onSelected: (option) {
|
||||
@@ -107,7 +216,8 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(120),
|
||||
@@ -168,12 +278,14 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.read(productsProvider.notifier).refresh();
|
||||
ref.read(categoriesProvider.notifier).refresh();
|
||||
},
|
||||
child: Column(
|
||||
body: productsAsync.when(
|
||||
data: (products) => RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Force sync with API
|
||||
await ref.read(productsProvider.notifier).syncProducts();
|
||||
await ref.refresh(categoriesProvider.future);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
// Results count
|
||||
if (filteredProducts.isNotEmpty)
|
||||
@@ -186,15 +298,208 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Product grid
|
||||
// Product grid or list
|
||||
Expanded(
|
||||
child: ProductGrid(
|
||||
sortOption: _sortOption,
|
||||
),
|
||||
child: _viewMode == ViewMode.grid
|
||||
? _buildGridView()
|
||||
: _buildListView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error loading products: $error'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.refresh(productsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build grid view for products
|
||||
Widget _buildGridView() {
|
||||
if (_isSelectionMode) {
|
||||
final filteredProducts = ref.watch(filteredProductsProvider);
|
||||
final sortedProducts = _sortProducts(filteredProducts, _sortOption);
|
||||
|
||||
if (sortedProducts.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: sortedProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = sortedProducts[index];
|
||||
final isSelected = _selectedProductIds.contains(product.id);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedProductIds.remove(product.id);
|
||||
} else {
|
||||
_selectedProductIds.add(product.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
ProductCard(product: product),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Icon(
|
||||
isSelected ? Icons.check : null,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ProductGrid(sortOption: _sortOption);
|
||||
}
|
||||
|
||||
/// Build list view for products
|
||||
Widget _buildListView() {
|
||||
final filteredProducts = ref.watch(filteredProductsProvider);
|
||||
final sortedProducts = _sortProducts(filteredProducts, _sortOption);
|
||||
|
||||
if (sortedProducts.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: sortedProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = sortedProducts[index];
|
||||
|
||||
if (_isSelectionMode) {
|
||||
final isSelected = _selectedProductIds.contains(product.id);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedProductIds.add(product.id);
|
||||
} else {
|
||||
_selectedProductIds.remove(product.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
secondary: SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: ProductCard(product: product),
|
||||
),
|
||||
title: Text(product.name),
|
||||
subtitle: Text('\$${product.price.toStringAsFixed(2)} • Stock: ${product.stockQuantity}'),
|
||||
);
|
||||
}
|
||||
|
||||
return ProductListItem(product: product);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build empty state
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No products found',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Try adjusting your filters',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sort products based on selected option
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,387 +1,97 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../data/models/product_model.dart';
|
||||
import 'product_datasource_provider.dart';
|
||||
import 'selected_category_provider.dart';
|
||||
import '../../data/providers/product_providers.dart';
|
||||
import '../../../../core/providers/providers.dart';
|
||||
|
||||
part 'products_provider.g.dart';
|
||||
|
||||
/// Pagination state for products
|
||||
class ProductPaginationState {
|
||||
final List<Product> products;
|
||||
final int currentPage;
|
||||
final int totalPages;
|
||||
final int totalItems;
|
||||
final bool hasMore;
|
||||
final bool isLoadingMore;
|
||||
|
||||
const ProductPaginationState({
|
||||
required this.products,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalItems,
|
||||
required this.hasMore,
|
||||
this.isLoadingMore = false,
|
||||
});
|
||||
|
||||
ProductPaginationState copyWith({
|
||||
List<Product>? products,
|
||||
int? currentPage,
|
||||
int? totalPages,
|
||||
int? totalItems,
|
||||
bool? hasMore,
|
||||
bool? isLoadingMore,
|
||||
}) {
|
||||
return ProductPaginationState(
|
||||
products: products ?? this.products,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
totalItems: totalItems ?? this.totalItems,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for products list with pagination and filtering
|
||||
/// Provider for products list with API-first approach
|
||||
@riverpod
|
||||
class Products extends _$Products {
|
||||
static const int _limit = 20;
|
||||
|
||||
@override
|
||||
Future<ProductPaginationState> build() async {
|
||||
return await _fetchProducts(page: 1);
|
||||
Future<List<Product>> build() async {
|
||||
// API-first: Try to load from API first
|
||||
final repository = ref.watch(productRepositoryProvider);
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
|
||||
// Check if online
|
||||
final isConnected = await networkInfo.isConnected;
|
||||
|
||||
if (isConnected) {
|
||||
// Try API first
|
||||
try {
|
||||
final syncResult = await repository.syncProducts();
|
||||
return syncResult.fold(
|
||||
(failure) {
|
||||
// API failed, fallback to cache
|
||||
print('API failed, falling back to cache: ${failure.message}');
|
||||
return _loadFromCache();
|
||||
},
|
||||
(products) => products,
|
||||
);
|
||||
} catch (e) {
|
||||
// API error, fallback to cache
|
||||
print('API error, falling back to cache: $e');
|
||||
return _loadFromCache();
|
||||
}
|
||||
} else {
|
||||
// Offline, load from cache
|
||||
print('Offline, loading from cache');
|
||||
return _loadFromCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch products with pagination and optional filters
|
||||
Future<ProductPaginationState> _fetchProducts({
|
||||
required int page,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
double? minPrice,
|
||||
double? maxPrice,
|
||||
bool? isAvailable,
|
||||
}) async {
|
||||
final datasource = ref.read(productRemoteDataSourceProvider);
|
||||
/// Load products from local cache
|
||||
Future<List<Product>> _loadFromCache() async {
|
||||
final repository = ref.read(productRepositoryProvider);
|
||||
final result = await repository.getAllProducts();
|
||||
|
||||
final response = await datasource.getAllProducts(
|
||||
page: page,
|
||||
limit: _limit,
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
minPrice: minPrice,
|
||||
maxPrice: maxPrice,
|
||||
isAvailable: isAvailable,
|
||||
);
|
||||
|
||||
// Extract data
|
||||
final List<ProductModel> productModels =
|
||||
(response['data'] as List<ProductModel>);
|
||||
final meta = response['meta'] as Map<String, dynamic>;
|
||||
|
||||
// Convert to entities
|
||||
final products = productModels.map((model) => model.toEntity()).toList();
|
||||
|
||||
// Extract pagination info
|
||||
final currentPage = meta['currentPage'] as int? ?? page;
|
||||
final totalPages = meta['totalPages'] as int? ?? 1;
|
||||
final totalItems = meta['totalItems'] as int? ?? products.length;
|
||||
final hasMore = currentPage < totalPages;
|
||||
|
||||
return ProductPaginationState(
|
||||
products: products,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: totalItems,
|
||||
hasMore: hasMore,
|
||||
return result.fold(
|
||||
(failure) {
|
||||
print('Cache load failed: ${failure.message}');
|
||||
return <Product>[];
|
||||
},
|
||||
(products) => products,
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh products (reset to first page)
|
||||
/// Refresh products from local storage
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchProducts(page: 1);
|
||||
final repository = ref.read(productRepositoryProvider);
|
||||
final result = await repository.getAllProducts();
|
||||
|
||||
return result.fold(
|
||||
(failure) => throw Exception(failure.message),
|
||||
(products) => products,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Load more products (next page)
|
||||
Future<void> loadMore() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null || !currentState.hasMore) return;
|
||||
/// Sync products from API and update local storage
|
||||
Future<void> syncProducts() async {
|
||||
final networkInfo = ref.read(networkInfoProvider);
|
||||
final isConnected = await networkInfo.isConnected;
|
||||
|
||||
// Set loading more flag
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: true),
|
||||
);
|
||||
|
||||
// Fetch next page
|
||||
final nextPage = currentState.currentPage + 1;
|
||||
|
||||
try {
|
||||
final newState = await _fetchProducts(page: nextPage);
|
||||
|
||||
// Append new products to existing ones
|
||||
state = AsyncValue.data(
|
||||
newState.copyWith(
|
||||
products: [...currentState.products, ...newState.products],
|
||||
isLoadingMore: false,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
// Restore previous state on error
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: false),
|
||||
);
|
||||
// Optionally rethrow or handle error
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
if (!isConnected) {
|
||||
throw Exception('No internet connection');
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter products by category
|
||||
Future<void> filterByCategory(String? categoryId) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchProducts(page: 1, categoryId: categoryId);
|
||||
});
|
||||
}
|
||||
final repository = ref.read(productRepositoryProvider);
|
||||
final result = await repository.syncProducts();
|
||||
|
||||
/// Search products
|
||||
Future<void> search(String query) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchProducts(page: 1, search: query);
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter by price range
|
||||
Future<void> filterByPrice({double? minPrice, double? maxPrice}) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchProducts(
|
||||
page: 1,
|
||||
minPrice: minPrice,
|
||||
maxPrice: maxPrice,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter by availability
|
||||
Future<void> filterByAvailability(bool isAvailable) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchProducts(page: 1, isAvailable: isAvailable);
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply multiple filters at once
|
||||
Future<void> applyFilters({
|
||||
String? categoryId,
|
||||
String? search,
|
||||
double? minPrice,
|
||||
double? maxPrice,
|
||||
bool? isAvailable,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await _fetchProducts(
|
||||
page: 1,
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
minPrice: minPrice,
|
||||
maxPrice: maxPrice,
|
||||
isAvailable: isAvailable,
|
||||
return result.fold(
|
||||
(failure) => throw Exception(failure.message),
|
||||
(products) => products,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for single product by ID
|
||||
@riverpod
|
||||
Future<Product> product(Ref ref, String id) async {
|
||||
final datasource = ref.read(productRemoteDataSourceProvider);
|
||||
final productModel = await datasource.getProductById(id);
|
||||
return productModel.toEntity();
|
||||
}
|
||||
|
||||
/// Provider for products filtered by the selected category
|
||||
/// This provider automatically updates when the selected category changes
|
||||
@riverpod
|
||||
class ProductsBySelectedCategory extends _$ProductsBySelectedCategory {
|
||||
static const int _limit = 20;
|
||||
|
||||
@override
|
||||
Future<ProductPaginationState> build() async {
|
||||
// Watch selected category
|
||||
final selectedCategoryId = ref.watch(selectedCategoryProvider);
|
||||
|
||||
// Fetch products with category filter
|
||||
return await _fetchProducts(page: 1, categoryId: selectedCategoryId);
|
||||
}
|
||||
|
||||
Future<ProductPaginationState> _fetchProducts({
|
||||
required int page,
|
||||
String? categoryId,
|
||||
}) async {
|
||||
final datasource = ref.read(productRemoteDataSourceProvider);
|
||||
|
||||
final response = await datasource.getAllProducts(
|
||||
page: page,
|
||||
limit: _limit,
|
||||
categoryId: categoryId,
|
||||
);
|
||||
|
||||
// Extract data
|
||||
final List<ProductModel> productModels =
|
||||
(response['data'] as List<ProductModel>);
|
||||
final meta = response['meta'] as Map<String, dynamic>;
|
||||
|
||||
// Convert to entities
|
||||
final products = productModels.map((model) => model.toEntity()).toList();
|
||||
|
||||
// Extract pagination info
|
||||
final currentPage = meta['currentPage'] as int? ?? page;
|
||||
final totalPages = meta['totalPages'] as int? ?? 1;
|
||||
final totalItems = meta['totalItems'] as int? ?? products.length;
|
||||
final hasMore = currentPage < totalPages;
|
||||
|
||||
return ProductPaginationState(
|
||||
products: products,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: totalItems,
|
||||
hasMore: hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Load more products (next page)
|
||||
Future<void> loadMore() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null || !currentState.hasMore) return;
|
||||
|
||||
// Set loading more flag
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: true),
|
||||
);
|
||||
|
||||
// Fetch next page
|
||||
final nextPage = currentState.currentPage + 1;
|
||||
final selectedCategoryId = ref.read(selectedCategoryProvider);
|
||||
|
||||
try {
|
||||
final newState = await _fetchProducts(
|
||||
page: nextPage,
|
||||
categoryId: selectedCategoryId,
|
||||
);
|
||||
|
||||
// Append new products to existing ones
|
||||
state = AsyncValue.data(
|
||||
newState.copyWith(
|
||||
products: [...currentState.products, ...newState.products],
|
||||
isLoadingMore: false,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
// Restore previous state on error
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: false),
|
||||
);
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for searching products with pagination
|
||||
@riverpod
|
||||
class ProductSearch extends _$ProductSearch {
|
||||
static const int _limit = 20;
|
||||
|
||||
@override
|
||||
Future<ProductPaginationState> build(String query) async {
|
||||
if (query.isEmpty) {
|
||||
return const ProductPaginationState(
|
||||
products: [],
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
hasMore: false,
|
||||
);
|
||||
}
|
||||
|
||||
return await _searchProducts(query: query, page: 1);
|
||||
}
|
||||
|
||||
Future<ProductPaginationState> _searchProducts({
|
||||
required String query,
|
||||
required int page,
|
||||
}) async {
|
||||
final datasource = ref.read(productRemoteDataSourceProvider);
|
||||
|
||||
final response = await datasource.searchProducts(query, page, _limit);
|
||||
|
||||
// Extract data
|
||||
final List<ProductModel> productModels =
|
||||
(response['data'] as List<ProductModel>);
|
||||
final meta = response['meta'] as Map<String, dynamic>;
|
||||
|
||||
// Convert to entities
|
||||
final products = productModels.map((model) => model.toEntity()).toList();
|
||||
|
||||
// Extract pagination info
|
||||
final currentPage = meta['currentPage'] as int? ?? page;
|
||||
final totalPages = meta['totalPages'] as int? ?? 1;
|
||||
final totalItems = meta['totalItems'] as int? ?? products.length;
|
||||
final hasMore = currentPage < totalPages;
|
||||
|
||||
return ProductPaginationState(
|
||||
products: products,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: totalItems,
|
||||
hasMore: hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Load more search results (next page)
|
||||
Future<void> loadMore() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null || !currentState.hasMore) return;
|
||||
|
||||
// Set loading more flag
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: true),
|
||||
);
|
||||
|
||||
// Fetch next page
|
||||
final nextPage = currentState.currentPage + 1;
|
||||
|
||||
try {
|
||||
// Get the query from the provider parameter
|
||||
// Note: In Riverpod 3.0, family parameters are accessed differently
|
||||
// We need to re-search with the same query
|
||||
final newState = await _searchProducts(
|
||||
query: '', // This will be replaced by proper implementation
|
||||
page: nextPage,
|
||||
);
|
||||
|
||||
// Append new products to existing ones
|
||||
state = AsyncValue.data(
|
||||
newState.copyWith(
|
||||
products: [...currentState.products, ...newState.products],
|
||||
isLoadingMore: false,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
// Restore previous state on error
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(isLoadingMore: false),
|
||||
);
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search query provider for products
|
||||
/// Provider for search query
|
||||
@riverpod
|
||||
class SearchQuery extends _$SearchQuery {
|
||||
@override
|
||||
@@ -389,16 +99,5 @@ class SearchQuery extends _$SearchQuery {
|
||||
|
||||
void setQuery(String query) {
|
||||
state = query;
|
||||
// Trigger search in products provider
|
||||
if (query.isNotEmpty) {
|
||||
ref.read(productsProvider.notifier).search(query);
|
||||
} else {
|
||||
ref.read(productsProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = '';
|
||||
ref.read(productsProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ part of 'products_provider.dart';
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for products list with pagination and filtering
|
||||
/// Provider for products list with API-first approach
|
||||
|
||||
@ProviderFor(Products)
|
||||
const productsProvider = ProductsProvider._();
|
||||
|
||||
/// Provider for products list with pagination and filtering
|
||||
/// Provider for products list with API-first approach
|
||||
final class ProductsProvider
|
||||
extends $AsyncNotifierProvider<Products, ProductPaginationState> {
|
||||
/// Provider for products list with pagination and filtering
|
||||
extends $AsyncNotifierProvider<Products, List<Product>> {
|
||||
/// Provider for products list with API-first approach
|
||||
const ProductsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -36,27 +36,22 @@ final class ProductsProvider
|
||||
Products create() => Products();
|
||||
}
|
||||
|
||||
String _$productsHash() => r'2f2da8d6d7c1b88a525e4f79c9b29267b7da08ea';
|
||||
String _$productsHash() => r'0ff8c2de46bb4b1e29678cc811ec121c9fb4c8eb';
|
||||
|
||||
/// Provider for products list with pagination and filtering
|
||||
/// Provider for products list with API-first approach
|
||||
|
||||
abstract class _$Products extends $AsyncNotifier<ProductPaginationState> {
|
||||
FutureOr<ProductPaginationState> build();
|
||||
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<ProductPaginationState>, ProductPaginationState>;
|
||||
final ref = this.ref as $Ref<AsyncValue<List<Product>>, List<Product>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<ProductPaginationState>,
|
||||
ProductPaginationState
|
||||
>,
|
||||
AsyncValue<ProductPaginationState>,
|
||||
AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
|
||||
AsyncValue<List<Product>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
@@ -64,264 +59,14 @@ abstract class _$Products extends $AsyncNotifier<ProductPaginationState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for single product by ID
|
||||
|
||||
@ProviderFor(product)
|
||||
const productProvider = ProductFamily._();
|
||||
|
||||
/// Provider for single product by ID
|
||||
|
||||
final class ProductProvider
|
||||
extends $FunctionalProvider<AsyncValue<Product>, Product, FutureOr<Product>>
|
||||
with $FutureModifier<Product>, $FutureProvider<Product> {
|
||||
/// Provider for single product by ID
|
||||
const ProductProvider._({
|
||||
required ProductFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'productProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'productProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Product> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Product> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return product(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProductProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$productHash() => r'e9b9a3db5f2aa33a19defe3551b8dca62d1c96b1';
|
||||
|
||||
/// Provider for single product by ID
|
||||
|
||||
final class ProductFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<Product>, String> {
|
||||
const ProductFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'productProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for single product by ID
|
||||
|
||||
ProductProvider call(String id) =>
|
||||
ProductProvider._(argument: id, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'productProvider';
|
||||
}
|
||||
|
||||
/// Provider for products filtered by the selected category
|
||||
/// This provider automatically updates when the selected category changes
|
||||
|
||||
@ProviderFor(ProductsBySelectedCategory)
|
||||
const productsBySelectedCategoryProvider =
|
||||
ProductsBySelectedCategoryProvider._();
|
||||
|
||||
/// Provider for products filtered by the selected category
|
||||
/// This provider automatically updates when the selected category changes
|
||||
final class ProductsBySelectedCategoryProvider
|
||||
extends
|
||||
$AsyncNotifierProvider<
|
||||
ProductsBySelectedCategory,
|
||||
ProductPaginationState
|
||||
> {
|
||||
/// Provider for products filtered by the selected category
|
||||
/// This provider automatically updates when the selected category changes
|
||||
const ProductsBySelectedCategoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productsBySelectedCategoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productsBySelectedCategoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ProductsBySelectedCategory create() => ProductsBySelectedCategory();
|
||||
}
|
||||
|
||||
String _$productsBySelectedCategoryHash() =>
|
||||
r'642bbfab846469933bd4af89fb2ac7da77895562';
|
||||
|
||||
/// Provider for products filtered by the selected category
|
||||
/// This provider automatically updates when the selected category changes
|
||||
|
||||
abstract class _$ProductsBySelectedCategory
|
||||
extends $AsyncNotifier<ProductPaginationState> {
|
||||
FutureOr<ProductPaginationState> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<AsyncValue<ProductPaginationState>, ProductPaginationState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<ProductPaginationState>,
|
||||
ProductPaginationState
|
||||
>,
|
||||
AsyncValue<ProductPaginationState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for searching products with pagination
|
||||
|
||||
@ProviderFor(ProductSearch)
|
||||
const productSearchProvider = ProductSearchFamily._();
|
||||
|
||||
/// Provider for searching products with pagination
|
||||
final class ProductSearchProvider
|
||||
extends $AsyncNotifierProvider<ProductSearch, ProductPaginationState> {
|
||||
/// Provider for searching products with pagination
|
||||
const ProductSearchProvider._({
|
||||
required ProductSearchFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'productSearchProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productSearchHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'productSearchProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ProductSearch create() => ProductSearch();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProductSearchProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$productSearchHash() => r'86946a7cf6722822ed205af5d4ec2a8f5ba5ca48';
|
||||
|
||||
/// Provider for searching products with pagination
|
||||
|
||||
final class ProductSearchFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
ProductSearch,
|
||||
AsyncValue<ProductPaginationState>,
|
||||
ProductPaginationState,
|
||||
FutureOr<ProductPaginationState>,
|
||||
String
|
||||
> {
|
||||
const ProductSearchFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'productSearchProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for searching products with pagination
|
||||
|
||||
ProductSearchProvider call(String query) =>
|
||||
ProductSearchProvider._(argument: query, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'productSearchProvider';
|
||||
}
|
||||
|
||||
/// Provider for searching products with pagination
|
||||
|
||||
abstract class _$ProductSearch extends $AsyncNotifier<ProductPaginationState> {
|
||||
late final _$args = ref.$arg as String;
|
||||
String get query => _$args;
|
||||
|
||||
FutureOr<ProductPaginationState> build(String query);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build(_$args);
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<AsyncValue<ProductPaginationState>, ProductPaginationState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<ProductPaginationState>,
|
||||
ProductPaginationState
|
||||
>,
|
||||
AsyncValue<ProductPaginationState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search query provider for products
|
||||
/// Provider for search query
|
||||
|
||||
@ProviderFor(SearchQuery)
|
||||
const searchQueryProvider = SearchQueryProvider._();
|
||||
|
||||
/// Search query provider for products
|
||||
/// Provider for search query
|
||||
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
|
||||
/// Search query provider for products
|
||||
/// Provider for search query
|
||||
const SearchQueryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -349,9 +94,9 @@ final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchQueryHash() => r'0c08fe7fe2ce47cf806a34872f5cf4912fe8c618';
|
||||
String _$searchQueryHash() => r'2c146927785523a0ddf51b23b777a9be4afdc092';
|
||||
|
||||
/// Search query provider for products
|
||||
/// Provider for search query
|
||||
|
||||
abstract class _$SearchQuery extends $Notifier<String> {
|
||||
String build();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../pages/product_detail_page.dart';
|
||||
import '../../../../shared/widgets/price_display.dart';
|
||||
|
||||
/// Product card widget
|
||||
@@ -18,7 +19,13 @@ class ProductCard extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Navigate to product details or add to cart
|
||||
// Navigate to product detail page
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProductDetailPage(product: product),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../pages/product_detail_page.dart';
|
||||
import '../../../../shared/widgets/price_display.dart';
|
||||
|
||||
/// Product list item widget for list view
|
||||
class ProductListItem extends StatelessWidget {
|
||||
final Product product;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ProductListItem({
|
||||
super.key,
|
||||
required this.product,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: InkWell(
|
||||
onTap: onTap ??
|
||||
() {
|
||||
// Navigate to product detail page
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProductDetailPage(product: product),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Product Image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: product.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: product.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Product Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (product.description.isNotEmpty)
|
||||
Text(
|
||||
product.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
PriceDisplay(price: product.price),
|
||||
const Spacer(),
|
||||
// Stock Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStockColor(context, product.stockQuantity),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Stock: ${product.stockQuantity}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getStockColor(BuildContext context, int stock) {
|
||||
if (stock == 0) {
|
||||
return Colors.red;
|
||||
} else if (stock < 5) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.green;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user