fix product

This commit is contained in:
Phuoc Nguyen
2025-11-17 15:07:53 +07:00
parent 0828ff1355
commit ff3629d6d1
14 changed files with 1087 additions and 110 deletions

View File

@@ -37,6 +37,7 @@ import 'package:worker/features/orders/presentation/pages/payments_page.dart';
import 'package:worker/features/price_policy/price_policy.dart'; import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_page.dart'; import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/products/presentation/pages/write_review_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart'; import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart'; import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart'; import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart';
@@ -181,6 +182,20 @@ final routerProvider = Provider<GoRouter>((ref) {
child: ProductDetailPage(productId: productId ?? ''), child: ProductDetailPage(productId: productId ?? ''),
); );
}, },
routes: [
// Write Review Route (nested under product detail)
GoRoute(
path: 'write-review',
name: RouteNames.writeReview,
pageBuilder: (context, state) {
final productId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: WriteReviewPage(productId: productId ?? ''),
);
},
),
],
), ),
// Promotion Detail Route // Promotion Detail Route
@@ -459,6 +474,7 @@ class RouteNames {
static const String home = '/'; static const String home = '/';
static const String products = '/products'; static const String products = '/products';
static const String productDetail = '/products/:id'; static const String productDetail = '/products/:id';
static const String writeReview = 'write-review';
static const String cart = '/cart'; static const String cart = '/cart';
static const String favorites = '/favorites'; static const String favorites = '/favorites';
static const String checkout = '/checkout'; static const String checkout = '/checkout';

View File

@@ -30,6 +30,7 @@ class ProductModel extends HiveObject {
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
this.introAttributes,
required this.isActive, required this.isActive,
required this.isFeatured, required this.isFeatured,
this.erpnextItemCode, this.erpnextItemCode,
@@ -91,6 +92,12 @@ class ProductModel extends HiveObject {
@HiveField(17) @HiveField(17)
final double? conversionOfSm; final double? conversionOfSm;
/// Intro attributes (JSON encoded list)
/// Quick reference attributes from API: Size, Colour, UOM
/// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}]
@HiveField(18)
final String? introAttributes;
/// Whether product is active /// Whether product is active
@HiveField(12) @HiveField(12)
final bool isActive; final bool isActive;
@@ -137,6 +144,9 @@ class ProductModel extends HiveObject {
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: json['intro_attributes'] != null
? jsonEncode(json['intro_attributes'])
: null,
isActive: json['is_active'] as bool? ?? true, isActive: json['is_active'] as bool? ?? true,
isFeatured: json['is_featured'] as bool? ?? false, isFeatured: json['is_featured'] as bool? ?? false,
erpnextItemCode: json['erpnext_item_code'] as String?, erpnextItemCode: json['erpnext_item_code'] as String?,
@@ -223,6 +233,22 @@ class ProductModel extends HiveObject {
} }
} }
// Parse intro_attributes array for quick reference
final List<Map<String, String>> introAttributesList = [];
if (json['intro_attributes'] != null && json['intro_attributes'] is List) {
final introAttrsData = json['intro_attributes'] as List;
for (final attr in introAttrsData) {
if (attr is Map<String, dynamic> &&
attr['code'] != null &&
attr['value'] != null) {
introAttributesList.add({
'code': attr['code'] as String,
'value': attr['value'] as String,
});
}
}
}
final now = DateTime.now(); final now = DateTime.now();
// Handle price from both product detail (price) and product list (standard_rate) // Handle price from both product detail (price) and product list (standard_rate)
@@ -251,6 +277,9 @@ class ProductModel extends HiveObject {
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
? jsonEncode(introAttributesList)
: null,
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
isFeatured: false, // Not provided by API, default to false isFeatured: false, // Not provided by API, default to false
erpnextItemCode: json['name'] as String, // Store item code for reference erpnextItemCode: json['name'] as String, // Store item code for reference
@@ -283,6 +312,9 @@ class ProductModel extends HiveObject {
'brand': brand, 'brand': brand,
'unit': unit, 'unit': unit,
'conversion_of_sm': conversionOfSm, 'conversion_of_sm': conversionOfSm,
'intro_attributes': introAttributes != null
? jsonDecode(introAttributes!)
: null,
'is_active': isActive, 'is_active': isActive,
'is_featured': isFeatured, 'is_featured': isFeatured,
'erpnext_item_code': erpnextItemCode, 'erpnext_item_code': erpnextItemCode,
@@ -336,6 +368,38 @@ class ProductModel extends HiveObject {
} }
} }
/// Get intro attributes as List
List<Map<String, String>>? get introAttributesList {
if (introAttributes == null) return null;
try {
final decoded = jsonDecode(introAttributes!) as List;
return decoded.map((e) {
final map = e as Map<String, dynamic>;
return {
'code': map['code'].toString(),
'value': map['value'].toString(),
};
}).toList();
} catch (e) {
return null;
}
}
/// Get specific intro attribute value by code
String? getIntroAttribute(String code) {
final attrs = introAttributesList;
if (attrs == null) return null;
try {
final attr = attrs.firstWhere(
(attr) => attr['code']?.toLowerCase() == code.toLowerCase(),
);
return attr['value'];
} catch (e) {
return null;
}
}
/// Get formatted price with currency /// Get formatted price with currency
String get formattedPrice { String get formattedPrice {
return '${basePrice.toStringAsFixed(0)}đ'; return '${basePrice.toStringAsFixed(0)}đ';
@@ -363,6 +427,7 @@ class ProductModel extends HiveObject {
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
String? introAttributes,
bool? isActive, bool? isActive,
bool? isFeatured, bool? isFeatured,
String? erpnextItemCode, String? erpnextItemCode,
@@ -383,6 +448,7 @@ class ProductModel extends HiveObject {
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
introAttributes: introAttributes ?? this.introAttributes,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured, isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode, erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
@@ -426,6 +492,7 @@ class ProductModel extends HiveObject {
brand: brand, brand: brand,
unit: unit, unit: unit,
conversionOfSm: conversionOfSm, conversionOfSm: conversionOfSm,
introAttributes: introAttributesList,
isActive: isActive, isActive: isActive,
isFeatured: isFeatured, isFeatured: isFeatured,
erpnextItemCode: erpnextItemCode, erpnextItemCode: erpnextItemCode,

View File

@@ -30,6 +30,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
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(),
introAttributes: fields[18] as String?,
isActive: fields[12] as bool, isActive: fields[12] as bool,
isFeatured: fields[13] as bool, isFeatured: fields[13] as bool,
erpnextItemCode: fields[14] as String?, erpnextItemCode: fields[14] as String?,
@@ -41,7 +42,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
@override @override
void write(BinaryWriter writer, ProductModel obj) { void write(BinaryWriter writer, ProductModel obj) {
writer writer
..writeByte(18) ..writeByte(19)
..writeByte(0) ..writeByte(0)
..write(obj.productId) ..write(obj.productId)
..writeByte(1) ..writeByte(1)
@@ -77,7 +78,9 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
..writeByte(16) ..writeByte(16)
..write(obj.updatedAt) ..write(obj.updatedAt)
..writeByte(17) ..writeByte(17)
..write(obj.conversionOfSm); ..write(obj.conversionOfSm)
..writeByte(18)
..write(obj.introAttributes);
} }
@override @override

View File

@@ -24,6 +24,7 @@ class Product {
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
this.introAttributes,
required this.isActive, required this.isActive,
required this.isFeatured, required this.isFeatured,
this.erpnextItemCode, this.erpnextItemCode,
@@ -70,6 +71,11 @@ class Product {
/// Used to calculate: Số viên = Số lượng × conversionOfSm /// Used to calculate: Số viên = Số lượng × conversionOfSm
final double? conversionOfSm; final double? conversionOfSm;
/// Intro attributes (quick reference)
/// List of maps with 'code' and 'value' keys
/// Example: [{"code": "Size", "value": "120x120"}, {"code": "UOM", "value": "2 viên/hộp"}]
final List<Map<String, String>>? introAttributes;
/// Product is active /// Product is active
final bool isActive; final bool isActive;
@@ -116,15 +122,25 @@ class Product {
/// TODO: Implement stock tracking when backend supports it /// TODO: Implement stock tracking when backend supports it
bool get isLowStock => false; bool get isLowStock => false;
/// Check if product is in stock
/// Currently using isActive as proxy
bool get inStock => isActive;
/// Get specification value by key /// Get specification value by key
String? getSpecification(String key) { String? getSpecification(String key) {
return specifications[key]?.toString(); return specifications[key]?.toString();
} }
/// Get intro attribute value by code
String? getIntroAttribute(String code) {
if (introAttributes == null) return null;
try {
final attr = introAttributes!.firstWhere(
(attr) => attr['code']?.toLowerCase() == code.toLowerCase(),
);
return attr['value'];
} catch (e) {
return null;
}
}
/// Copy with method for creating modified copies /// Copy with method for creating modified copies
Product copyWith({ Product copyWith({
String? productId, String? productId,
@@ -140,6 +156,7 @@ class Product {
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
List<Map<String, String>>? introAttributes,
bool? isActive, bool? isActive,
bool? isFeatured, bool? isFeatured,
String? erpnextItemCode, String? erpnextItemCode,
@@ -160,6 +177,7 @@ class Product {
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
introAttributes: introAttributes ?? this.introAttributes,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured, isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode, erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,

View File

@@ -246,11 +246,12 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
child: StickyActionBar( child: StickyActionBar(
quantity: _quantity, quantity: _quantity,
unit: product.unit ?? '', unit: product.unit ?? '',
conversionOfSm: product.conversionOfSm,
uomFromIntroAttributes: product.getIntroAttribute('UOM'),
onIncrease: _increaseQuantity, onIncrease: _increaseQuantity,
onDecrease: _decreaseQuantity, onDecrease: _decreaseQuantity,
onQuantityChanged: _updateQuantity, onQuantityChanged: _updateQuantity,
onAddToCart: () => _addToCart(product), onAddToCart: () => _addToCart(product),
isOutOfStock: !product.inStock,
), ),
), ),
], ],

View File

@@ -0,0 +1,504 @@
/// Page: Write Review
///
/// Form page for users to write product reviews with star rating.
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart';
import 'package:worker/features/products/presentation/widgets/write_review/review_guidelines_card.dart';
import 'package:worker/features/products/presentation/widgets/write_review/star_rating_selector.dart';
/// Write Review Page
///
/// Allows users to write a review for a product:
/// - Product info card (read-only)
/// - Star rating selector (1-5, required)
/// - Review content (textarea, min 20 chars, max 1000)
/// - Character counter (red if < 20)
/// - Review guidelines
/// - Submit button with loading state
class WriteReviewPage extends ConsumerStatefulWidget {
/// Product ID to review
final String productId;
const WriteReviewPage({super.key, required this.productId});
@override
ConsumerState<WriteReviewPage> createState() => _WriteReviewPageState();
}
class _WriteReviewPageState extends ConsumerState<WriteReviewPage> {
// Form state
int _selectedRating = 0;
final _contentController = TextEditingController();
bool _isSubmitting = false;
// Validation errors
String? _ratingError;
String? _contentError;
// Constants
static const int _minContentLength = 20;
static const int _maxContentLength = 1000;
@override
void dispose() {
_contentController.dispose();
super.dispose();
}
/// Validate form fields
bool _validateForm() {
bool isValid = true;
// Reset errors
setState(() {
_ratingError = null;
_contentError = null;
});
// Rating validation
if (_selectedRating == 0) {
setState(() => _ratingError = 'Vui lòng chọn số sao đánh giá');
isValid = false;
}
// Content validation
final content = _contentController.text.trim();
if (content.length < _minContentLength) {
setState(() =>
_contentError = 'Nội dung đánh giá phải có ít nhất $_minContentLength ký tự');
isValid = false;
}
return isValid;
}
/// Submit review
Future<void> _submitReview() async {
if (!_validateForm()) return;
setState(() => _isSubmitting = true);
// Simulate API call (TODO: Replace with actual API integration)
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
Icon(FontAwesomeIcons.circleCheck, color: AppColors.white),
SizedBox(width: 12),
Expanded(
child: Text('Đánh giá của bạn đã được gửi thành công!'),
),
],
),
backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating,
),
);
// Navigate back
context.pop();
}
}
@override
Widget build(BuildContext context) {
final productAsync = ref.watch(productDetailProvider(productId: widget.productId));
return Scaffold(
backgroundColor: AppColors.white,
// Standard AppBar
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
centerTitle: false,
leading: IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppColors.grey900,
),
onPressed: () => context.pop(),
),
title: const Text(
'Viết đánh giá sản phẩm',
style: TextStyle(
color: AppColors.grey900,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
body: productAsync.when(
data: (product) => _buildForm(product),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
FontAwesomeIcons.circleExclamation,
size: 48,
color: AppColors.danger,
),
const SizedBox(height: 16),
Text(
'Không thể tải thông tin sản phẩm',
style: const TextStyle(
fontSize: 16,
color: AppColors.grey900,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Quay lại'),
),
],
),
),
),
);
}
Widget _buildForm(Product product) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Info Card (Read-only)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: const Color(0xFFe0e0e0), width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Product Image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: product.images.isNotEmpty
? product.images.first
: '',
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 80,
height: 80,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 80,
color: AppColors.grey100,
child: const Icon(
FontAwesomeIcons.image,
color: AppColors.grey500,
),
),
),
),
const SizedBox(width: 16),
// Product Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
'Mã: ${product.productId}',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
),
],
),
),
const SizedBox(height: 28),
// Rating Section (Required)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Xếp hạng của bạn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
const Text(
'Bấm vào ngôi sao để chọn đánh giá',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(height: 16),
// Star Rating Selector
StarRatingSelector(
rating: _selectedRating,
onRatingChanged: (rating) {
setState(() {
_selectedRating = rating;
_ratingError = null;
});
},
),
// Rating Error
if (_ratingError != null) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(
FontAwesomeIcons.triangleExclamation,
size: 14,
color: AppColors.danger,
),
const SizedBox(width: 6),
Text(
_ratingError!,
style: const TextStyle(
fontSize: 13,
color: AppColors.danger,
),
),
],
),
],
],
),
const SizedBox(height: 28),
// Review Content (Required)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Nội dung đánh giá',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
// Textarea
TextField(
controller: _contentController,
maxLines: 6,
maxLength: _maxContentLength,
decoration: InputDecoration(
hintText:
'Chia sẻ trải nghiệm của bạn về sản phẩm này...',
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.grey500,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFe0e0e0),
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFe0e0e0),
width: 2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
contentPadding: const EdgeInsets.all(14),
counterText: '', // Hide default counter
),
style: const TextStyle(
fontSize: 15,
height: 1.6,
color: AppColors.grey900,
),
onChanged: (value) {
setState(() {
_contentError = null;
});
},
),
const SizedBox(height: 6),
// Character Counter
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'${_contentController.text.length}',
style: TextStyle(
fontSize: 13,
color: _contentController.text.length < _minContentLength
? AppColors.danger
: AppColors.grey500,
),
),
Text(
' / $_maxContentLength ký tự',
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
// Content Error
if (_contentError != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
FontAwesomeIcons.triangleExclamation,
size: 14,
color: AppColors.danger,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_contentError!,
style: const TextStyle(
fontSize: 13,
color: AppColors.danger,
),
),
),
],
),
],
],
),
const SizedBox(height: 24),
// Guidelines
const ReviewGuidelinesCard(),
const SizedBox(height: 24),
// Submit Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submitReview,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
disabledBackgroundColor: const Color(0xFFe0e0e0),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.white),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
FontAwesomeIcons.paperPlane,
size: 16,
color: AppColors.white,
),
const SizedBox(width: 10),
const Text(
'Gửi đánh giá',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
],
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
}

View File

@@ -239,12 +239,10 @@ class ProductCard extends ConsumerWidget {
width: double.infinity, width: double.infinity,
height: 36.0, height: 36.0,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: !product.inStock ? onAddToCart : null, onPressed: onAddToCart,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white, foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
@@ -256,9 +254,9 @@ class ProductCard extends ConsumerWidget {
), ),
), ),
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0), icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 14.0),
label: Text( label: const Text(
!product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock, 'Thêm vào giỏ',
style: const TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),

View File

@@ -4,8 +4,8 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/entities/product.dart';
@@ -15,12 +15,13 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - SKU text (small, gray) /// - SKU text (small, gray)
/// - Product title (large, bold) /// - Product title (large, bold)
/// - Pricing row (current price, original price, discount badge) /// - Pricing row (current price, original price, discount badge)
/// - Quick info cards (3 cards: Size, Warranty, Delivery) /// - Rating section (5 stars with review count)
/// - Dynamic intro attribute cards (shown only if non-null)
class ProductInfoSection extends StatelessWidget { class ProductInfoSection extends StatelessWidget {
final Product product;
const ProductInfoSection({super.key, required this.product}); const ProductInfoSection({super.key, required this.product});
final Product product;
String _formatPrice(double price) { String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN'); final formatter = NumberFormat('#,###', 'vi_VN');
return '${formatter.format(price)} VND'; return '${formatter.format(price)} VND';
@@ -107,57 +108,114 @@ class ProductInfoSection extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// Quick Info Cards // Rating & Reviews Section
const Row(
children: [
// Rating Stars
Row( Row(
children: [ children: [
// Size Info Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
Expanded( SizedBox(width: 2),
child: _QuickInfoCard( Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
icon: Icons.straighten, // expand icon SizedBox(width: 2),
label: 'Kích thước', Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
value: product.getSpecification('size') ?? '1200x1200', SizedBox(width: 2),
Icon(FontAwesomeIcons.solidStar, color: Color(0xFFffc107), size: 16),
SizedBox(width: 2),
Icon(FontAwesomeIcons.starHalfStroke, color: Color(0xFFffc107), size: 16),
],
), ),
),
const SizedBox(width: 12),
// Packaging Info SizedBox(width: 12),
Expanded(
child: _QuickInfoCard(
icon: Icons.inventory_2_outlined, // cube/box icon
label: 'Đóng gói',
value: product.getSpecification('packaging') ?? '2 viên/thùng',
),
),
const SizedBox(width: 12),
// Delivery Info // Rating Text
Expanded( Text(
child: _QuickInfoCard( '4.8 (125 đánh giá)',
icon: Icons.local_shipping_outlined, // truck icon style: TextStyle(
label: 'Giao hàng', fontSize: 14,
value: '2-3 Ngày', color: AppColors.grey500,
), ),
), ),
], ],
), ),
const SizedBox(height: 16),
// Intro attributes quick info cards (dynamic based on non-null values)
if (_buildIntroAttributeCards(product).isNotEmpty)
Row(
children: _buildIntroAttributeCards(product),
),
], ],
), ),
); );
} }
/// Build intro attribute cards dynamically based on non-null values
List<Widget> _buildIntroAttributeCards(Product product) {
final cards = <Widget>[];
// Define available intro attributes with their display info
final availableAttributes = [
{
'code': 'Size',
'icon': FontAwesomeIcons.expand,
'label': 'Kích thước',
},
{
'code': 'Colour',
'icon': FontAwesomeIcons.palette,
'label': 'Màu sắc',
},
{
'code': 'UOM',
'icon': FontAwesomeIcons.boxArchive,
'label': 'Đóng gói',
},
];
// Build cards only for non-null values
for (final attr in availableAttributes) {
final value = product.getIntroAttribute(attr['code'] as String);
if (value != null && value.isNotEmpty) {
cards.add(
Expanded(
child: _QuickInfoCard(
icon: attr['icon'] as IconData,
label: attr['label'] as String,
value: value,
),
),
);
// Add spacing between cards (except after the last one)
if (cards.length < availableAttributes.length) {
cards.add(const SizedBox(width: 8));
}
}
}
// Remove trailing spacer if it exists
if (cards.isNotEmpty && cards.last is SizedBox) {
cards.removeLast();
}
return cards;
}
} }
/// Quick Info Card Widget /// Quick Info Card Widget
class _QuickInfoCard extends StatelessWidget { class _QuickInfoCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _QuickInfoCard({ const _QuickInfoCard({
required this.icon, required this.icon,
required this.label, required this.label,
required this.value, required this.value,
}); });
final IconData icon;
final String label;
final String value;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(

View File

@@ -8,6 +8,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/presentation/widgets/product_detail/write_review_button.dart';
/// Product Tabs Section /// Product Tabs Section
/// ///
@@ -16,9 +17,9 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - Specifications: Table of product specs /// - Specifications: Table of product specs
/// - Reviews: Rating overview and review items /// - Reviews: Rating overview and review items
class ProductTabsSection extends StatefulWidget { class ProductTabsSection extends StatefulWidget {
final Product product;
const ProductTabsSection({super.key, required this.product}); const ProductTabsSection({super.key, required this.product});
final Product product;
@override @override
State<ProductTabsSection> createState() => _ProductTabsSectionState(); State<ProductTabsSection> createState() => _ProductTabsSectionState();
@@ -33,6 +34,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
super.initState(); super.initState();
// Start with Specifications tab (index 0) // Start with Specifications tab (index 0)
_tabController = TabController(length: 2, vsync: this, initialIndex: 0); _tabController = TabController(length: 2, vsync: this, initialIndex: 0);
_tabController.addListener(() {
setState(() {}); // Update IndexedStack when tab changes
});
} }
@override @override
@@ -75,17 +79,14 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
), ),
), ),
// Tab Content // Tab Content (expands to fit content)
SizedBox( IndexedStack(
height: 400, // Fixed height for tab content index: _tabController.index,
child: TabBarView(
controller: _tabController,
children: [ children: [
_SpecificationsTab(product: widget.product), _SpecificationsTab(product: widget.product),
const _ReviewsTab(), _ReviewsTab(productId: widget.product.productId),
], ],
), ),
),
], ],
), ),
); );
@@ -94,9 +95,9 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
/// Description Tab Content /// Description Tab Content
class _DescriptionTab extends StatelessWidget { class _DescriptionTab extends StatelessWidget {
final Product product;
const _DescriptionTab({required this.product}); const _DescriptionTab({required this.product});
final Product product;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -201,9 +202,9 @@ class _DescriptionTab extends StatelessWidget {
/// Specifications Tab Content /// Specifications Tab Content
class _SpecificationsTab extends StatelessWidget { class _SpecificationsTab extends StatelessWidget {
final Product product;
const _SpecificationsTab({required this.product}); const _SpecificationsTab({required this.product});
final Product product;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -223,8 +224,7 @@ class _SpecificationsTab extends StatelessWidget {
'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006', 'Tiêu chuẩn': 'TCVN 9081:2012, ISO 13006',
}; };
return SingleChildScrollView( return Container(
child: Container(
margin: const EdgeInsets.all(20), margin: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)), border: Border.all(color: const Color(0xFFe0e0e0)),
@@ -284,21 +284,26 @@ class _SpecificationsTab extends StatelessWidget {
); );
}).toList(), }).toList(),
), ),
),
); );
} }
} }
/// Reviews Tab Content /// Reviews Tab Content
class _ReviewsTab extends StatelessWidget { class _ReviewsTab extends StatelessWidget {
const _ReviewsTab();
const _ReviewsTab({required this.productId});
final String productId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Write Review Button
WriteReviewButton(productId: productId),
// Rating Overview // Rating Overview
Container( Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@@ -353,6 +358,7 @@ class _ReviewsTab extends StatelessWidget {
// Review Items // Review Items
..._mockReviews.map((review) => _ReviewItem(review: review)), ..._mockReviews.map((review) => _ReviewItem(review: review)),
const SizedBox(height: 48),
], ],
), ),
); );
@@ -361,9 +367,9 @@ class _ReviewsTab extends StatelessWidget {
/// Review Item Widget /// Review Item Widget
class _ReviewItem extends StatelessWidget { class _ReviewItem extends StatelessWidget {
final Map<String, dynamic> review;
const _ReviewItem({required this.review}); const _ReviewItem({required this.review});
final Map<String, dynamic> review;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -15,13 +15,6 @@ import 'package:worker/core/theme/colors.dart';
/// - Quantity section with label, controls, and conversion text /// - Quantity section with label, controls, and conversion text
/// - Add to cart button /// - Add to cart button
class StickyActionBar extends StatelessWidget { class StickyActionBar extends StatelessWidget {
final int quantity;
final VoidCallback onIncrease;
final VoidCallback onDecrease;
final ValueChanged<int> onQuantityChanged;
final VoidCallback onAddToCart;
final bool isOutOfStock;
final String unit;
const StickyActionBar({ const StickyActionBar({
super.key, super.key,
@@ -30,15 +23,40 @@ class StickyActionBar extends StatelessWidget {
required this.onDecrease, required this.onDecrease,
required this.onQuantityChanged, required this.onQuantityChanged,
required this.onAddToCart, required this.onAddToCart,
this.isOutOfStock = false,
this.unit = '', this.unit = '',
this.conversionOfSm,
this.uomFromIntroAttributes,
}); });
final int quantity;
final VoidCallback onIncrease;
final VoidCallback onDecrease;
final ValueChanged<int> onQuantityChanged;
final VoidCallback onAddToCart;
final String unit;
final double? conversionOfSm;
final String? uomFromIntroAttributes;
String _getConversionText() { String _getConversionText() {
// Calculate conversion: each m² ≈ 0.36 boxes, each box = varies if (conversionOfSm == null || conversionOfSm == 0) {
final pieces = (quantity / 0.36).ceil(); return ''; // No conversion data available
final actualArea = (pieces * 0.36).toStringAsFixed(2); }
return 'Tương đương: $pieces viên / $actualArea';
// Calculate boxes needed using API conversion factor
// Formula: boxes_needed = quantity_m² / conversion_of_sm
final boxesNeeded = (quantity / conversionOfSm!).ceil();
// Extract pieces per box from UOM if available (e.g., "2 viên/hộp" -> 2)
int piecesPerBox = 1;
if (uomFromIntroAttributes != null) {
final match = RegExp(r'(\d+)\s*viên').firstMatch(uomFromIntroAttributes!);
if (match != null) {
piecesPerBox = int.tryParse(match.group(1) ?? '1') ?? 1;
}
}
final totalPieces = boxesNeeded * piecesPerBox;
return 'Tương đương: $boxesNeeded hộp / $totalPieces viên';
} }
@override @override
@@ -46,8 +64,8 @@ class StickyActionBar extends StatelessWidget {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, color: AppColors.white,
border: Border( border: const Border(
top: BorderSide(color: const Color(0xFFe0e0e0), width: 1), top: BorderSide(color: Color(0xFFe0e0e0), width: 1),
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -131,6 +149,7 @@ class StickyActionBar extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
// Conversion Text // Conversion Text
if (_getConversionText().isNotEmpty)
Text( Text(
_getConversionText(), _getConversionText(),
style: const TextStyle( style: const TextStyle(
@@ -141,30 +160,28 @@ class StickyActionBar extends StatelessWidget {
], ],
), ),
const SizedBox(width: 16), const SizedBox(width: 8),
// Add to Cart Button // Add to Cart Button
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isOutOfStock ? null : onAddToCart, onPressed: onAddToCart,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white, foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 12,
vertical: 12, vertical: 8,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
), ),
icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18), icon: const FaIcon(FontAwesomeIcons.cartShopping, size: 18),
label: Text( label: const Text(
isOutOfStock ? 'Hết hàng' : 'Thêm vào giỏ hàng', 'Thêm vào giỏ hàng',
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -180,10 +197,10 @@ class StickyActionBar extends StatelessWidget {
/// Quantity Button Widget /// Quantity Button Widget
class _QuantityButton extends StatelessWidget { class _QuantityButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
const _QuantityButton({required this.icon, this.onPressed}); const _QuantityButton({required this.icon, this.onPressed});
final IconData icon;
final VoidCallback? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -0,0 +1,67 @@
/// Widget: Write Review Button
///
/// Button to navigate to the write review page from product detail.
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/theme/colors.dart';
/// Write Review Button
///
/// Displays a prominent button for users to write a review:
/// - Primary blue background
/// - Edit icon
/// - Text: "Viết đánh giá của bạn"
/// - Navigates to WriteReviewPage with productId
class WriteReviewButton extends StatelessWidget {
/// Product ID to review
final String productId;
const WriteReviewButton({
super.key,
required this.productId,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 20),
child: ElevatedButton(
onPressed: () {
// Navigate to write review page
context.push('/products/$productId/write-review');
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 28),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
FontAwesomeIcons.penToSquare,
size: 16,
color: AppColors.white,
),
SizedBox(width: 10),
Text(
'Viết đánh giá của bạn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
/// Widget: Review Guidelines Card
///
/// Information card with tips for writing good reviews.
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/theme/colors.dart';
/// Review Guidelines Card Widget
///
/// Displays helpful tips for writing product reviews:
/// - Light blue background (#f0f7ff)
/// - Blue left border
/// - Lightbulb icon
/// - 4 bullet points with guidelines
class ReviewGuidelinesCard extends StatelessWidget {
const ReviewGuidelinesCard({super.key});
// Guidelines background color
static const Color _backgroundColor = Color(0xFFF0F7FF);
// Guidelines list
static const List<String> _guidelines = [
'Chia sẻ trải nghiệm thực tế của bạn về sản phẩm',
'Đề cập đến chất lượng, màu sắc, độ bền của sản phẩm',
'Nêu rõ điểm tốt và điểm chưa tốt (nếu có)',
'Tránh spam, nội dung không phù hợp hoặc vi phạm',
];
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _backgroundColor,
borderRadius: BorderRadius.circular(8),
border: const Border(
left: BorderSide(
color: AppColors.primaryBlue,
width: 4,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
const Row(
children: [
Icon(
FontAwesomeIcons.lightbulb,
size: 16,
color: AppColors.primaryBlue,
),
SizedBox(width: 8),
Text(
'Gợi ý viết đánh giá tốt',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
),
),
],
),
const SizedBox(height: 12),
// Guidelines List
...List.generate(_guidelines.length, (index) {
return Padding(
padding: EdgeInsets.only(
bottom: index < _guidelines.length - 1 ? 6 : 0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.grey900,
height: 1.6,
),
),
Expanded(
child: Text(
_guidelines[index],
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
height: 1.6,
),
),
),
],
),
);
}),
],
),
);
}
}

View File

@@ -0,0 +1,117 @@
/// Widget: Star Rating Selector
///
/// Interactive 5-star rating widget with hover effects and visual feedback.
library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Star Rating Selector Widget
///
/// Displays 5 clickable stars with:
/// - Empty stars by default (FontAwesomeIcons.star)
/// - Filled stars when selected (FontAwesomeIcons.solidStar)
/// - Hover effects (scale 1.1)
/// - Rating label below showing text
/// - Callback when rating changes
class StarRatingSelector extends StatefulWidget {
/// Current selected rating (0-5)
final int rating;
/// Callback when rating changes
final ValueChanged<int> onRatingChanged;
const StarRatingSelector({
super.key,
required this.rating,
required this.onRatingChanged,
});
@override
State<StarRatingSelector> createState() => _StarRatingSelectorState();
}
class _StarRatingSelectorState extends State<StarRatingSelector> {
// Rating label text mapping
static const Map<int, String> _ratingLabels = {
0: 'Chưa chọn đánh giá',
1: 'Rất không hài lòng',
2: 'Không hài lòng',
3: 'Bình thường',
4: 'Hài lòng',
5: 'Rất hài lòng',
};
// Colors
static const Color _starUnselected = Color(0xFFe0e0e0);
static const Color _starHover = Color(0xFFffc107);
static const Color _starSelected = Color(0xFFff9800);
static const Color _labelBackgroundSelected = Color(0xFFfff3e0);
int? _hoverRating;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Stars Row
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (index) {
final starIndex = index + 1;
final isSelected = starIndex <= widget.rating;
final isHovered = _hoverRating != null && starIndex <= _hoverRating!;
return MouseRegion(
onEnter: (_) => setState(() => _hoverRating = starIndex),
onExit: (_) => setState(() => _hoverRating = null),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => widget.onRatingChanged(starIndex),
child: AnimatedScale(
scale: isHovered ? 1.1 : 1.0,
duration: AppDuration.short,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
isSelected
? FontAwesomeIcons.solidStar
: FontAwesomeIcons.star,
size: 36,
color: isHovered
? _starHover
: (isSelected ? _starSelected : _starUnselected),
),
),
),
),
);
}),
),
const SizedBox(height: 12),
// Rating Label
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: widget.rating > 0
? _labelBackgroundSelected
: AppColors.grey50,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_ratingLabels[widget.rating] ?? '',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: widget.rating > 0 ? _starSelected : AppColors.grey500,
),
),
),
],
);
}
}

View File

@@ -912,10 +912,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.16.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1437,26 +1437,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.26.3" version: "1.26.2"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.6"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.12" version: "0.6.11"
timing: timing:
dependency: transitive dependency: transitive
description: description: