249 lines
8.8 KiB
Dart
249 lines
8.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../services/storage_service.dart';
|
|
import '../theme/app_colors.dart';
|
|
|
|
/// A styled card for displaying a single die-cast car in the garage.
|
|
class CarCard extends StatelessWidget {
|
|
final String hwId;
|
|
final String? name;
|
|
final String? series;
|
|
final int? year;
|
|
final String? color;
|
|
final String? imageUrl;
|
|
final String? imagePath;
|
|
final bool isVerified;
|
|
final DateTime? addedAt;
|
|
final VoidCallback? onTap;
|
|
final VoidCallback? onLongPress;
|
|
final VoidCallback? onImageError;
|
|
final bool isSelected;
|
|
|
|
const CarCard({
|
|
super.key,
|
|
required this.hwId,
|
|
this.name,
|
|
this.series,
|
|
this.year,
|
|
this.color,
|
|
this.imageUrl,
|
|
this.imagePath,
|
|
this.isVerified = false,
|
|
this.addedAt,
|
|
this.onTap,
|
|
this.onLongPress,
|
|
this.onImageError,
|
|
this.isSelected = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final resolvedImageUrl = imageUrl;
|
|
final hasResolvedUrl = resolvedImageUrl != null && resolvedImageUrl.isNotEmpty;
|
|
final hasImagePath = imagePath != null && imagePath!.isNotEmpty;
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
color: isSelected
|
|
? AppColors.orange.withValues(alpha: 0.18)
|
|
: Theme.of(context).cardTheme.color,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── Image / placeholder area ──
|
|
AspectRatio(
|
|
aspectRatio: 16 / 10,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: isDark
|
|
? LinearGradient(
|
|
colors: [
|
|
AppColors.surfaceDark,
|
|
AppColors.cardDark,
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
)
|
|
: AppColors.brandGradientSoft,
|
|
),
|
|
child: hasResolvedUrl
|
|
? CachedNetworkImage(
|
|
imageUrl: resolvedImageUrl,
|
|
fit: BoxFit.cover,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
errorWidget: (context, url, error) {
|
|
onImageError?.call();
|
|
return _PlaceholderIcon(isDark: isDark);
|
|
},
|
|
)
|
|
: hasImagePath
|
|
? FutureBuilder<String?>(
|
|
future: StorageService.createSignedUrl(imagePath),
|
|
builder: (context, snapshot) {
|
|
final signedUrl = snapshot.data;
|
|
if (signedUrl == null || signedUrl.isEmpty) {
|
|
return _PlaceholderIcon(isDark: isDark);
|
|
}
|
|
return CachedNetworkImage(
|
|
imageUrl: signedUrl,
|
|
fit: BoxFit.cover,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
errorWidget: (context, url, error) {
|
|
onImageError?.call();
|
|
return _PlaceholderIcon(isDark: isDark);
|
|
},
|
|
);
|
|
},
|
|
)
|
|
: _PlaceholderIcon(isDark: isDark),
|
|
),
|
|
),
|
|
|
|
// ── Info area ──
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (isSelected)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
children: const [
|
|
Icon(Icons.check_circle,
|
|
size: 16, color: AppColors.orange),
|
|
SizedBox(width: 6),
|
|
Text(
|
|
'Selected',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.orange,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// HW ID badge
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.orange.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
hwId,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.orange,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
isVerified
|
|
? Icons.verified
|
|
: Icons.hourglass_bottom,
|
|
size: 12,
|
|
color: isVerified
|
|
? AppColors.success
|
|
: AppColors.textHint,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
isVerified ? 'Verified' : 'Pending',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: isVerified
|
|
? AppColors.success
|
|
: AppColors.textHint,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (name != null && name!.isNotEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
name!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
if (series != null && series!.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
series!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
if (year != null) ...[
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.calendar_today,
|
|
size: 12, color: AppColors.textHint),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'$year',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: AppColors.textHint,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Placeholder icon when no image is available.
|
|
class _PlaceholderIcon extends StatelessWidget {
|
|
final bool isDark;
|
|
const _PlaceholderIcon({required this.isDark});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Image.asset(
|
|
'assets/img/icon_bg_removed.png',
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) => Center(
|
|
child: Icon(
|
|
Icons.directions_car_filled,
|
|
size: 48,
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.15)
|
|
: AppColors.orange.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|