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:
bponders12-cell 2026-09-01 20:31:24 -04:00 committed by GitHub
parent 432253b11f
commit ed269a0e67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 276 additions and 234 deletions

View File

@ -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}';
}
}

View File

@ -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,
);
}

View File

@ -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];

View File

@ -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);
});