import 'package:flutter/material.dart'; import '../../app/i18n/app_localizations.dart'; import '../../app/theme/app_colors.dart'; import '../../app/theme/app_typography.dart'; import '../../app/theme/app_spacing.dart'; /// 券卡片组件 - 全端通用核心组件 /// /// 展示:券封面图 + 品牌 + 面值 + 折扣率 + 到期时间 /// 使用场景:首页推荐、市场列表、我的券列表、搜索结果 class CouponCard extends StatelessWidget { final String brandName; final String couponName; final double faceValue; final double currentPrice; final String? imageUrl; final String? brandLogoUrl; final DateTime? expiryDate; final String? creditRating; final CouponStatus status; final CouponCardStyle style; final VoidCallback? onTap; const CouponCard({ super.key, required this.brandName, required this.couponName, required this.faceValue, required this.currentPrice, this.imageUrl, this.brandLogoUrl, this.expiryDate, this.creditRating, this.status = CouponStatus.active, this.style = CouponCardStyle.list, this.onTap, }); double get discountRate => currentPrice / faceValue; String _getDiscountText(BuildContext context) => '${(discountRate * 10).toStringAsFixed(1)}${context.t('market.discountSuffix')}'; @override Widget build(BuildContext context) { return style == CouponCardStyle.grid ? _buildGridCard(context) : _buildListCard(context); } Widget _buildListCard(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( height: AppSpacing.couponCardHeight, decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppSpacing.borderRadiusMd, boxShadow: AppSpacing.shadowSm, border: Border.all(color: AppColors.borderLight), ), child: Row( children: [ // Left: Coupon Image with Ticket Notch _buildCouponImage(width: 110, height: AppSpacing.couponCardHeight), // Ticket Divider _buildTicketDivider(), // Right: Info Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Brand + Name Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( brandName, style: AppTypography.caption.copyWith( color: AppColors.textTertiary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( couponName, style: AppTypography.labelMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), // Price + Discount Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '\$${currentPrice.toStringAsFixed(2)}', style: AppTypography.priceSmall, ), const SizedBox(width: 6), Text( '\$${faceValue.toStringAsFixed(0)}', style: AppTypography.priceOriginal, ), const Spacer(), _buildDiscountBadge(context), ], ), // Expiry + Status Row( children: [ if (expiryDate != null) ...[ Icon(Icons.access_time_rounded, size: 12, color: _expiryColor), const SizedBox(width: 3), Text( _getExpiryText(context), style: AppTypography.caption.copyWith(color: _expiryColor), ), ], const Spacer(), if (creditRating != null) _buildCreditBadge(), ], ), ], ), ), ), ], ), ), ); } Widget _buildGridCard(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppSpacing.borderRadiusMd, boxShadow: AppSpacing.shadowSm, border: Border.all(color: AppColors.borderLight), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Image _buildCouponImage(width: double.infinity, height: 100), // Info Padding( padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( couponName, style: AppTypography.labelSmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Row( children: [ Text( '\$${currentPrice.toStringAsFixed(2)}', style: AppTypography.priceSmall.copyWith(fontSize: 15), ), const SizedBox(width: 4), _buildDiscountBadge(context), ], ), const SizedBox(height: 4), Text( brandName, style: AppTypography.caption, maxLines: 1, ), ], ), ), ], ), ), ); } Widget _buildCouponImage({required double width, required double height}) { return ClipRRect( borderRadius: BorderRadius.only( topLeft: const Radius.circular(12), bottomLeft: style == CouponCardStyle.list ? const Radius.circular(12) : Radius.zero, topRight: style == CouponCardStyle.grid ? const Radius.circular(12) : Radius.zero, ), child: Container( width: width, height: height, color: AppColors.primarySurface, child: imageUrl != null ? Image.network(imageUrl!, fit: BoxFit.cover) : Center( child: Icon( Icons.confirmation_number_outlined, size: 32, color: AppColors.primary.withValues(alpha: 0.4), ), ), ), ); } Widget _buildTicketDivider() { return SizedBox( width: 16, child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildNotch(isTop: true), CustomPaint( size: const Size(1, 80), painter: _DashedLinePainter(color: AppColors.border), ), _buildNotch(isTop: false), ], ), ); } Widget _buildNotch({required bool isTop}) { return Container( width: 16, height: 8, decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.vertical( top: isTop ? Radius.zero : const Radius.circular(8), bottom: isTop ? const Radius.circular(8) : Radius.zero, ), ), ); } Widget _buildDiscountBadge(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: AppSpacing.borderRadiusFull, ), child: Text(_getDiscountText(context), style: AppTypography.discountBadge), ); } Widget _buildCreditBadge() { final color = _creditColor(creditRating!); return Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: AppSpacing.borderRadiusFull, border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5), ), child: Text( creditRating!, style: AppTypography.caption.copyWith( color: color, fontSize: 10, fontWeight: FontWeight.w600, ), ), ); } Color _creditColor(String rating) { switch (rating) { case 'AAA': return AppColors.creditAAA; case 'AA': return AppColors.creditAA; case 'A': return AppColors.creditA; case 'BBB': return AppColors.creditBBB; default: return AppColors.creditBB; } } String _getExpiryText(BuildContext context) { if (expiryDate == null) return ''; final days = expiryDate!.difference(DateTime.now()).inDays; if (days < 0) return context.t('couponCard.expiredText'); if (days == 0) return context.t('couponCard.expiringToday'); if (days <= 3) return '$days${context.t('couponCard.daysToExpiry')}'; if (days <= 30) return '$days${context.t('couponCard.daysToExpiry')}'; return '${expiryDate!.month}/${expiryDate!.day}${context.t('couponCard.expiryFormat')}'; } Color get _expiryColor { if (expiryDate == null) return AppColors.textTertiary; final days = expiryDate!.difference(DateTime.now()).inDays; if (days <= 3) return AppColors.error; if (days <= 7) return AppColors.warning; return AppColors.textTertiary; } } /// 虚线画笔 class _DashedLinePainter extends CustomPainter { final Color color; _DashedLinePainter({required this.color}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeWidth = 1; const dashHeight = 4.0; const gapHeight = 3.0; double startY = 0; while (startY < size.height) { canvas.drawLine( Offset(0, startY), Offset(0, startY + dashHeight), paint, ); startY += dashHeight + gapHeight; } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } enum CouponStatus { active, pending, expired, used } enum CouponCardStyle { list, grid }