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; final productsList = message as List;
return productsList return productsList
.map( .map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>), (item) => ProductModel.fromJson(item as Map<String, dynamic>),
) )
.toList(); .toList();
} on DioException catch (e) { } on DioException catch (e) {
@@ -138,7 +138,7 @@ class ProductsRemoteDataSource {
throw Exception('Product not found: $itemCode'); 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) { } on DioException catch (e) {
if (e.response?.statusCode == 404) { if (e.response?.statusCode == 404) {
throw Exception('Product not found: $itemCode'); throw Exception('Product not found: $itemCode');
@@ -206,7 +206,7 @@ class ProductsRemoteDataSource {
final productsList = message as List; final productsList = message as List;
return productsList return productsList
.map( .map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>), (item) => ProductModel.fromJson(item as Map<String, dynamic>),
) )
.toList(); .toList();
} on DioException catch (e) { } on DioException catch (e) {
@@ -248,7 +248,7 @@ class ProductsRemoteDataSource {
} }
return allProducts return allProducts
.where((product) => product.category == categoryId) .where((product) => product.itemGroupName == categoryId)
.toList(); .toList();
} }
@@ -495,7 +495,7 @@ class ProductsRemoteDataSource {
final productsList = message as List; final productsList = message as List;
return productsList return productsList
.map( .map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>), (item) => ProductModel.fromJson(item as Map<String, dynamic>),
) )
.toList(); .toList();
} on DioException catch (e) { } on DioException catch (e) {

View File

@@ -26,7 +26,7 @@ class ProductModel extends HiveObject {
this.imageCaptions, this.imageCaptions,
this.customLink360, this.customLink360,
this.specifications, this.specifications,
this.category, this.itemGroupName,
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
@@ -75,9 +75,9 @@ class ProductModel extends HiveObject {
@HiveField(8) @HiveField(8)
final String? specifications; final String? specifications;
/// Product category /// Item group name (from ERPNext)
@HiveField(9) @HiveField(9)
final String? category; final String? itemGroupName;
/// Product brand /// Product brand
@HiveField(10) @HiveField(10)
@@ -122,72 +122,24 @@ class ProductModel extends HiveObject {
// JSON SERIALIZATION // JSON SERIALIZATION
// ========================================================================= // =========================================================================
/// Create ProductModel from JSON /// Create ProductModel from API 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
/// ///
/// Maps Frappe Item doctype fields to our ProductModel structure. /// Handles the new Frappe ERPNext API structure with full image URLs.
/// Frappe fields: /// API response structure:
/// - name: Item code (e.g., "CHG S01P") /// - name: Item code (e.g., "CHG S01P")
/// - item_name: Display name /// - item_name: Display name
/// - description: Product description /// - item_group_name: Category
/// - 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
/// - brand: Brand name /// - brand: Brand name
/// - item_group_name: Category/group name /// - thumbnail: Full image URL
/// - custom_link_360: Custom 360 view link /// - image_list: Array of {image_name, image_url, description}
/// - attributes: Array of product attributes (Size, Color, Surface, etc.) /// - price: Product price
factory ProductModel.fromFrappeJson(Map<String, dynamic> json) { /// - conversion_of_sm: Conversion factor
// Handle thumbnail - prepend base URL if needed /// - intro_attributes: Array of {code, value}
String? thumbnailUrl; /// - attributes: Array of {attribute, attribute_name, attribute_value}
if (json['thumbnail'] != null && (json['thumbnail'] as String).isNotEmpty) { factory ProductModel.fromJson(Map<String, dynamic> json) {
final thumbnailPath = json['thumbnail'] as String; final now = DateTime.now();
if (thumbnailPath.startsWith('/')) {
thumbnailUrl = '${ApiConstants.baseUrl}$thumbnailPath';
} else if (thumbnailPath.startsWith('http')) {
thumbnailUrl = thumbnailPath;
} else {
thumbnailUrl = '${ApiConstants.baseUrl}/$thumbnailPath';
}
}
// Handle image_list array (from product detail API) // Parse image_list array
final List<String> imagesList = []; final List<String> imagesList = [];
final Map<String, String> imageCaptionsMap = {}; final Map<String, String> imageCaptionsMap = {};
@@ -198,26 +150,13 @@ class ProductModel extends HiveObject {
final imageUrl = imgData['image_url'] as String; final imageUrl = imgData['image_url'] as String;
imagesList.add(imageUrl); 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) { if (imgData['image_name'] != null) {
imageCaptionsMap[imageUrl] = imgData['image_name'] as String; 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 // Parse attributes array into specifications map
final Map<String, dynamic> specificationsMap = {}; 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 = []; final List<Map<String, String>> introAttributesList = [];
if (json['intro_attributes'] != null && json['intro_attributes'] is List) { if (json['intro_attributes'] != null && json['intro_attributes'] is List) {
final introAttrsData = json['intro_attributes'] as 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( return ProductModel(
productId: json['name'] as String, // Item code productId: json['name'] as String, // Item code
name: json['item_name'] as String? ?? json['name'] as String, name: json['item_name'] as String? ?? json['name'] as String,
description: json['description'] as String?, description: json['description'] as String?, // May be null in API
basePrice: price, basePrice: (json['price'] as num?)?.toDouble() ?? 0.0,
images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null, images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null,
thumbnail: thumbnailUrl ?? '', thumbnail: json['thumbnail'] as String? ?? '',
imageCaptions: imageCaptionsMap.isNotEmpty imageCaptions: imageCaptionsMap.isNotEmpty
? jsonEncode(imageCaptionsMap) ? jsonEncode(imageCaptionsMap)
: null, : null,
@@ -270,28 +202,24 @@ class ProductModel extends HiveObject {
specifications: specificationsMap.isNotEmpty specifications: specificationsMap.isNotEmpty
? jsonEncode(specificationsMap) ? jsonEncode(specificationsMap)
: null, : null,
category: json['item_group_name'] as String? ?? itemGroupName: json['item_group_name'] as String?,
json['item_group'] as String?, // Try item_group_name first, fallback to item_group
brand: json['brand'] 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 conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble() ? (json['conversion_of_sm'] as num).toDouble()
: null, : null,
introAttributes: introAttributesList.isNotEmpty introAttributes: introAttributesList.isNotEmpty
? jsonEncode(introAttributesList) ? jsonEncode(introAttributesList)
: null, : null,
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field isActive: true, // Assume active if returned by API
isFeatured: false, // Not provided by API, default to false isFeatured: false, // Not provided by API
erpnextItemCode: json['name'] as String, // Store item code for reference erpnextItemCode: json['item_code'] as String? ?? json['name'] as String,
createdAt: json['creation'] != null createdAt: now, // Not provided by this API endpoint
? DateTime.tryParse(json['creation'] as String) ?? now updatedAt: now,
: now,
updatedAt: json['modified'] != null
? DateTime.tryParse(json['modified'] as String)
: null,
); );
} }
/// Create ProductModel from Wishlist API JSON /// Create ProductModel from Wishlist API JSON
/// ///
/// The wishlist API returns a simplified product structure: /// The wishlist API returns a simplified product structure:
@@ -330,7 +258,7 @@ class ProductModel extends HiveObject {
imageCaptions: null, imageCaptions: null,
customLink360: json['custom_link_360'] as String?, customLink360: json['custom_link_360'] as String?,
specifications: null, specifications: null,
category: json['item_group_name'] as String?, itemGroupName: json['item_group_name'] as String?,
brand: null, // Not provided by wishlist API brand: null, // Not provided by wishlist API
unit: json['currency'] as String? ?? '', unit: json['currency'] as String? ?? '',
conversionOfSm: json['conversion_of_sm'] != null conversionOfSm: json['conversion_of_sm'] != null
@@ -361,7 +289,7 @@ class ProductModel extends HiveObject {
'specifications': specifications != null 'specifications': specifications != null
? jsonDecode(specifications!) ? jsonDecode(specifications!)
: null, : null,
'category': category, 'item_group_name': itemGroupName,
'brand': brand, 'brand': brand,
'unit': unit, 'unit': unit,
'conversion_of_sm': conversionOfSm, 'conversion_of_sm': conversionOfSm,
@@ -476,7 +404,7 @@ class ProductModel extends HiveObject {
String? imageCaptions, String? imageCaptions,
String? customLink360, String? customLink360,
String? specifications, String? specifications,
String? category, String? itemGroupName,
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
@@ -497,7 +425,7 @@ class ProductModel extends HiveObject {
imageCaptions: imageCaptions ?? this.imageCaptions, imageCaptions: imageCaptions ?? this.imageCaptions,
customLink360: customLink360 ?? this.customLink360, customLink360: customLink360 ?? this.customLink360,
specifications: specifications ?? this.specifications, specifications: specifications ?? this.specifications,
category: category ?? this.category, itemGroupName: itemGroupName ?? this.itemGroupName,
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
@@ -541,7 +469,7 @@ class ProductModel extends HiveObject {
imageCaptions: imageCaptionsMap ?? {}, imageCaptions: imageCaptionsMap ?? {},
customLink360: customLink360, customLink360: customLink360,
specifications: specificationsMap ?? {}, specifications: specificationsMap ?? {},
category: category, itemGroupName: itemGroupName,
brand: brand, brand: brand,
unit: unit, unit: unit,
conversionOfSm: conversionOfSm, conversionOfSm: conversionOfSm,

View File

@@ -26,7 +26,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
imageCaptions: fields[6] as String?, imageCaptions: fields[6] as String?,
customLink360: fields[7] as String?, customLink360: fields[7] as String?,
specifications: fields[8] as String?, specifications: fields[8] as String?,
category: fields[9] as String?, itemGroupName: fields[9] as String?,
brand: fields[10] as String?, brand: fields[10] as String?,
unit: fields[11] as String?, unit: fields[11] as String?,
conversionOfSm: (fields[17] as num?)?.toDouble(), conversionOfSm: (fields[17] as num?)?.toDouble(),
@@ -62,7 +62,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
..writeByte(8) ..writeByte(8)
..write(obj.specifications) ..write(obj.specifications)
..writeByte(9) ..writeByte(9)
..write(obj.category) ..write(obj.itemGroupName)
..writeByte(10) ..writeByte(10)
..write(obj.brand) ..write(obj.brand)
..writeByte(11) ..writeByte(11)

View File

@@ -20,7 +20,7 @@ class Product {
required this.imageCaptions, required this.imageCaptions,
this.customLink360, this.customLink360,
required this.specifications, required this.specifications,
this.category, this.itemGroupName,
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
@@ -58,8 +58,8 @@ class Product {
/// Product specifications /// Product specifications
final Map<String, dynamic> specifications; final Map<String, dynamic> specifications;
/// Category name /// Item group name (from ERPNext)
final String? category; final String? itemGroupName;
/// Brand name /// Brand name
final String? brand; final String? brand;
@@ -97,8 +97,8 @@ class Product {
/// Alias for primaryImage (used by UI widgets) /// Alias for primaryImage (used by UI widgets)
String get imageUrl => primaryImage ?? ''; String get imageUrl => primaryImage ?? '';
/// Category ID (alias for category field) /// Category ID (alias for itemGroupName field)
String? get categoryId => category; String? get categoryId => itemGroupName;
/// Check if product has 360 view /// Check if product has 360 view
bool get has360View => customLink360 != null && customLink360!.isNotEmpty; bool get has360View => customLink360 != null && customLink360!.isNotEmpty;
@@ -152,7 +152,7 @@ class Product {
Map<String, String>? imageCaptions, Map<String, String>? imageCaptions,
String? customLink360, String? customLink360,
Map<String, dynamic>? specifications, Map<String, dynamic>? specifications,
String? category, String? itemGroupName,
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
@@ -173,7 +173,7 @@ class Product {
imageCaptions: imageCaptions ?? this.imageCaptions, imageCaptions: imageCaptions ?? this.imageCaptions,
customLink360: customLink360 ?? this.customLink360, customLink360: customLink360 ?? this.customLink360,
specifications: specifications ?? this.specifications, specifications: specifications ?? this.specifications,
category: category ?? this.category, itemGroupName: itemGroupName ?? this.itemGroupName,
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
@@ -189,7 +189,7 @@ class Product {
@override @override
String toString() { String toString() {
return 'Product(productId: $productId, name: $name, basePrice: $basePrice, ' return 'Product(productId: $productId, name: $name, basePrice: $basePrice, '
'category: $category, isActive: $isActive, isFeatured: $isFeatured)'; 'itemGroupName: $itemGroupName, isActive: $isActive, isFeatured: $isFeatured)';
} }
@override @override
@@ -201,7 +201,7 @@ class Product {
other.name == name && other.name == name &&
other.description == description && other.description == description &&
other.basePrice == basePrice && other.basePrice == basePrice &&
other.category == category && other.itemGroupName == itemGroupName &&
other.brand == brand && other.brand == brand &&
other.unit == unit && other.unit == unit &&
other.isActive == isActive && other.isActive == isActive &&
@@ -216,7 +216,7 @@ class Product {
name, name,
description, description,
basePrice, basePrice,
category, itemGroupName,
brand, brand,
unit, unit,
isActive, isActive,

View File

@@ -228,64 +228,184 @@ class _SpecificationsTab extends StatelessWidget {
}; };
return Container( return Container(
margin: const EdgeInsets.all(20), margin: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)), border: Border.all(color: const Color(0xFFe0e0e0)),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
children: specs.entries.map((entry) { children: [
final isLast = entry == specs.entries.last; Container(
return Container( decoration: const BoxDecoration(
decoration: BoxDecoration( border: Border(
border: isLast bottom: BorderSide(color: Color(0xFFe0e0e0)),
? null ),
: const Border(
bottom: BorderSide(color: Color(0xFFe0e0e0)),
),
), ),
child: Row( child: IntrinsicHeight(
children: [ child: Row(
// Label crossAxisAlignment: CrossAxisAlignment.stretch,
Expanded( children: [
child: Container( // Label
padding: const EdgeInsets.all(12), Expanded(
color: const Color(0xFFF4F6F8), child: Container(
child: Text( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
entry.key, color: const Color(0xFFF4F6F8),
style: const TextStyle( child: Align(
fontSize: 14, alignment: Alignment.centerLeft,
fontWeight: FontWeight.w500, child: Text(
color: AppColors.grey900, "Thương hiệu",
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
height: 1.5,
),
),
), ),
), ),
), ),
),
// Divider // Divider
Container( Container(
width: 1, width: 1,
height: 44, color: const Color(0xFFe0e0e0),
color: const Color(0xFFe0e0e0), ),
),
// Value // Value
Expanded( Expanded(
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Text( child: Text(
entry.value.toString(), '${product.brand}',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.grey900, 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,
),
),
),
],
),
),
);
}),
],
), ),
); );
} }