- Rename installed app display name to car64 (Android/iOS/Web titles) - Remove Buy Me a Coffee link from About screen - Update app version to 3.0.0+1 - Migrate scanner workflow to catalog-first flow: - look up in global_cars - show Found in Catalog bottom sheet for known cars - show New Discovery bottom sheet and insert into global_cars + car_votes - insert only into hotwheels for collection entries - Align garage reads to TPB schema by joining global_cars - Switch image field usage from image_url to user_image_url - Implement private-storage image service: - upload path auth.uid()/hotwheels.id.jpg - signed URL generation (1h) for display - in-app compression pipeline targeting <=500KB and max 1080px width - Use local brand fallback image assets/img/icon_bg_removed.png for missing car photos - Restrict edit dialog to personal notes (hotwheels) instead of global car metadata - Ensure first login auto-creates a default collection and owner membership
163 lines
5.2 KiB
Dart
163 lines
5.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../theme/app_colors.dart';
|
|
|
|
/// A styled card for displaying a single Hot Wheels 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 DateTime? addedAt;
|
|
final VoidCallback? onTap;
|
|
|
|
const CarCard({
|
|
super.key,
|
|
required this.hwId,
|
|
this.name,
|
|
this.series,
|
|
this.year,
|
|
this.color,
|
|
this.imageUrl,
|
|
this.addedAt,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
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: imageUrl != null && imageUrl!.isNotEmpty
|
|
? Image.network(
|
|
imageUrl!,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
_PlaceholderIcon(isDark: isDark),
|
|
)
|
|
: _PlaceholderIcon(isDark: isDark),
|
|
),
|
|
),
|
|
|
|
// ── Info area ──
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 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,
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|