update product detail

This commit is contained in:
Phuoc Nguyen
2025-11-19 16:07:54 +07:00
parent 841d77d886
commit 73ad2fc80c
5 changed files with 214 additions and 166 deletions

View File

@@ -81,7 +81,7 @@ class ProductsRemoteDataSource {
final productsList = message as List;
return productsList
.map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>),
(item) => ProductModel.fromJson(item as Map<String, dynamic>),
)
.toList();
} on DioException catch (e) {
@@ -138,7 +138,7 @@ class ProductsRemoteDataSource {
throw Exception('Product not found: $itemCode');
}
return ProductModel.fromFrappeJson(message as Map<String, dynamic>);
return ProductModel.fromJson(message as Map<String, dynamic>);
} 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<String, dynamic>),
(item) => ProductModel.fromJson(item as Map<String, dynamic>),
)
.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<String, dynamic>),
(item) => ProductModel.fromJson(item as Map<String, dynamic>),
)
.toList();
} on DioException catch (e) {

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
final now = DateTime.now();
// Handle image_list array (from product detail API)
// Parse image_list array
final List<String> imagesList = [];
final Map<String, String> 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<String, dynamic> specificationsMap = {};
@@ -233,7 +172,7 @@ class ProductModel extends HiveObject {
}
}
// Parse intro_attributes array for quick reference
// Parse intro_attributes array
final List<Map<String, String>> 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? ?? '',
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? ?? '',
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,

View File

@@ -26,7 +26,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
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<ProductModel> {
..writeByte(8)
..write(obj.specifications)
..writeByte(9)
..write(obj.category)
..write(obj.itemGroupName)
..writeByte(10)
..write(obj.brand)
..writeByte(11)

View File

@@ -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<String, dynamic> 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<String, String>? imageCaptions,
String? customLink360,
Map<String, dynamic>? 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,

View File

@@ -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,
),
),
),
],
),
),
);
}),
],
),
);
}