346 lines
11 KiB
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 }
|