From b367d405c41d435b85434860ce390ec6893b40b2 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Mon, 10 Nov 2025 17:02:26 +0700 Subject: [PATCH] load products --- docs/products.sh | 42 ++++ lib/core/constants/api_constants.dart | 19 ++ .../providers/favorites_provider.dart | 12 +- .../providers/favorites_provider.g.dart | 2 +- .../products_remote_datasource.dart | 203 ++++++++++++++++++ .../products/data/models/product_model.dart | 123 +++++++++-- .../products/data/models/product_model.g.dart | 47 ++-- .../products_repository_impl.dart | 41 ++-- .../products/domain/entities/product.dart | 18 +- .../providers/categories_provider.dart | 7 +- .../providers/categories_provider.g.dart | 2 +- .../providers/products_provider.dart | 40 +++- .../providers/products_provider.g.dart | 158 +++++++++++++- .../presentation/widgets/product_card.dart | 2 +- .../product_detail/image_gallery_section.dart | 2 +- 15 files changed, 635 insertions(+), 83 deletions(-) create mode 100644 docs/products.sh create mode 100644 lib/features/products/data/datasources/products_remote_datasource.dart diff --git a/docs/products.sh b/docs/products.sh new file mode 100644 index 0000000..4f05678 --- /dev/null +++ b/docs/products.sh @@ -0,0 +1,42 @@ +get product list +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item.get_list' \ +--header 'X-Frappe-Csrf-Token: 2080d7c5952833b5080de1f93012ae019731aa00e79f93ae787869f3' \ +--header 'Cookie: sid=f5fa31ebf6901e99fc7fda974a3c6e524949bc38e551a39544d7d0e2; full_name=Ha%20Duy%20Lam; sid=f5fa31ebf6901e99fc7fda974a3c6e524949bc38e551a39544d7d0e2; system_user=yes; user_id=lamhd%40gmail.com; user_image=' \ +--header 'Content-Type: application/json' \ +--data '{ + "limit_start" : 0, + "limit_page_length": 0 +}' + +get attribute list +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item_attribute.get_list' \ +--header 'X-Frappe-Csrf-Token: 13c271e0e58dcad9bcc0053cad0057540eb0675bb7052c2cc1a815b2' \ +--header 'Cookie: sid=d9ddd3862832f12901ef4c0d77d6891cd08ef851a254b7d56c857724; full_name=Ha%20Duy%20Lam; sid=d9ddd3862832f12901ef4c0d77d6891cd08ef851a254b7d56c857724; system_user=yes; user_id=lamhd%40gmail.com; user_image=' \ +--header 'Content-Type: application/json' \ +--data '{ + "filters": {"is_group": 0}, + "limit_page_length": 0 +}' + +get brand + +curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ +--header 'X-Frappe-Csrf-Token: 52e3deff2accdc4d990312508dff6be0ecae61e01da837f00b2bfae9' \ +--header 'Cookie: sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \ +--header 'Content-Type: application/json' \ +--data '{ + "doctype": "Brand", + "fields": ["name"], + "limit_page_length": 0 +}' + +get product detail +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item.get_detail' \ +--header 'X-Frappe-Csrf-Token: 4989ff095956a891bbae0944a1483097b6eb06f1080961f7164a7e17' \ +--header 'Cookie: sid=42ab54811fb7eadc8c67a6651c68519c8655e9b3e7b797628dcd0b88; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \ +--header 'Content-Type: application/json' \ +--data '{ + "name" : "GIB20 G02" +}' + + diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index e21df03..770ff40 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -368,6 +368,25 @@ class ApiConstants { /// Frappe public API user ID static const String frappePublicUserId = 'public_api@dbiz.com'; + // ============================================================================ + // Product/Item Endpoints (Frappe ERPNext) + // ============================================================================ + + /// Get product/item list (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.item.get_list + /// Body: { "limit_start": 0, "limit_page_length": 0 } + static const String frappeGetItems = '/building_material.building_material.api.item.get_list'; + + /// Get product/item detail (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.item.get_detail + /// Body: { "name": "item_code" } + static const String frappeGetItemDetail = '/building_material.building_material.api.item.get_detail'; + + /// Get item attributes list (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.item_attribute.get_list + /// Body: { "filters": {"is_group": 0}, "limit_page_length": 0 } + static const String frappeGetItemAttributes = '/building_material.building_material.api.item_attribute.get_list'; + // ============================================================================ // Notification Endpoints // ============================================================================ diff --git a/lib/features/favorites/presentation/providers/favorites_provider.dart b/lib/features/favorites/presentation/providers/favorites_provider.dart index bc345a5..6ae2cdb 100644 --- a/lib/features/favorites/presentation/providers/favorites_provider.dart +++ b/lib/features/favorites/presentation/providers/favorites_provider.dart @@ -1,10 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:worker/features/favorites/data/datasources/favorites_local_datasource.dart'; import 'package:worker/features/favorites/data/models/favorite_model.dart'; -import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; -import 'package:worker/features/products/data/repositories/products_repository_impl.dart'; import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/usecases/get_products.dart'; +import 'package:worker/features/products/presentation/providers/products_provider.dart'; part 'favorites_provider.g.dart'; @@ -251,12 +250,9 @@ Future> favoriteProducts(Ref ref) async { return []; } - // Import products provider to get all products - const productsRepository = ProductsRepositoryImpl( - localDataSource: ProductsLocalDataSourceImpl(), - ); - - const getProductsUseCase = GetProducts(productsRepository); + // Get products repository with injected dependencies + final productsRepository = await ref.watch(productsRepositoryProvider.future); + final getProductsUseCase = GetProducts(productsRepository); final allProducts = await getProductsUseCase(); // Filter to only include favorited products diff --git a/lib/features/favorites/presentation/providers/favorites_provider.g.dart b/lib/features/favorites/presentation/providers/favorites_provider.g.dart index b1a9265..91855cc 100644 --- a/lib/features/favorites/presentation/providers/favorites_provider.g.dart +++ b/lib/features/favorites/presentation/providers/favorites_provider.g.dart @@ -440,4 +440,4 @@ final class FavoriteProductsProvider } } -String _$favoriteProductsHash() => r'6f48aa57781b0276ad72928e6b54b04fc53b0d7e'; +String _$favoriteProductsHash() => r'630acfbc403cc4deb486c7b0199f128252a8990b'; diff --git a/lib/features/products/data/datasources/products_remote_datasource.dart b/lib/features/products/data/datasources/products_remote_datasource.dart new file mode 100644 index 0000000..7e2ebd6 --- /dev/null +++ b/lib/features/products/data/datasources/products_remote_datasource.dart @@ -0,0 +1,203 @@ +/// Data Source: Products Remote Data Source +/// +/// Handles fetching product data from the Frappe ERPNext API. +library; + +import 'package:dio/dio.dart'; +import 'package:worker/core/constants/api_constants.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/core/services/frappe_auth_service.dart'; +import 'package:worker/features/products/data/models/product_model.dart'; + +/// Products Remote Data Source +/// +/// Provides methods to fetch product data from the Frappe ERPNext API. +/// Uses FrappeAuthService for session management. +class ProductsRemoteDataSource { + ProductsRemoteDataSource(this._dioClient, this._frappeAuthService); + + final DioClient _dioClient; + final FrappeAuthService _frappeAuthService; + + /// Get all products + /// + /// Fetches all products from Frappe ERPNext. + /// Returns a list of [ProductModel]. + /// + /// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_list + /// Request body: + /// ```json + /// { + /// "limit_start": 0, + /// "limit_page_length": 0 + /// } + /// ``` + /// + /// Response format: + /// ```json + /// { + /// "message": [ + /// { + /// "name": "GIB20 G02", + /// "item_name": "Gạch Eurotile 1200x1200", + /// "description": "Product description...", + /// "standard_rate": 450000.0, + /// "stock_uom": "m²", + /// "image": "/files/image.jpg", + /// "brand": "Eurotile", + /// ... + /// } + /// ] + /// } + /// ``` + Future> getAllProducts({ + int limitStart = 0, + int limitPageLength = 0, + }) async { + try { + // Get Frappe session headers + final headers = await _frappeAuthService.getHeaders(); + + // Build full API URL + const url = + '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItems}'; + + final response = await _dioClient.post>( + url, + data: { + 'limit_start': limitStart, + 'limit_page_length': limitPageLength, + }, + options: Options(headers: headers), + ); + + if (response.data == null) { + throw Exception('Empty response from server'); + } + + // Parse the response + final message = response.data!['message']; + if (message == null) { + throw Exception('No message field in response'); + } + + final productsList = message as List; + return productsList + .map((item) => ProductModel.fromFrappeJson(item as Map)) + .toList(); + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + throw Exception('Products endpoint not found'); + } else if (e.response?.statusCode == 500) { + throw Exception('Server error while fetching products'); + } else if (e.type == DioExceptionType.connectionTimeout) { + throw Exception('Connection timeout while fetching products'); + } else if (e.type == DioExceptionType.receiveTimeout) { + throw Exception('Response timeout while fetching products'); + } else { + throw Exception('Failed to fetch products: ${e.message}'); + } + } catch (e) { + throw Exception('Unexpected error fetching products: $e'); + } + } + + /// Get product detail by name/code + /// + /// Fetches a single product by its item code from Frappe ERPNext. + /// Returns a [ProductModel]. + /// + /// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_detail + /// Request body: + /// ```json + /// { + /// "name": "GIB20 G02" + /// } + /// ``` + Future getProductDetail(String itemCode) async { + try { + // Get Frappe session headers + final headers = await _frappeAuthService.getHeaders(); + + // Build full API URL + final url = + '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItemDetail}'; + + final response = await _dioClient.post>( + url, + data: { + 'name': itemCode, + }, + options: Options(headers: headers), + ); + + if (response.data == null) { + throw Exception('Empty response from server'); + } + + // Parse the response + final message = response.data!['message']; + if (message == null) { + throw Exception('Product not found: $itemCode'); + } + + return ProductModel.fromFrappeJson(message as Map); + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + throw Exception('Product not found: $itemCode'); + } else if (e.response?.statusCode == 500) { + throw Exception('Server error while fetching product detail'); + } else if (e.type == DioExceptionType.connectionTimeout) { + throw Exception('Connection timeout while fetching product detail'); + } else if (e.type == DioExceptionType.receiveTimeout) { + throw Exception('Response timeout while fetching product detail'); + } else { + throw Exception('Failed to fetch product detail: ${e.message}'); + } + } catch (e) { + throw Exception('Unexpected error fetching product detail: $e'); + } + } + + /// Search products + /// + /// Searches products by name or description. + /// For now, we fetch all products and filter locally. + /// In the future, the API might support server-side search. + Future> searchProducts(String query) async { + // For now, fetch all products and filter locally + // TODO: Implement server-side search if API supports it + final allProducts = await getAllProducts(); + + final lowercaseQuery = query.toLowerCase(); + + return allProducts.where((product) { + final name = product.name.toLowerCase(); + final description = (product.description ?? '').toLowerCase(); + final productId = product.productId.toLowerCase(); + + return name.contains(lowercaseQuery) || + description.contains(lowercaseQuery) || + productId.contains(lowercaseQuery); + }).toList(); + } + + /// Get products by category + /// + /// Filters products by category. + /// For now, we fetch all products and filter locally. + /// In the future, the API might support category filtering. + Future> getProductsByCategory(String categoryId) async { + // For now, fetch all products and filter locally + // TODO: Implement server-side category filtering if API supports it + final allProducts = await getAllProducts(); + + if (categoryId == 'all') { + return allProducts; + } + + return allProducts + .where((product) => product.category == categoryId) + .toList(); + } +} diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart index 4958e70..36d2ac7 100644 --- a/lib/features/products/data/models/product_model.dart +++ b/lib/features/products/data/models/product_model.dart @@ -21,8 +21,9 @@ class ProductModel extends HiveObject { this.description, required this.basePrice, this.images, + this.thumbnail, this.imageCaptions, - this.link360, + this.customLink360, this.specifications, this.category, this.brand, @@ -54,49 +55,53 @@ class ProductModel extends HiveObject { @HiveField(4) final String? images; - /// Image captions (JSON encoded map of image_url -> caption) + /// Thumbnail image URL @HiveField(5) + final String? thumbnail; + + /// Image captions (JSON encoded map of image_url -> caption) + @HiveField(6) final String? imageCaptions; - /// 360-degree view link - @HiveField(6) - final String? link360; + /// Custom 360-degree view link + @HiveField(7) + final String? customLink360; /// Product specifications (JSON encoded) /// Contains: size, material, color, finish, etc. - @HiveField(7) + @HiveField(8) final String? specifications; /// Product category - @HiveField(8) + @HiveField(9) final String? category; /// Product brand - @HiveField(9) + @HiveField(10) final String? brand; /// Unit of measurement (m2, box, piece, etc.) - @HiveField(10) + @HiveField(11) final String? unit; /// Whether product is active - @HiveField(11) + @HiveField(12) final bool isActive; /// Whether product is featured - @HiveField(12) + @HiveField(13) final bool isFeatured; /// ERPNext item code for integration - @HiveField(13) + @HiveField(14) final String? erpnextItemCode; /// Product creation timestamp - @HiveField(14) + @HiveField(15) final DateTime createdAt; /// Last update timestamp - @HiveField(15) + @HiveField(16) final DateTime? updatedAt; // ========================================================================= @@ -111,10 +116,11 @@ class ProductModel extends HiveObject { description: json['description'] as String?, basePrice: (json['base_price'] as num).toDouble(), images: json['images'] != null ? jsonEncode(json['images']) : null, + thumbnail: json['thumbnail'] as String?, imageCaptions: json['image_captions'] != null ? jsonEncode(json['image_captions']) : null, - link360: json['link_360'] as String?, + customLink360: json['custom_link_360'] as String?, specifications: json['specifications'] != null ? jsonEncode(json['specifications']) : null, @@ -131,6 +137,79 @@ class ProductModel extends HiveObject { ); } + /// Create ProductModel from Frappe ERPNext API JSON + /// + /// Maps Frappe Item doctype fields to our ProductModel structure. + /// Frappe fields: + /// - name: Item code (e.g., "GIB20 G02") + /// - item_name: Display name + /// - description: Product description + /// - standard_rate: Price + /// - stock_uom: Unit of measurement + /// - image: Image path + /// - thumbnail: Thumbnail image path + /// - brand: Brand name + /// - item_group: Category/group + /// - custom_link_360: Custom 360 view link + factory ProductModel.fromFrappeJson(Map json) { + // Handle image - prepend base URL if needed + String? imageUrl; + if (json['image'] != null && (json['image'] as String).isNotEmpty) { + final imagePath = json['image'] as String; + if (imagePath.startsWith('/')) { + imageUrl = 'https://land.dbiz.com$imagePath'; + } else if (imagePath.startsWith('http')) { + imageUrl = imagePath; + } else { + imageUrl = 'https://land.dbiz.com/$imagePath'; + } + } + + // Handle thumbnail - prepend base URL if needed + String? thumbnailUrl; + if (json['thumbnail'] != null && (json['thumbnail'] as String).isNotEmpty) { + final thumbnailPath = json['thumbnail'] as String; + if (thumbnailPath.startsWith('/')) { + thumbnailUrl = 'https://land.dbiz.com$thumbnailPath'; + } else if (thumbnailPath.startsWith('http')) { + thumbnailUrl = thumbnailPath; + } else { + thumbnailUrl = 'https://land.dbiz.com/$thumbnailPath'; + } + } + + // Convert single image to list format + final imagesList = imageUrl != null ? [imageUrl] : []; + + final now = DateTime.now(); + + return ProductModel( + productId: json['name'] as String, // Item code + name: json['item_name'] as String? ?? json['name'] as String, + description: json['description'] as String?, + basePrice: (json['standard_rate'] as num?)?.toDouble() ?? 0.0, + images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null, + thumbnail: thumbnailUrl, + imageCaptions: null, // Not provided by API + customLink360: json['custom_link_360'] as String?, + specifications: json['specifications'] != null + ? jsonEncode(json['specifications']) + : null, + category: json['item_group'] as String?, // Frappe uses item_group + brand: json['brand'] as String?, + unit: json['stock_uom'] as String? ?? 'm²', + isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field + isFeatured: false, // Not provided by API, default to false + erpnextItemCode: json['name'] as String, // Store item code for reference + createdAt: json['creation'] != null + ? DateTime.tryParse(json['creation'] as String) ?? now + : now, + updatedAt: json['modified'] != null + ? DateTime.tryParse(json['modified'] as String) + : null, + ); + } + /// Convert ProductModel to JSON Map toJson() { return { @@ -139,10 +218,11 @@ class ProductModel extends HiveObject { 'description': description, 'base_price': basePrice, 'images': images != null ? jsonDecode(images!) : null, + 'thumbnail': thumbnail, 'image_captions': imageCaptions != null ? jsonDecode(imageCaptions!) : null, - 'link_360': link360, + 'custom_link_360': customLink360, 'specifications': specifications != null ? jsonDecode(specifications!) : null, @@ -208,7 +288,7 @@ class ProductModel extends HiveObject { } /// Check if product has 360 view - bool get has360View => link360 != null && link360!.isNotEmpty; + bool get has360View => customLink360 != null && customLink360!.isNotEmpty; // ========================================================================= // COPY WITH @@ -221,8 +301,9 @@ class ProductModel extends HiveObject { String? description, double? basePrice, String? images, + String? thumbnail, String? imageCaptions, - String? link360, + String? customLink360, String? specifications, String? category, String? brand, @@ -239,8 +320,9 @@ class ProductModel extends HiveObject { description: description ?? this.description, basePrice: basePrice ?? this.basePrice, images: images ?? this.images, + thumbnail: thumbnail ?? this.thumbnail, imageCaptions: imageCaptions ?? this.imageCaptions, - link360: link360 ?? this.link360, + customLink360: customLink360 ?? this.customLink360, specifications: specifications ?? this.specifications, category: category ?? this.category, brand: brand ?? this.brand, @@ -280,8 +362,9 @@ class ProductModel extends HiveObject { description: description, basePrice: basePrice, images: imagesList ?? [], + thumbnail: thumbnail, imageCaptions: imageCaptionsMap ?? {}, - link360: link360, + customLink360: customLink360, specifications: specificationsMap ?? {}, category: category, brand: brand, diff --git a/lib/features/products/data/models/product_model.g.dart b/lib/features/products/data/models/product_model.g.dart index 6846ff2..192c353 100644 --- a/lib/features/products/data/models/product_model.g.dart +++ b/lib/features/products/data/models/product_model.g.dart @@ -22,24 +22,25 @@ class ProductModelAdapter extends TypeAdapter { description: fields[2] as String?, basePrice: (fields[3] as num).toDouble(), images: fields[4] as String?, - imageCaptions: fields[5] as String?, - link360: fields[6] as String?, - specifications: fields[7] as String?, - category: fields[8] as String?, - brand: fields[9] as String?, - unit: fields[10] as String?, - isActive: fields[11] as bool, - isFeatured: fields[12] as bool, - erpnextItemCode: fields[13] as String?, - createdAt: fields[14] as DateTime, - updatedAt: fields[15] as DateTime?, + thumbnail: fields[5] as String?, + imageCaptions: fields[6] as String?, + customLink360: fields[7] as String?, + specifications: fields[8] as String?, + category: fields[9] as String?, + brand: fields[10] as String?, + unit: fields[11] as String?, + isActive: fields[12] as bool, + isFeatured: fields[13] as bool, + erpnextItemCode: fields[14] as String?, + createdAt: fields[15] as DateTime, + updatedAt: fields[16] as DateTime?, ); } @override void write(BinaryWriter writer, ProductModel obj) { writer - ..writeByte(16) + ..writeByte(17) ..writeByte(0) ..write(obj.productId) ..writeByte(1) @@ -51,26 +52,28 @@ class ProductModelAdapter extends TypeAdapter { ..writeByte(4) ..write(obj.images) ..writeByte(5) - ..write(obj.imageCaptions) + ..write(obj.thumbnail) ..writeByte(6) - ..write(obj.link360) + ..write(obj.imageCaptions) ..writeByte(7) - ..write(obj.specifications) + ..write(obj.customLink360) ..writeByte(8) - ..write(obj.category) + ..write(obj.specifications) ..writeByte(9) - ..write(obj.brand) + ..write(obj.category) ..writeByte(10) - ..write(obj.unit) + ..write(obj.brand) ..writeByte(11) - ..write(obj.isActive) + ..write(obj.unit) ..writeByte(12) - ..write(obj.isFeatured) + ..write(obj.isActive) ..writeByte(13) - ..write(obj.erpnextItemCode) + ..write(obj.isFeatured) ..writeByte(14) - ..write(obj.createdAt) + ..write(obj.erpnextItemCode) ..writeByte(15) + ..write(obj.createdAt) + ..writeByte(16) ..write(obj.updatedAt); } diff --git a/lib/features/products/data/repositories/products_repository_impl.dart b/lib/features/products/data/repositories/products_repository_impl.dart index ba561c2..6766581 100644 --- a/lib/features/products/data/repositories/products_repository_impl.dart +++ b/lib/features/products/data/repositories/products_repository_impl.dart @@ -1,10 +1,11 @@ /// Repository Implementation: Products Repository /// /// Concrete implementation of the products repository interface. -/// Handles data from local datasource and converts to domain entities. +/// Handles data from remote datasource (Frappe API) and converts to domain entities. library; import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; +import 'package:worker/features/products/data/datasources/products_remote_datasource.dart'; import 'package:worker/features/products/domain/entities/category.dart'; import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/repositories/products_repository.dart'; @@ -12,61 +13,77 @@ import 'package:worker/features/products/domain/repositories/products_repository /// Products Repository Implementation /// /// Implements the repository interface defined in the domain layer. -/// Coordinates data from local datasource and converts models to entities. +/// Uses remote datasource (Frappe API) for fetching products. +/// Local datasource is kept for categories (mock data). class ProductsRepositoryImpl implements ProductsRepository { final ProductsLocalDataSource localDataSource; + final ProductsRemoteDataSource remoteDataSource; - const ProductsRepositoryImpl({required this.localDataSource}); + const ProductsRepositoryImpl({ + required this.localDataSource, + required this.remoteDataSource, + }); @override Future> getAllProducts() async { try { - final productModels = await localDataSource.getAllProducts(); + // Fetch from Frappe API + final productModels = await remoteDataSource.getAllProducts(); return productModels.map((model) => model.toEntity()).toList(); } catch (e) { - throw Exception('Failed to get products: $e'); + print('[ProductsRepository] Error getting products: $e'); + rethrow; // Re-throw to let providers handle the error } } @override Future> searchProducts(String query) async { try { - final productModels = await localDataSource.searchProducts(query); + // Search via remote API + final productModels = await remoteDataSource.searchProducts(query); return productModels.map((model) => model.toEntity()).toList(); } catch (e) { - throw Exception('Failed to search products: $e'); + print('[ProductsRepository] Error searching products: $e'); + rethrow; } } @override Future> getProductsByCategory(String categoryId) async { try { - final productModels = await localDataSource.getProductsByCategory( + // Filter by category via remote API + final productModels = await remoteDataSource.getProductsByCategory( categoryId, ); return productModels.map((model) => model.toEntity()).toList(); } catch (e) { - throw Exception('Failed to get products by category: $e'); + print('[ProductsRepository] Error getting products by category: $e'); + rethrow; } } @override Future getProductById(String id) async { try { - final productModel = await localDataSource.getProductById(id); + // Fetch product detail from Frappe API + final productModel = await remoteDataSource.getProductDetail(id); return productModel.toEntity(); } catch (e) { - throw Exception('Failed to get product: $e'); + print('[ProductsRepository] Error getting product: $e'); + rethrow; } } @override Future> getCategories() async { try { + // For now, use local mock categories + // TODO: Fetch categories from Frappe API if available final categoryModels = await localDataSource.getCategories(); return categoryModels.map((model) => model.toEntity()).toList(); } catch (e) { - throw Exception('Failed to get categories: $e'); + print('[ProductsRepository] Error getting categories: $e'); + rethrow; } } } diff --git a/lib/features/products/domain/entities/product.dart b/lib/features/products/domain/entities/product.dart index b8aa290..7930e9b 100644 --- a/lib/features/products/domain/entities/product.dart +++ b/lib/features/products/domain/entities/product.dart @@ -24,11 +24,14 @@ class Product { /// Product images (URLs) final List images; + /// Thumbnail image URL + final String? thumbnail; + /// Image captions final Map imageCaptions; - /// 360-degree view link - final String? link360; + /// Custom 360-degree view link + final String? customLink360; /// Product specifications final Map specifications; @@ -63,8 +66,9 @@ class Product { this.description, required this.basePrice, required this.images, + this.thumbnail, required this.imageCaptions, - this.link360, + this.customLink360, required this.specifications, this.category, this.brand, @@ -86,7 +90,7 @@ class Product { String? get categoryId => category; /// Check if product has 360 view - bool get has360View => link360 != null && link360!.isNotEmpty; + bool get has360View => customLink360 != null && customLink360!.isNotEmpty; /// Check if product has multiple images bool get hasMultipleImages => images.length > 1; @@ -123,8 +127,9 @@ class Product { String? description, double? basePrice, List? images, + String? thumbnail, Map? imageCaptions, - String? link360, + String? customLink360, Map? specifications, String? category, String? brand, @@ -141,8 +146,9 @@ class Product { description: description ?? this.description, basePrice: basePrice ?? this.basePrice, images: images ?? this.images, + thumbnail: thumbnail ?? this.thumbnail, imageCaptions: imageCaptions ?? this.imageCaptions, - link360: link360 ?? this.link360, + customLink360: customLink360 ?? this.customLink360, specifications: specifications ?? this.specifications, category: category ?? this.category, brand: brand ?? this.brand, diff --git a/lib/features/products/presentation/providers/categories_provider.dart b/lib/features/products/presentation/providers/categories_provider.dart index 62fc41a..433da0c 100644 --- a/lib/features/products/presentation/providers/categories_provider.dart +++ b/lib/features/products/presentation/providers/categories_provider.dart @@ -4,10 +4,9 @@ library; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; -import 'package:worker/features/products/data/repositories/products_repository_impl.dart'; import 'package:worker/features/products/domain/entities/category.dart'; import 'package:worker/features/products/domain/usecases/get_categories.dart'; +import 'package:worker/features/products/presentation/providers/products_provider.dart'; part 'categories_provider.g.dart'; @@ -28,8 +27,8 @@ part 'categories_provider.g.dart'; /// ``` @riverpod Future> categories(Ref ref) async { - final localDataSource = const ProductsLocalDataSourceImpl(); - final repository = ProductsRepositoryImpl(localDataSource: localDataSource); + // Get products repository with injected dependencies + final repository = await ref.watch(productsRepositoryProvider.future); final useCase = GetCategories(repository); return await useCase(); diff --git a/lib/features/products/presentation/providers/categories_provider.g.dart b/lib/features/products/presentation/providers/categories_provider.g.dart index 7cbb97c..6700dd4 100644 --- a/lib/features/products/presentation/providers/categories_provider.g.dart +++ b/lib/features/products/presentation/providers/categories_provider.g.dart @@ -92,4 +92,4 @@ final class CategoriesProvider } } -String _$categoriesHash() => r'6de35d3271d6d6572d9cdf5ed68edd26036115fc'; +String _$categoriesHash() => r'811c668d2624bbc198bc7c563ed14c7d9ffc390b'; diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index 1d83719..d883473 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -2,12 +2,17 @@ /// /// Manages the state of products data using Riverpod. /// Provides filtered products based on category and search query. +/// Fetches data from Frappe ERPNext API via remote data source. library; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/core/services/frappe_auth_provider.dart'; import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; +import 'package:worker/features/products/data/datasources/products_remote_datasource.dart'; import 'package:worker/features/products/data/repositories/products_repository_impl.dart'; import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/domain/repositories/products_repository.dart'; import 'package:worker/features/products/domain/usecases/get_products.dart'; import 'package:worker/features/products/domain/usecases/search_products.dart'; import 'package:worker/features/products/presentation/providers/selected_category_provider.dart'; @@ -15,10 +20,36 @@ import 'package:worker/features/products/presentation/providers/search_query_pro part 'products_provider.g.dart'; +/// Products Local DataSource Provider +@riverpod +ProductsLocalDataSource productsLocalDataSource(Ref ref) { + return const ProductsLocalDataSourceImpl(); +} + +/// Products Remote DataSource Provider +@riverpod +Future productsRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + final frappeAuthService = ref.watch(frappeAuthServiceProvider); + return ProductsRemoteDataSource(dioClient, frappeAuthService); +} + +/// Products Repository Provider +@riverpod +Future productsRepository(Ref ref) async { + final localDataSource = ref.watch(productsLocalDataSourceProvider); + final remoteDataSource = await ref.watch(productsRemoteDataSourceProvider.future); + return ProductsRepositoryImpl( + localDataSource: localDataSource, + remoteDataSource: remoteDataSource, + ); +} + /// Products Provider /// /// Fetches and filters products based on selected category and search query. /// Automatically updates when category or search query changes. +/// Data is fetched from Frappe ERPNext API. /// /// Usage: /// ```dart @@ -38,9 +69,8 @@ class Products extends _$Products { final selectedCategory = ref.watch(selectedCategoryProvider); final searchQuery = ref.watch(searchQueryProvider); - // Initialize dependencies - final localDataSource = const ProductsLocalDataSourceImpl(); - final repository = ProductsRepositoryImpl(localDataSource: localDataSource); + // Get repository with injected data sources + final repository = await ref.watch(productsRepositoryProvider.future); // Apply filters List products; @@ -78,10 +108,10 @@ class Products extends _$Products { /// /// Provides all products without any filtering. /// Useful for product selection dialogs, etc. +/// Fetches from Frappe ERPNext API. @riverpod Future> allProducts(Ref ref) async { - final localDataSource = const ProductsLocalDataSourceImpl(); - final repository = ProductsRepositoryImpl(localDataSource: localDataSource); + final repository = await ref.watch(productsRepositoryProvider.future); final getProductsUseCase = GetProducts(repository); return await getProductsUseCase(); diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart index 4db09b1..b3e0a74 100644 --- a/lib/features/products/presentation/providers/products_provider.g.dart +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -8,10 +8,158 @@ part of 'products_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Products Local DataSource Provider + +@ProviderFor(productsLocalDataSource) +const productsLocalDataSourceProvider = ProductsLocalDataSourceProvider._(); + +/// Products Local DataSource Provider + +final class ProductsLocalDataSourceProvider + extends + $FunctionalProvider< + ProductsLocalDataSource, + ProductsLocalDataSource, + ProductsLocalDataSource + > + with $Provider { + /// Products Local DataSource Provider + const ProductsLocalDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'productsLocalDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productsLocalDataSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ProductsLocalDataSource create(Ref ref) { + return productsLocalDataSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ProductsLocalDataSource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$productsLocalDataSourceHash() => + r'c9f87b0affb7b86b890c38175b2b5ff328b7cfa4'; + +/// Products Remote DataSource Provider + +@ProviderFor(productsRemoteDataSource) +const productsRemoteDataSourceProvider = ProductsRemoteDataSourceProvider._(); + +/// Products Remote DataSource Provider + +final class ProductsRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + ProductsRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Products Remote DataSource Provider + const ProductsRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'productsRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productsRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return productsRemoteDataSource(ref); + } +} + +String _$productsRemoteDataSourceHash() => + r'4f08cec50d95b05954ca18a7b04d5f09d1ffd059'; + +/// Products Repository Provider + +@ProviderFor(productsRepository) +const productsRepositoryProvider = ProductsRepositoryProvider._(); + +/// Products Repository Provider + +final class ProductsRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + ProductsRepository, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Products Repository Provider + const ProductsRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'productsRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productsRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return productsRepository(ref); + } +} + +String _$productsRepositoryHash() => + r'f1ddd3ec17bbbb70e87a8d47e7c58f681c80c2ae'; + /// Products Provider /// /// Fetches and filters products based on selected category and search query. /// Automatically updates when category or search query changes. +/// Data is fetched from Frappe ERPNext API. /// /// Usage: /// ```dart @@ -31,6 +179,7 @@ const productsProvider = ProductsProvider._(); /// /// Fetches and filters products based on selected category and search query. /// Automatically updates when category or search query changes. +/// Data is fetched from Frappe ERPNext API. /// /// Usage: /// ```dart @@ -48,6 +197,7 @@ final class ProductsProvider /// /// Fetches and filters products based on selected category and search query. /// Automatically updates when category or search query changes. + /// Data is fetched from Frappe ERPNext API. /// /// Usage: /// ```dart @@ -78,12 +228,13 @@ final class ProductsProvider Products create() => Products(); } -String _$productsHash() => r'0f1b32d2c14b9d8d600ffb0270f54d32af753e1f'; +String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf'; /// Products Provider /// /// Fetches and filters products based on selected category and search query. /// Automatically updates when category or search query changes. +/// Data is fetched from Frappe ERPNext API. /// /// Usage: /// ```dart @@ -119,6 +270,7 @@ abstract class _$Products extends $AsyncNotifier> { /// /// Provides all products without any filtering. /// Useful for product selection dialogs, etc. +/// Fetches from Frappe ERPNext API. @ProviderFor(allProducts) const allProductsProvider = AllProductsProvider._(); @@ -127,6 +279,7 @@ const allProductsProvider = AllProductsProvider._(); /// /// Provides all products without any filtering. /// Useful for product selection dialogs, etc. +/// Fetches from Frappe ERPNext API. final class AllProductsProvider extends @@ -140,6 +293,7 @@ final class AllProductsProvider /// /// Provides all products without any filtering. /// Useful for product selection dialogs, etc. + /// Fetches from Frappe ERPNext API. const AllProductsProvider._() : super( from: null, @@ -166,4 +320,4 @@ final class AllProductsProvider } } -String _$allProductsHash() => r'a02e989ad36e644d9b62e681b3ced88e10e4d4c3'; +String _$allProductsHash() => r'402d7c6e8d119c7c7eab5e696fb8163831259def'; diff --git a/lib/features/products/presentation/widgets/product_card.dart b/lib/features/products/presentation/widgets/product_card.dart index 0296d72..619fc1f 100644 --- a/lib/features/products/presentation/widgets/product_card.dart +++ b/lib/features/products/presentation/widgets/product_card.dart @@ -61,7 +61,7 @@ class ProductCard extends ConsumerWidget { top: Radius.circular(ProductCardSpecs.borderRadius), ), child: CachedNetworkImage( - imageUrl: product.imageUrl, + imageUrl: product.thumbnail ?? '', width: double.infinity, height: double.infinity, fit: BoxFit.cover, diff --git a/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart b/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart index 33029cb..187b488 100644 --- a/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/image_gallery_section.dart @@ -48,7 +48,7 @@ class _ImageGallerySectionState extends State { } void _open360View() { - if (widget.product.link360 != null) { + if (widget.product.customLink360 != null) { // TODO: Open in browser ScaffoldMessenger.of(context).showSnackBar( const SnackBar(