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),
|
||||
|
||||
Reference in New Issue
Block a user