gcx/frontend/mobile/lib/shared/widgets/coupon_card.dart

346 lines
11 KiB
Dart

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 }