diff --git a/lib/features/products/data/datasources/products_remote_datasource.dart b/lib/features/products/data/datasources/products_remote_datasource.dart index bd2f762..8515bfd 100644 --- a/lib/features/products/data/datasources/products_remote_datasource.dart +++ b/lib/features/products/data/datasources/products_remote_datasource.dart @@ -81,7 +81,7 @@ class ProductsRemoteDataSource { final productsList = message as List; return productsList .map( - (item) => ProductModel.fromFrappeJson(item as Map), + (item) => ProductModel.fromJson(item as Map), ) .toList(); } on DioException catch (e) { @@ -138,7 +138,7 @@ class ProductsRemoteDataSource { throw Exception('Product not found: $itemCode'); } - return ProductModel.fromFrappeJson(message as Map); + return ProductModel.fromJson(message as Map); } on DioException catch (e) { if (e.response?.statusCode == 404) { throw Exception('Product not found: $itemCode'); @@ -206,7 +206,7 @@ class ProductsRemoteDataSource { final productsList = message as List; return productsList .map( - (item) => ProductModel.fromFrappeJson(item as Map), + (item) => ProductModel.fromJson(item as Map), ) .toList(); } on DioException catch (e) { @@ -248,7 +248,7 @@ class ProductsRemoteDataSource { } return allProducts - .where((product) => product.category == categoryId) + .where((product) => product.itemGroupName == categoryId) .toList(); } @@ -495,7 +495,7 @@ class ProductsRemoteDataSource { final productsList = message as List; return productsList .map( - (item) => ProductModel.fromFrappeJson(item as Map), + (item) => ProductModel.fromJson(item as Map), ) .toList(); } on DioException catch (e) { diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart index 7c92bb1..bf9c14c 100644 --- a/lib/features/products/data/models/product_model.dart +++ b/lib/features/products/data/models/product_model.dart @@ -26,7 +26,7 @@ class ProductModel extends HiveObject { this.imageCaptions, this.customLink360, this.specifications, - this.category, + this.itemGroupName, this.brand, this.unit, this.conversionOfSm, @@ -75,9 +75,9 @@ class ProductModel extends HiveObject { @HiveField(8) final String? specifications; - /// Product category + /// Item group name (from ERPNext) @HiveField(9) - final String? category; + final String? itemGroupName; /// Product brand @HiveField(10) @@ -122,72 +122,24 @@ class ProductModel extends HiveObject { // JSON SERIALIZATION // ========================================================================= - /// Create ProductModel from JSON - factory ProductModel.fromJson(Map json) { - return ProductModel( - productId: json['product_id'] as String, - name: json['name'] as String, - 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, - customLink360: json['custom_link_360'] as String?, - specifications: json['specifications'] != null - ? jsonEncode(json['specifications']) - : null, - category: json['category'] as String?, - brand: json['brand'] as String?, - unit: json['unit'] as String?, - conversionOfSm: json['conversion_of_sm'] != null - ? (json['conversion_of_sm'] as num).toDouble() - : null, - introAttributes: json['intro_attributes'] != null - ? jsonEncode(json['intro_attributes']) - : null, - isActive: json['is_active'] as bool? ?? true, - isFeatured: json['is_featured'] as bool? ?? false, - erpnextItemCode: json['erpnext_item_code'] as String?, - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at'] as String) - : null, - ); - } - - /// Create ProductModel from Frappe ERPNext API JSON + /// Create ProductModel from API JSON /// - /// Maps Frappe Item doctype fields to our ProductModel structure. - /// Frappe fields: + /// Handles the new Frappe ERPNext API structure with full image URLs. + /// API response structure: /// - name: Item code (e.g., "CHG S01P") /// - item_name: Display name - /// - description: Product description - /// - price: Price (from product detail API) - /// - standard_rate: Price (from product list API) - /// - stock_uom: Unit of measurement - /// - thumbnail: Thumbnail image URL - /// - image_list: Array of images with image_url + /// - item_group_name: Category /// - brand: Brand name - /// - item_group_name: Category/group name - /// - custom_link_360: Custom 360 view link - /// - attributes: Array of product attributes (Size, Color, Surface, etc.) - factory ProductModel.fromFrappeJson(Map json) { - // 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 = '${ApiConstants.baseUrl}$thumbnailPath'; - } else if (thumbnailPath.startsWith('http')) { - thumbnailUrl = thumbnailPath; - } else { - thumbnailUrl = '${ApiConstants.baseUrl}/$thumbnailPath'; - } - } + /// - thumbnail: Full image URL + /// - image_list: Array of {image_name, image_url, description} + /// - price: Product price + /// - conversion_of_sm: Conversion factor + /// - intro_attributes: Array of {code, value} + /// - attributes: Array of {attribute, attribute_name, attribute_value} + factory ProductModel.fromJson(Map json) { + final now = DateTime.now(); - // Handle image_list array (from product detail API) + // Parse image_list array final List imagesList = []; final Map imageCaptionsMap = {}; @@ -198,26 +150,13 @@ class ProductModel extends HiveObject { final imageUrl = imgData['image_url'] as String; imagesList.add(imageUrl); - // Store image caption (image_name: A, B, C, etc.) + // Store image caption (image_name: Face A, Face B, etc.) if (imgData['image_name'] != null) { imageCaptionsMap[imageUrl] = imgData['image_name'] as String; } } } } - // Fallback to single image field (from product list API) - else if (json['image'] != null && (json['image'] as String).isNotEmpty) { - final imagePath = json['image'] as String; - String imageUrl; - if (imagePath.startsWith('/')) { - imageUrl = '${ApiConstants.baseUrl}$imagePath'; - } else if (imagePath.startsWith('http')) { - imageUrl = imagePath; - } else { - imageUrl = '${ApiConstants.baseUrl}/$imagePath'; - } - imagesList.add(imageUrl); - } // Parse attributes array into specifications map final Map specificationsMap = {}; @@ -233,7 +172,7 @@ class ProductModel extends HiveObject { } } - // Parse intro_attributes array for quick reference + // Parse intro_attributes array final List> introAttributesList = []; if (json['intro_attributes'] != null && json['intro_attributes'] is List) { final introAttrsData = json['intro_attributes'] as List; @@ -249,20 +188,13 @@ class ProductModel extends HiveObject { } } - final now = DateTime.now(); - - // Handle price from both product detail (price) and product list (standard_rate) - final price = (json['price'] as num?)?.toDouble() ?? - (json['standard_rate'] as num?)?.toDouble() ?? - 0.0; - 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: price, + description: json['description'] as String?, // May be null in API + basePrice: (json['price'] as num?)?.toDouble() ?? 0.0, images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null, - thumbnail: thumbnailUrl ?? '', + thumbnail: json['thumbnail'] as String? ?? '', imageCaptions: imageCaptionsMap.isNotEmpty ? jsonEncode(imageCaptionsMap) : null, @@ -270,28 +202,24 @@ class ProductModel extends HiveObject { specifications: specificationsMap.isNotEmpty ? jsonEncode(specificationsMap) : null, - category: json['item_group_name'] as String? ?? - json['item_group'] as String?, // Try item_group_name first, fallback to item_group + itemGroupName: json['item_group_name'] as String?, brand: json['brand'] as String?, - unit: json['stock_uom'] as String? ?? 'm²', + unit: json['currency'] as String?, // Use currency as unit for now conversionOfSm: json['conversion_of_sm'] != null ? (json['conversion_of_sm'] as num).toDouble() : null, introAttributes: introAttributesList.isNotEmpty ? jsonEncode(introAttributesList) : null, - 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, + isActive: true, // Assume active if returned by API + isFeatured: false, // Not provided by API + erpnextItemCode: json['item_code'] as String? ?? json['name'] as String, + createdAt: now, // Not provided by this API endpoint + updatedAt: now, ); } + /// Create ProductModel from Wishlist API JSON /// /// The wishlist API returns a simplified product structure: @@ -330,7 +258,7 @@ class ProductModel extends HiveObject { imageCaptions: null, customLink360: json['custom_link_360'] as String?, specifications: null, - category: json['item_group_name'] as String?, + itemGroupName: json['item_group_name'] as String?, brand: null, // Not provided by wishlist API unit: json['currency'] as String? ?? 'm²', conversionOfSm: json['conversion_of_sm'] != null @@ -361,7 +289,7 @@ class ProductModel extends HiveObject { 'specifications': specifications != null ? jsonDecode(specifications!) : null, - 'category': category, + 'item_group_name': itemGroupName, 'brand': brand, 'unit': unit, 'conversion_of_sm': conversionOfSm, @@ -476,7 +404,7 @@ class ProductModel extends HiveObject { String? imageCaptions, String? customLink360, String? specifications, - String? category, + String? itemGroupName, String? brand, String? unit, double? conversionOfSm, @@ -497,7 +425,7 @@ class ProductModel extends HiveObject { imageCaptions: imageCaptions ?? this.imageCaptions, customLink360: customLink360 ?? this.customLink360, specifications: specifications ?? this.specifications, - category: category ?? this.category, + itemGroupName: itemGroupName ?? this.itemGroupName, brand: brand ?? this.brand, unit: unit ?? this.unit, conversionOfSm: conversionOfSm ?? this.conversionOfSm, @@ -541,7 +469,7 @@ class ProductModel extends HiveObject { imageCaptions: imageCaptionsMap ?? {}, customLink360: customLink360, specifications: specificationsMap ?? {}, - category: category, + itemGroupName: itemGroupName, brand: brand, unit: unit, conversionOfSm: conversionOfSm, diff --git a/lib/features/products/data/models/product_model.g.dart b/lib/features/products/data/models/product_model.g.dart index e06bf48..e506a19 100644 --- a/lib/features/products/data/models/product_model.g.dart +++ b/lib/features/products/data/models/product_model.g.dart @@ -26,7 +26,7 @@ class ProductModelAdapter extends TypeAdapter { imageCaptions: fields[6] as String?, customLink360: fields[7] as String?, specifications: fields[8] as String?, - category: fields[9] as String?, + itemGroupName: fields[9] as String?, brand: fields[10] as String?, unit: fields[11] as String?, conversionOfSm: (fields[17] as num?)?.toDouble(), @@ -62,7 +62,7 @@ class ProductModelAdapter extends TypeAdapter { ..writeByte(8) ..write(obj.specifications) ..writeByte(9) - ..write(obj.category) + ..write(obj.itemGroupName) ..writeByte(10) ..write(obj.brand) ..writeByte(11) diff --git a/lib/features/products/domain/entities/product.dart b/lib/features/products/domain/entities/product.dart index e6c16af..db9d230 100644 --- a/lib/features/products/domain/entities/product.dart +++ b/lib/features/products/domain/entities/product.dart @@ -20,7 +20,7 @@ class Product { required this.imageCaptions, this.customLink360, required this.specifications, - this.category, + this.itemGroupName, this.brand, this.unit, this.conversionOfSm, @@ -58,8 +58,8 @@ class Product { /// Product specifications final Map specifications; - /// Category name - final String? category; + /// Item group name (from ERPNext) + final String? itemGroupName; /// Brand name final String? brand; @@ -97,8 +97,8 @@ class Product { /// Alias for primaryImage (used by UI widgets) String get imageUrl => primaryImage ?? ''; - /// Category ID (alias for category field) - String? get categoryId => category; + /// Category ID (alias for itemGroupName field) + String? get categoryId => itemGroupName; /// Check if product has 360 view bool get has360View => customLink360 != null && customLink360!.isNotEmpty; @@ -152,7 +152,7 @@ class Product { Map? imageCaptions, String? customLink360, Map? specifications, - String? category, + String? itemGroupName, String? brand, String? unit, double? conversionOfSm, @@ -173,7 +173,7 @@ class Product { imageCaptions: imageCaptions ?? this.imageCaptions, customLink360: customLink360 ?? this.customLink360, specifications: specifications ?? this.specifications, - category: category ?? this.category, + itemGroupName: itemGroupName ?? this.itemGroupName, brand: brand ?? this.brand, unit: unit ?? this.unit, conversionOfSm: conversionOfSm ?? this.conversionOfSm, @@ -189,7 +189,7 @@ class Product { @override String toString() { return 'Product(productId: $productId, name: $name, basePrice: $basePrice, ' - 'category: $category, isActive: $isActive, isFeatured: $isFeatured)'; + 'itemGroupName: $itemGroupName, isActive: $isActive, isFeatured: $isFeatured)'; } @override @@ -201,7 +201,7 @@ class Product { other.name == name && other.description == description && other.basePrice == basePrice && - other.category == category && + other.itemGroupName == itemGroupName && other.brand == brand && other.unit == unit && other.isActive == isActive && @@ -216,7 +216,7 @@ class Product { name, description, basePrice, - category, + itemGroupName, brand, unit, isActive, diff --git a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart index b0d888d..1a988d3 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart @@ -228,64 +228,184 @@ class _SpecificationsTab extends StatelessWidget { }; return Container( - margin: const EdgeInsets.all(20), + margin: const EdgeInsets.all(12), decoration: BoxDecoration( border: Border.all(color: const Color(0xFFe0e0e0)), borderRadius: BorderRadius.circular(8), ), child: Column( - children: specs.entries.map((entry) { - final isLast = entry == specs.entries.last; - return Container( - decoration: BoxDecoration( - border: isLast - ? null - : const Border( - bottom: BorderSide(color: Color(0xFFe0e0e0)), - ), + children: [ + Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFe0e0e0)), + ), ), - child: Row( - children: [ - // Label - Expanded( - child: Container( - padding: const EdgeInsets.all(12), - color: const Color(0xFFF4F6F8), - child: Text( - entry.key, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.grey900, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Label + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + color: const Color(0xFFF4F6F8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "Thương hiệu", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + height: 1.5, + ), + ), ), ), ), - ), - // Divider - Container( - width: 1, - height: 44, - color: const Color(0xFFe0e0e0), - ), + // Divider + Container( + width: 1, + color: const Color(0xFFe0e0e0), + ), - // Value - Expanded( - child: Container( - padding: const EdgeInsets.all(12), - child: Text( - entry.value.toString(), - style: const TextStyle( - fontSize: 14, - color: AppColors.grey900, + // Value + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Text( + '${product.brand}', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + height: 1.5, + ), + softWrap: true, ), ), ), - ), - ], + ], + ), ), - ); - }).toList(), + ), + Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFe0e0e0)), + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Label + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + color: const Color(0xFFF4F6F8), + child: const Align( + alignment: Alignment.centerLeft, + child: Text( + "Dòng sản phẩm", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + height: 1.5, + ), + ), + ), + ), + ), + + // Divider + Container( + width: 1, + color: const Color(0xFFe0e0e0), + ), + + // Value + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Text( + '${product.itemGroupName}', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + height: 1.5, + ), + softWrap: true, + ), + ), + ), + ], + ), + ), + ), + ...specs.entries.map((entry) { + final isLast = entry == specs.entries.last; + return Container( + decoration: BoxDecoration( + border: isLast + ? null + : const Border( + bottom: BorderSide(color: Color(0xFFe0e0e0)), + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Label + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + color: const Color(0xFFF4F6F8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + entry.key, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + height: 1.5, + ), + ), + ), + ), + ), + + // Divider + Container( + width: 1, + color: const Color(0xFFe0e0e0), + ), + + // Value + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Text( + '${entry.value}', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + height: 1.5, + ), + softWrap: true, + ), + ), + ), + ], + ), + ), + ); + }), + ], ), ); }