diff --git a/lib/features/garage/widgets/motorcycle_photo.dart b/lib/features/garage/widgets/motorcycle_photo.dart index ec59177..4371211 100644 --- a/lib/features/garage/widgets/motorcycle_photo.dart +++ b/lib/features/garage/widgets/motorcycle_photo.dart @@ -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}'; } } diff --git a/lib/services/garage/motorcycle_image_service.dart b/lib/services/garage/motorcycle_image_service.dart index abf8aed..26e6782 100644 --- a/lib/services/garage/motorcycle_image_service.dart +++ b/lib/services/garage/motorcycle_image_service.dart @@ -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 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 bundledPhotographicAssets = [ - 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', - ]; + static List get bundledPhotographicAssets => + unified.MotorcycleImageService.bundledPhotographicAssets; - /// Default registry of curated motorcycle reference descriptors - static final List _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 _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 get allDescriptors => [ - ..._customDescriptors, - ..._defaultDescriptors, - ]; + List 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, + ); } diff --git a/lib/services/motorcycle_image_service.dart b/lib/services/motorcycle_image_service.dart index 6994fc5..eab8603 100644 --- a/lib/services/motorcycle_image_service.dart +++ b/lib/services/motorcycle_image_service.dart @@ -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 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 bundledPhotographicAssets = [ + 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + ]; + + static const List _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 _customDescriptors = []; final Map _cache = {}; - static const _exactMatches = { - '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 get allDescriptors => [ + ..._customDescriptors, + ..._defaultDescriptors, + ]; Future 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.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.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 metadata, String key) { final value = metadata[key]; diff --git a/test/motorcycle_image_selection_audit_test.dart b/test/motorcycle_image_selection_audit_test.dart index 5b25280..583e962 100644 --- a/test/motorcycle_image_selection_audit_test.dart +++ b/test/motorcycle_image_selection_audit_test.dart @@ -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); });