load products
This commit is contained in:
42
docs/products.sh
Normal file
42
docs/products.sh
Normal file
@@ -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"
|
||||
}'
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<List<Product>> 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
|
||||
|
||||
@@ -440,4 +440,4 @@ final class FavoriteProductsProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$favoriteProductsHash() => r'6f48aa57781b0276ad72928e6b54b04fc53b0d7e';
|
||||
String _$favoriteProductsHash() => r'630acfbc403cc4deb486c7b0199f128252a8990b';
|
||||
|
||||
@@ -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<List<ProductModel>> 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<Map<String, dynamic>>(
|
||||
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<String, dynamic>))
|
||||
.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<ProductModel> 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<Map<String, dynamic>>(
|
||||
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<String, dynamic>);
|
||||
} 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<List<ProductModel>> 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<List<ProductModel>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
|
||||
@@ -22,24 +22,25 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
||||
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<ProductModel> {
|
||||
..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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<List<Product>> 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<List<Product>> 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<List<Product>> 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<Product> 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<List<Category>> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,14 @@ class Product {
|
||||
/// Product images (URLs)
|
||||
final List<String> images;
|
||||
|
||||
/// Thumbnail image URL
|
||||
final String? thumbnail;
|
||||
|
||||
/// Image captions
|
||||
final Map<String, String> imageCaptions;
|
||||
|
||||
/// 360-degree view link
|
||||
final String? link360;
|
||||
/// Custom 360-degree view link
|
||||
final String? customLink360;
|
||||
|
||||
/// Product specifications
|
||||
final Map<String, dynamic> 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<String>? images,
|
||||
String? thumbnail,
|
||||
Map<String, String>? imageCaptions,
|
||||
String? link360,
|
||||
String? customLink360,
|
||||
Map<String, dynamic>? 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,
|
||||
|
||||
@@ -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<List<Category>> 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();
|
||||
|
||||
@@ -92,4 +92,4 @@ final class CategoriesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoriesHash() => r'6de35d3271d6d6572d9cdf5ed68edd26036115fc';
|
||||
String _$categoriesHash() => r'811c668d2624bbc198bc7c563ed14c7d9ffc390b';
|
||||
|
||||
@@ -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> 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> 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<Product> 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<List<Product>> 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();
|
||||
|
||||
@@ -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<ProductsLocalDataSource> {
|
||||
/// 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<ProductsLocalDataSource> $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<ProductsLocalDataSource>(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>,
|
||||
ProductsRemoteDataSource,
|
||||
FutureOr<ProductsRemoteDataSource>
|
||||
>
|
||||
with
|
||||
$FutureModifier<ProductsRemoteDataSource>,
|
||||
$FutureProvider<ProductsRemoteDataSource> {
|
||||
/// 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<ProductsRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<ProductsRemoteDataSource> 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>,
|
||||
ProductsRepository,
|
||||
FutureOr<ProductsRepository>
|
||||
>
|
||||
with
|
||||
$FutureModifier<ProductsRepository>,
|
||||
$FutureProvider<ProductsRepository> {
|
||||
/// 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<ProductsRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<ProductsRepository> 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<List<Product>> {
|
||||
///
|
||||
/// 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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,7 +48,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
|
||||
}
|
||||
|
||||
void _open360View() {
|
||||
if (widget.product.link360 != null) {
|
||||
if (widget.product.customLink360 != null) {
|
||||
// TODO: Open in browser
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
||||
Reference in New Issue
Block a user