MotorSignal-App/lib/services/motorcycle_image_service.dart

347 lines
10 KiB
Dart

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? assetPath;
final String? imageUrl;
final String? sourceUrl;
final String? credit;
final String? license;
final String caption;
final String attribution;
final bool isPhoto;
const MotorcycleImage({
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;
}
/// 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.
///
/// 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.jpg',
];
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.jpg',
caption: '2001 Yamaha YZF-R6 (5EB)',
attribution: 'James McCauley / Wikimedia Commons, CC BY 2.0',
isPhoto: true,
),
];
static final List<MotorcycleImageDescriptor> _customDescriptors = [];
final Map<String, MotorcycleImage?> _cache = {};
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,
];
MotorcycleImage? getCachedOrCuratedImage({
required int year,
required String make,
required String model,
}) {
final key = _key(year, make, model);
if (_cache.containsKey(key)) return _cache[key];
return _resolveCurated(make: make, model: model, year: year);
}
Future<MotorcycleImage?> 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 curated = _resolveCurated(make: make, model: model, year: year);
if (curated != null) {
_cache[key] = curated;
return curated;
}
try {
// Cascading query strategy:
// 1. Exact make + model + year
// 2. Broad make + model
final queries = ['$make $model $year', '$make $model'];
for (final query in queries) {
final image = await _searchWikimedia(query, year, make, model);
if (image != null) {
_cache[key] = image;
return image;
}
}
_cache[key] = null;
return null;
} catch (error) {
debugPrint('Motorcycle image lookup failed: $error');
_cache[key] = null;
return null;
}
}
Future<MotorcycleImage?> _searchWikimedia(
String query,
int year,
String make,
String model,
) async {
try {
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: 6));
if (response.statusCode != 200) return null;
final json = jsonDecode(response.body) as Map<String, dynamic>;
final pages = (json['query']?['pages'] as Map?)?.values;
if (pages == null || pages.isEmpty) return null;
MotorcycleImage? best;
var bestScore = -1;
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);
final thumb = info['thumburl']?.toString() ?? info['url']?.toString();
final source = info['descriptionurl']?.toString();
if (thumb == null || source == null) continue;
final metadata = Map<String, dynamic>.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 (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;
}
if (score >= 4 && score > bestScore) {
bestScore = score;
best = MotorcycleImage(
imageUrl: thumb,
sourceUrl: source,
credit:
_metadataValue(metadata, 'Artist') ??
_metadataValue(metadata, 'Credit'),
license: license,
caption: '$year $make $model',
attribution: 'Wikimedia Commons',
isPhoto: true,
);
}
}
return best;
} catch (_) {
return null;
}
}
/// 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();
}
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];
if (value is Map) return value['value']?.toString();
return value?.toString();
}
}