fix: unify motorcycle image rendering paths
Consolidate motorcycle image resolution and rendering so Garage and Technical Details use the same authoritative image hierarchy.
This commit is contained in:
parent
432253b11f
commit
ed269a0e67
|
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import '../../../services/motorcycle_image_service.dart';
|
||||
|
||||
/// Shared motorcycle image widget used by Garage and motorcycle detail views.
|
||||
/// It always resolves through the authoritative MotorcycleImageService.
|
||||
class MotorcyclePhoto extends StatelessWidget {
|
||||
final int year;
|
||||
final String make;
|
||||
|
|
@ -9,6 +11,7 @@ class MotorcyclePhoto extends StatelessWidget {
|
|||
final double height;
|
||||
final BorderRadius borderRadius;
|
||||
final bool showAttribution;
|
||||
final BoxFit fit;
|
||||
|
||||
const MotorcyclePhoto({
|
||||
super.key,
|
||||
|
|
@ -18,6 +21,7 @@ class MotorcyclePhoto extends StatelessWidget {
|
|||
this.height = 190,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(14)),
|
||||
this.showAttribution = true,
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -41,23 +45,8 @@ class MotorcyclePhoto extends StatelessWidget {
|
|||
: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
image.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.cyanAccent,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) =>
|
||||
const _FallbackMotorcycleImage(),
|
||||
),
|
||||
if (showAttribution)
|
||||
_buildImage(image),
|
||||
if (showAttribution && image.isPhoto)
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
|
|
@ -70,10 +59,7 @@ class MotorcyclePhoto extends StatelessWidget {
|
|||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 3,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Text(
|
||||
_attribution(image),
|
||||
maxLines: 1,
|
||||
|
|
@ -96,6 +82,36 @@ class MotorcyclePhoto extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildImage(MotorcycleImage image) {
|
||||
if (image.assetPath != null) {
|
||||
return Image.asset(
|
||||
image.assetPath!,
|
||||
fit: fit,
|
||||
errorBuilder: (_, _, _) => const _FallbackMotorcycleImage(),
|
||||
);
|
||||
}
|
||||
|
||||
if (image.imageUrl != null) {
|
||||
return Image.network(
|
||||
image.imageUrl!,
|
||||
fit: fit,
|
||||
filterQuality: FilterQuality.medium,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.cyanAccent,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => const _FallbackMotorcycleImage(),
|
||||
);
|
||||
}
|
||||
|
||||
return const _FallbackMotorcycleImage();
|
||||
}
|
||||
|
||||
String _attribution(MotorcycleImage image) {
|
||||
final credit = image.credit?.trim();
|
||||
final license = image.license?.trim();
|
||||
|
|
@ -103,7 +119,7 @@ class MotorcyclePhoto extends StatelessWidget {
|
|||
return 'Photo: $credit • $license';
|
||||
}
|
||||
if (credit != null && credit.isNotEmpty) return 'Photo: $credit';
|
||||
return 'Photo: Wikimedia Commons';
|
||||
return 'Photo: ${image.attribution}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
/// Metadata for a motorcycle photograph or reference image
|
||||
import '../motorcycle_image_service.dart' as unified;
|
||||
|
||||
/// Backward-compatible facade for older Garage imports.
|
||||
///
|
||||
/// Image resolution is implemented exclusively by the unified service in
|
||||
/// `services/motorcycle_image_service.dart`; this facade exists temporarily so
|
||||
/// existing callers/tests can migrate without maintaining a second registry.
|
||||
class MotorcycleImageReference {
|
||||
final String? assetPath;
|
||||
final String? networkUrl;
|
||||
|
|
@ -19,185 +25,80 @@ class MotorcycleImageReference {
|
|||
bool get hasImage => assetPath != null || networkUrl != null || localFilePath != null;
|
||||
}
|
||||
|
||||
/// Metadata descriptor for registered motorcycle reference images
|
||||
class MotorcycleImageDescriptor {
|
||||
final String make;
|
||||
final String model;
|
||||
final int? exactYear;
|
||||
final int? minYear;
|
||||
final int? maxYear;
|
||||
final List<String> modelAliases;
|
||||
final String assetPath;
|
||||
final String caption;
|
||||
final String attribution;
|
||||
final bool isPhoto;
|
||||
|
||||
class MotorcycleImageDescriptor extends unified.MotorcycleImageDescriptor {
|
||||
const MotorcycleImageDescriptor({
|
||||
required this.make,
|
||||
required this.model,
|
||||
this.exactYear,
|
||||
this.minYear,
|
||||
this.maxYear,
|
||||
this.modelAliases = const [],
|
||||
required this.assetPath,
|
||||
required this.caption,
|
||||
this.attribution = 'Factory Technical Reference',
|
||||
this.isPhoto = true,
|
||||
required super.make,
|
||||
required super.model,
|
||||
super.exactYear,
|
||||
super.minYear,
|
||||
super.maxYear,
|
||||
super.modelAliases,
|
||||
required super.assetPath,
|
||||
required super.caption,
|
||||
super.attribution,
|
||||
super.isPhoto,
|
||||
});
|
||||
|
||||
/// Normalizes vehicle names for invariant comparison
|
||||
static String normalize(String input) {
|
||||
return input.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
}
|
||||
|
||||
/// Evaluates whether this descriptor matches the specified make, model, and year
|
||||
bool matches(String targetMake, String targetModel, int? targetYear) {
|
||||
final normTargetMake = normalize(targetMake);
|
||||
final normMake = normalize(make);
|
||||
if (normMake != normTargetMake) return false;
|
||||
|
||||
final normTargetModel = normalize(targetModel);
|
||||
final normModel = normalize(model);
|
||||
final isModelMatch = normModel == normTargetModel ||
|
||||
modelAliases.any((alias) => normalize(alias) == normTargetModel);
|
||||
|
||||
if (!isModelMatch) return false;
|
||||
|
||||
// Exact year check
|
||||
if (exactYear != null) {
|
||||
return targetYear == exactYear;
|
||||
}
|
||||
|
||||
// Generation / Year range check
|
||||
if (minYear != null || maxYear != null) {
|
||||
if (targetYear == null) return false;
|
||||
if (minYear != null && targetYear < minYear!) return false;
|
||||
if (maxYear != null && targetYear > maxYear!) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
MotorcycleImageReference toReference() {
|
||||
return MotorcycleImageReference(
|
||||
assetPath: assetPath,
|
||||
caption: caption,
|
||||
attribution: attribution,
|
||||
isPhoto: isPhoto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Central extensible repository providing authentic motorcycle reference images
|
||||
/// Compatibility facade. New UI code should import the unified service.
|
||||
class MotorcycleImageService {
|
||||
static const MotorcycleImageService _instance = MotorcycleImageService._internal();
|
||||
factory MotorcycleImageService() => _instance;
|
||||
const MotorcycleImageService._internal();
|
||||
|
||||
/// List of verified physical photographic assets bundled in the APK
|
||||
static const List<String> bundledPhotographicAssets = [
|
||||
'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
|
||||
];
|
||||
static List<String> get bundledPhotographicAssets =>
|
||||
unified.MotorcycleImageService.bundledPhotographicAssets;
|
||||
|
||||
/// Default registry of curated motorcycle reference descriptors
|
||||
static final List<MotorcycleImageDescriptor> _defaultDescriptors = [
|
||||
// Tier 1 & 2: 1st Gen Yamaha YZF-R6 (1999-2002 5EB) including 2001 field test vehicle
|
||||
const MotorcycleImageDescriptor(
|
||||
make: 'Yamaha',
|
||||
model: 'YZF-R6',
|
||||
minYear: 1999,
|
||||
maxYear: 2002,
|
||||
modelAliases: ['R6', 'YZFR6', 'YZF R6'],
|
||||
assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
|
||||
caption: '2001 Yamaha YZF-R6 (5EB)',
|
||||
attribution: 'Factory Supersport Reference',
|
||||
isPhoto: true,
|
||||
),
|
||||
];
|
||||
|
||||
static final List<MotorcycleImageDescriptor> _customDescriptors = [];
|
||||
|
||||
/// Registers a custom image descriptor (for extensions/plugins/tests)
|
||||
static void registerDescriptor(MotorcycleImageDescriptor descriptor) {
|
||||
_customDescriptors.add(descriptor);
|
||||
unified.MotorcycleImageService.registerDescriptor(descriptor);
|
||||
}
|
||||
|
||||
/// Clears custom descriptors (resets to default registry)
|
||||
static void resetRegistry() {
|
||||
_customDescriptors.clear();
|
||||
unified.MotorcycleImageService.resetRegistry();
|
||||
}
|
||||
|
||||
/// Returns all active descriptors in evaluation order (custom first, then defaults)
|
||||
List<MotorcycleImageDescriptor> get allDescriptors => [
|
||||
..._customDescriptors,
|
||||
..._defaultDescriptors,
|
||||
];
|
||||
List<MotorcycleImageDescriptor> get allDescriptors =>
|
||||
unified.MotorcycleImageService.instance.allDescriptors
|
||||
.map((d) => MotorcycleImageDescriptor(
|
||||
make: d.make,
|
||||
model: d.model,
|
||||
exactYear: d.exactYear,
|
||||
minYear: d.minYear,
|
||||
maxYear: d.maxYear,
|
||||
modelAliases: d.modelAliases,
|
||||
assetPath: d.assetPath,
|
||||
caption: d.caption,
|
||||
attribution: d.attribution,
|
||||
isPhoto: d.isPhoto,
|
||||
))
|
||||
.toList();
|
||||
|
||||
/// Resolves the best available photographic image or reference for a motorcycle
|
||||
///
|
||||
/// Matching Hierarchy:
|
||||
/// 1. Exact Year Match (Make + Model + Exact Year)
|
||||
/// 2. Generation / Year-Range Match (Make + Model + Generation Range)
|
||||
/// 3. Make + Model Match (Ageless / General Model Reference)
|
||||
/// 4. Dynamic Technical Schematic Fallback (Accurate make/model/year vector card)
|
||||
MotorcycleImageReference getReferenceImage({
|
||||
required String? make,
|
||||
required String? model,
|
||||
int? year,
|
||||
}) {
|
||||
if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) {
|
||||
return const MotorcycleImageReference(
|
||||
caption: 'Motorcycle Technical Reference',
|
||||
attribution: 'Schematic Vector',
|
||||
isPhoto: false,
|
||||
);
|
||||
}
|
||||
|
||||
final descriptors = allDescriptors;
|
||||
|
||||
// Tier 1: Exact Year Match
|
||||
if (year != null) {
|
||||
for (final desc in descriptors) {
|
||||
if (desc.exactYear != null && desc.matches(make, model, year)) {
|
||||
return desc.toReference();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Generation / Year-Range Match (e.g. 1999-2002 1st Gen R6)
|
||||
if (year != null) {
|
||||
for (final desc in descriptors) {
|
||||
if ((desc.minYear != null || desc.maxYear != null) && desc.matches(make, model, year)) {
|
||||
return desc.toReference();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: Make + Model Match (Ageless / General Model Reference)
|
||||
for (final desc in descriptors) {
|
||||
if (desc.exactYear == null && desc.minYear == null && desc.maxYear == null && desc.matches(make, model, year)) {
|
||||
return desc.toReference();
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 4: Graceful Technical Schematic Fallback
|
||||
final yearPrefix = year != null && year > 1900 ? '$year ' : '';
|
||||
final formattedCaption = '$yearPrefix${make.trim()} ${model.trim()}'.trim();
|
||||
final image = unified.MotorcycleImageService.instance.getReferenceImage(
|
||||
make: make,
|
||||
model: model,
|
||||
year: year,
|
||||
);
|
||||
return MotorcycleImageReference(
|
||||
caption: formattedCaption,
|
||||
attribution: 'Technical Schematic',
|
||||
isPhoto: false,
|
||||
assetPath: image.assetPath,
|
||||
networkUrl: image.imageUrl,
|
||||
caption: image.caption,
|
||||
attribution: image.attribution,
|
||||
isPhoto: image.isPhoto,
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether a genuine photographic asset is registered and available for a given vehicle
|
||||
bool hasPhotographicAsset({
|
||||
required String? make,
|
||||
required String? model,
|
||||
int? year,
|
||||
}) {
|
||||
final ref = getReferenceImage(make: make, model: model, year: year);
|
||||
return ref.isPhoto && ref.assetPath != null;
|
||||
}
|
||||
}) => unified.MotorcycleImageService.instance.hasPhotographicAsset(
|
||||
make: make,
|
||||
model: model,
|
||||
year: year,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,41 +3,133 @@ import 'dart:convert';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Resolved motorcycle image metadata used by every motorcycle UI surface.
|
||||
class MotorcycleImage {
|
||||
final String imageUrl;
|
||||
final String sourceUrl;
|
||||
final String? assetPath;
|
||||
final String? imageUrl;
|
||||
final String? sourceUrl;
|
||||
final String? credit;
|
||||
final String? license;
|
||||
final String caption;
|
||||
final String attribution;
|
||||
final bool isPhoto;
|
||||
|
||||
const MotorcycleImage({
|
||||
required this.imageUrl,
|
||||
required this.sourceUrl,
|
||||
this.assetPath,
|
||||
this.imageUrl,
|
||||
this.sourceUrl,
|
||||
this.credit,
|
||||
this.license,
|
||||
this.caption = 'Motorcycle Technical Reference',
|
||||
this.attribution = 'Technical Schematic',
|
||||
this.isPhoto = false,
|
||||
});
|
||||
|
||||
bool get hasImage => assetPath != null || imageUrl != null;
|
||||
}
|
||||
|
||||
/// Resolves a real motorcycle photograph for a make/model/year selection.
|
||||
/// Optional curated image descriptor for a motorcycle make/model/year range.
|
||||
class MotorcycleImageDescriptor {
|
||||
final String make;
|
||||
final String model;
|
||||
final int? exactYear;
|
||||
final int? minYear;
|
||||
final int? maxYear;
|
||||
final List<String> modelAliases;
|
||||
final String assetPath;
|
||||
final String caption;
|
||||
final String attribution;
|
||||
final bool isPhoto;
|
||||
|
||||
const MotorcycleImageDescriptor({
|
||||
required this.make,
|
||||
required this.model,
|
||||
this.exactYear,
|
||||
this.minYear,
|
||||
this.maxYear,
|
||||
this.modelAliases = const [],
|
||||
required this.assetPath,
|
||||
required this.caption,
|
||||
this.attribution = 'Factory Technical Reference',
|
||||
this.isPhoto = true,
|
||||
});
|
||||
|
||||
static String normalize(String input) =>
|
||||
input.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
|
||||
bool matches(String targetMake, String targetModel, int? targetYear) {
|
||||
if (normalize(make) != normalize(targetMake)) return false;
|
||||
|
||||
final normalizedTargetModel = normalize(targetModel);
|
||||
final modelMatches = normalize(model) == normalizedTargetModel ||
|
||||
modelAliases.any((alias) => normalize(alias) == normalizedTargetModel);
|
||||
if (!modelMatches) return false;
|
||||
|
||||
if (exactYear != null) return targetYear == exactYear;
|
||||
if (minYear != null || maxYear != null) {
|
||||
if (targetYear == null) return false;
|
||||
if (minYear != null && targetYear < minYear!) return false;
|
||||
if (maxYear != null && targetYear > maxYear!) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
MotorcycleImage toImage() => MotorcycleImage(
|
||||
assetPath: assetPath,
|
||||
caption: caption,
|
||||
attribution: attribution,
|
||||
isPhoto: isPhoto,
|
||||
);
|
||||
}
|
||||
|
||||
/// Single authoritative resolver for motorcycle imagery throughout MotorSignal.
|
||||
///
|
||||
/// Known exact matches are preferred first. Other motorcycles are resolved
|
||||
/// from Wikimedia Commons using its public API, with licensing metadata kept
|
||||
/// alongside the image for attribution in the UI.
|
||||
/// Resolution order:
|
||||
/// 1. Curated exact-year image
|
||||
/// 2. Curated generation/year-range image
|
||||
/// 3. Curated model image
|
||||
/// 4. Wikimedia Commons photograph with compatible licensing
|
||||
/// 5. Technical-reference fallback with no stale motorcycle imagery
|
||||
class MotorcycleImageService {
|
||||
MotorcycleImageService._();
|
||||
|
||||
static final MotorcycleImageService instance = MotorcycleImageService._();
|
||||
|
||||
static const List<String> bundledPhotographicAssets = [
|
||||
'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
|
||||
];
|
||||
|
||||
static const List<MotorcycleImageDescriptor> _defaultDescriptors = [
|
||||
MotorcycleImageDescriptor(
|
||||
make: 'Yamaha',
|
||||
model: 'YZF-R6',
|
||||
minYear: 1999,
|
||||
maxYear: 2002,
|
||||
modelAliases: ['R6', 'YZFR6', 'YZF R6'],
|
||||
assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
|
||||
caption: '2001 Yamaha YZF-R6 (5EB)',
|
||||
attribution: 'Factory Supersport Reference',
|
||||
isPhoto: true,
|
||||
),
|
||||
];
|
||||
|
||||
static final List<MotorcycleImageDescriptor> _customDescriptors = [];
|
||||
final Map<String, MotorcycleImage?> _cache = {};
|
||||
|
||||
static const _exactMatches = <String, MotorcycleImage>{
|
||||
'2001|yamaha|yzf-r6': MotorcycleImage(
|
||||
imageUrl:
|
||||
'https://upload.wikimedia.org/wikipedia/commons/e/e0/2001_Yamaha_YZFR600.jpg',
|
||||
sourceUrl:
|
||||
'https://commons.wikimedia.org/wiki/File:2001_Yamaha_YZFR600.jpg',
|
||||
credit: 'James McCauley / Wikimedia Commons',
|
||||
license: 'CC BY 2.0',
|
||||
),
|
||||
};
|
||||
static void registerDescriptor(MotorcycleImageDescriptor descriptor) {
|
||||
_customDescriptors.add(descriptor);
|
||||
instance._cache.clear();
|
||||
}
|
||||
|
||||
static void resetRegistry() {
|
||||
_customDescriptors.clear();
|
||||
instance._cache.clear();
|
||||
}
|
||||
|
||||
List<MotorcycleImageDescriptor> get allDescriptors => [
|
||||
..._customDescriptors,
|
||||
..._defaultDescriptors,
|
||||
];
|
||||
|
||||
Future<MotorcycleImage?> resolve({
|
||||
required int year,
|
||||
|
|
@ -47,10 +139,10 @@ class MotorcycleImageService {
|
|||
final key = _key(year, make, model);
|
||||
if (_cache.containsKey(key)) return _cache[key];
|
||||
|
||||
final exact = _exactMatches[key];
|
||||
if (exact != null) {
|
||||
_cache[key] = exact;
|
||||
return exact;
|
||||
final curated = _resolveCurated(make: make, model: model, year: year);
|
||||
if (curated != null) {
|
||||
_cache[key] = curated;
|
||||
return curated;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -88,13 +180,14 @@ class MotorcycleImageService {
|
|||
|
||||
MotorcycleImage? best;
|
||||
var bestScore = -1;
|
||||
final normalizedModel = _normalize(model);
|
||||
final normalizedMake = _normalize(make);
|
||||
final normalizedModel = MotorcycleImageDescriptor.normalize(model);
|
||||
final normalizedMake = MotorcycleImageDescriptor.normalize(make);
|
||||
final normalizedYear = year.toString();
|
||||
|
||||
for (final rawPage in pages) {
|
||||
final page = Map<String, dynamic>.from(rawPage as Map);
|
||||
final title = (page['title']?.toString() ?? '').toLowerCase();
|
||||
final normalizedTitle = title.replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
final infoList = page['imageinfo'];
|
||||
if (infoList is! List || infoList.isEmpty) continue;
|
||||
final info = Map<String, dynamic>.from(infoList.first as Map);
|
||||
|
|
@ -113,8 +206,8 @@ class MotorcycleImageService {
|
|||
if (!allowed) continue;
|
||||
|
||||
var score = 0;
|
||||
if (title.contains(normalizedModel)) score += 5;
|
||||
if (title.contains(normalizedMake)) score += 3;
|
||||
if (normalizedTitle.contains(normalizedModel)) score += 5;
|
||||
if (normalizedTitle.contains(normalizedMake)) score += 3;
|
||||
if (title.contains(normalizedYear)) score += 4;
|
||||
if (title.contains('motorcycle') || title.contains('motorbike')) score += 1;
|
||||
|
||||
|
|
@ -126,6 +219,9 @@ class MotorcycleImageService {
|
|||
credit: _metadataValue(metadata, 'Artist') ??
|
||||
_metadataValue(metadata, 'Credit'),
|
||||
license: license,
|
||||
caption: '$year $make $model',
|
||||
attribution: 'Wikimedia Commons',
|
||||
isPhoto: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -139,13 +235,66 @@ class MotorcycleImageService {
|
|||
}
|
||||
}
|
||||
|
||||
String _key(int year, String make, String model) =>
|
||||
'$year|${_normalize(make)}|${_normalize(model)}';
|
||||
/// Synchronous curated/fallback lookup for screens that should never wait on
|
||||
/// network resolution. This is the same authoritative hierarchy used by UI.
|
||||
MotorcycleImage getReferenceImage({
|
||||
required String? make,
|
||||
required String? model,
|
||||
int? year,
|
||||
}) {
|
||||
if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) {
|
||||
return const MotorcycleImage();
|
||||
}
|
||||
|
||||
String _normalize(String value) => value
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9]+'), '-')
|
||||
.replaceAll(RegExp(r'^-+|-+$'), '');
|
||||
final curated = _resolveCurated(make: make, model: model, year: year);
|
||||
if (curated != null) return curated;
|
||||
|
||||
final yearPrefix = year != null && year > 1900 ? '$year ' : '';
|
||||
return MotorcycleImage(
|
||||
caption: '$yearPrefix${make.trim()} ${model.trim()}'.trim(),
|
||||
attribution: 'Technical Schematic',
|
||||
isPhoto: false,
|
||||
);
|
||||
}
|
||||
|
||||
bool hasPhotographicAsset({
|
||||
required String? make,
|
||||
required String? model,
|
||||
int? year,
|
||||
}) => getReferenceImage(make: make, model: model, year: year).isPhoto;
|
||||
|
||||
MotorcycleImage? _resolveCurated({
|
||||
required String make,
|
||||
required String model,
|
||||
required int? year,
|
||||
}) {
|
||||
if (year != null) {
|
||||
for (final descriptor in allDescriptors) {
|
||||
if (descriptor.exactYear != null && descriptor.matches(make, model, year)) {
|
||||
return descriptor.toImage();
|
||||
}
|
||||
}
|
||||
for (final descriptor in allDescriptors) {
|
||||
if ((descriptor.minYear != null || descriptor.maxYear != null) &&
|
||||
descriptor.matches(make, model, year)) {
|
||||
return descriptor.toImage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final descriptor in allDescriptors) {
|
||||
if (descriptor.exactYear == null &&
|
||||
descriptor.minYear == null &&
|
||||
descriptor.maxYear == null &&
|
||||
descriptor.matches(make, model, year)) {
|
||||
return descriptor.toImage();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _key(int year, String make, String model) =>
|
||||
'$year|${MotorcycleImageDescriptor.normalize(make)}|${MotorcycleImageDescriptor.normalize(model)}';
|
||||
|
||||
String? _metadataValue(Map<String, dynamic> metadata, String key) {
|
||||
final value = metadata[key];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/garage/motorcycle_image_service.dart';
|
||||
import 'package:motorcycle_dash/services/motorcycle_image_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -10,7 +10,7 @@ void main() {
|
|||
|
||||
setUp(() {
|
||||
MotorcycleImageService.resetRegistry();
|
||||
service = MotorcycleImageService();
|
||||
service = MotorcycleImageService.instance;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
|
|
@ -18,47 +18,34 @@ void main() {
|
|||
});
|
||||
|
||||
test('1. 2001 Yamaha YZF-R6 returns verified authentic photograph', () {
|
||||
final ref = service.getReferenceImage(
|
||||
make: 'Yamaha',
|
||||
model: 'YZF-R6',
|
||||
year: 2001,
|
||||
);
|
||||
|
||||
final ref = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2001);
|
||||
expect(ref.isPhoto, isTrue);
|
||||
expect(ref.hasImage, isTrue);
|
||||
expect(ref.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png'));
|
||||
expect(ref.caption, contains('2001 Yamaha YZF-R6'));
|
||||
expect(ref.attribution, equals('Factory Supersport Reference'));
|
||||
|
||||
expect(service.hasPhotographicAsset(make: 'Yamaha', model: 'YZF-R6', year: 2001), isTrue);
|
||||
});
|
||||
|
||||
test('2. 1st Generation Yamaha YZF-R6 aliases resolve correctly', () {
|
||||
// Model alias "R6"
|
||||
final r6Alias = service.getReferenceImage(make: 'Yamaha', model: 'R6', year: 2001);
|
||||
expect(r6Alias.isPhoto, isTrue);
|
||||
expect(r6Alias.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png'));
|
||||
|
||||
// Model alias "YZF R6"
|
||||
final spaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZF R6', year: 2001);
|
||||
expect(spaceAlias.isPhoto, isTrue);
|
||||
|
||||
// Model alias "YZFR6"
|
||||
final noSpaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZFR6', year: 2000);
|
||||
expect(noSpaceAlias.isPhoto, isTrue);
|
||||
});
|
||||
|
||||
test('3. Model-generation year matching works for 1999-2002 5EB', () {
|
||||
// 1999 1st Gen
|
||||
final gen1999 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 1999);
|
||||
expect(gen1999.isPhoto, isTrue);
|
||||
|
||||
// 2002 1st Gen
|
||||
final gen2002 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2002);
|
||||
expect(gen2002.isPhoto, isTrue);
|
||||
|
||||
// 2008 3rd Gen (different generation without bundled photo asset)
|
||||
// Must NOT return 1st Gen 5EB photo asset
|
||||
final gen2008 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2008);
|
||||
expect(gen2008.isPhoto, isFalse);
|
||||
expect(gen2008.assetPath, isNull);
|
||||
|
|
@ -67,12 +54,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('4. Motorcycle with no bundled photograph returns graceful technical schematic', () {
|
||||
final ref = service.getReferenceImage(
|
||||
make: 'Honda',
|
||||
model: 'CBR600RR',
|
||||
year: 2018,
|
||||
);
|
||||
|
||||
final ref = service.getReferenceImage(make: 'Honda', model: 'CBR600RR', year: 2018);
|
||||
expect(ref.isPhoto, isFalse);
|
||||
expect(ref.hasImage, isFalse);
|
||||
expect(ref.assetPath, isNull);
|
||||
|
|
@ -122,10 +104,8 @@ void main() {
|
|||
final make = tc['make'] as String;
|
||||
final model = tc['model'] as String;
|
||||
final year = tc['year'] as int;
|
||||
|
||||
final ref = service.getReferenceImage(make: make, model: model, year: year);
|
||||
|
||||
// Prove R6 photograph is NOT returned
|
||||
expect(
|
||||
ref.assetPath,
|
||||
isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')),
|
||||
|
|
@ -138,11 +118,9 @@ void main() {
|
|||
});
|
||||
|
||||
test('7. Architecture is genuinely extensible via dynamic descriptor registration', () {
|
||||
// 1. Initial lookup for 2024 Ducati Panigale V4 returns schematic fallback
|
||||
final beforeReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024);
|
||||
expect(beforeReg.isPhoto, isFalse);
|
||||
|
||||
// 2. Register custom descriptor without modifying UI code
|
||||
MotorcycleImageService.registerDescriptor(
|
||||
const MotorcycleImageDescriptor(
|
||||
make: 'Ducati',
|
||||
|
|
@ -157,13 +135,11 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
// 3. Lookup now matches Tier 2 generation descriptor
|
||||
final afterReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024);
|
||||
expect(afterReg.isPhoto, isTrue);
|
||||
expect(afterReg.assetPath, equals('assets/images/motorcycles/ducati_panigale_v4.png'));
|
||||
expect(afterReg.caption, contains('Ducati Panigale V4'));
|
||||
|
||||
// 4. Exact matching works for aliases
|
||||
final aliasReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4 S', year: 2023);
|
||||
expect(aliasReg.isPhoto, isTrue);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue