From 8f1ed177b0ec06dfd10525539d5f67456abbce92 Mon Sep 17 00:00:00 2001 From: bponders12-cell Date: Tue, 1 Sep 2026 12:10:22 -0400 Subject: [PATCH] feat(garage): add real motorcycle image resolver --- lib/services/motorcycle_image_service.dart | 155 +++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 lib/services/motorcycle_image_service.dart diff --git a/lib/services/motorcycle_image_service.dart b/lib/services/motorcycle_image_service.dart new file mode 100644 index 0000000..6994fc5 --- /dev/null +++ b/lib/services/motorcycle_image_service.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +class MotorcycleImage { + final String imageUrl; + final String sourceUrl; + final String? credit; + final String? license; + + const MotorcycleImage({ + required this.imageUrl, + required this.sourceUrl, + this.credit, + this.license, + }); +} + +/// Resolves a real motorcycle photograph for a make/model/year selection. +/// +/// 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. +class MotorcycleImageService { + MotorcycleImageService._(); + + static final MotorcycleImageService instance = MotorcycleImageService._(); + 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', + ), + }; + + Future resolve({ + required int year, + required String make, + required String model, + }) async { + 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; + } + + try { + final query = '$year $make $model motorcycle'; + final uri = Uri.https('commons.wikimedia.org', '/w/api.php', { + 'action': 'query', + 'format': 'json', + 'origin': '*', + 'generator': 'search', + 'gsrnamespace': '6', + 'gsrsearch': query, + 'gsrlimit': '8', + 'prop': 'imageinfo', + 'iiprop': 'url|extmetadata', + 'iiurlwidth': '900', + 'iiextmetadatafilter': 'Artist|Credit|LicenseShortName|LicenseUrl', + }); + + final response = await http.get(uri, headers: const { + 'Accept': 'application/json', + 'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)', + }).timeout(const Duration(seconds: 8)); + + if (response.statusCode != 200) { + _cache[key] = null; + return null; + } + + final json = jsonDecode(response.body) as Map; + final pages = (json['query']?['pages'] as Map?)?.values; + if (pages == null) { + _cache[key] = null; + return null; + } + + MotorcycleImage? best; + var bestScore = -1; + final normalizedModel = _normalize(model); + final normalizedMake = _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 infoList = page['imageinfo']; + if (infoList is! List || infoList.isEmpty) continue; + final info = Map.from(infoList.first as Map); + final thumb = info['thumburl']?.toString() ?? info['url']?.toString(); + final source = info['descriptionurl']?.toString(); + if (thumb == null || source == null) continue; + + final metadata = Map.from( + (info['extmetadata'] as Map?) ?? const {}, + ); + final license = _metadataValue(metadata, 'LicenseShortName'); + final licenseLower = (license ?? '').toLowerCase(); + final allowed = licenseLower.contains('cc by') || + licenseLower.contains('public domain') || + licenseLower.contains('cc0'); + if (!allowed) continue; + + var score = 0; + if (title.contains(normalizedModel)) score += 5; + if (title.contains(normalizedMake)) score += 3; + if (title.contains(normalizedYear)) score += 4; + if (title.contains('motorcycle') || title.contains('motorbike')) score += 1; + + if (score > bestScore) { + bestScore = score; + best = MotorcycleImage( + imageUrl: thumb, + sourceUrl: source, + credit: _metadataValue(metadata, 'Artist') ?? + _metadataValue(metadata, 'Credit'), + license: license, + ); + } + } + + _cache[key] = best; + return best; + } catch (error) { + debugPrint('Motorcycle image lookup failed: $error'); + _cache[key] = null; + return null; + } + } + + String _key(int year, String make, String model) => + '$year|${_normalize(make)}|${_normalize(model)}'; + + String _normalize(String value) => value + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + + String? _metadataValue(Map metadata, String key) { + final value = metadata[key]; + if (value is Map) return value['value']?.toString(); + return value?.toString(); + } +}