305 lines
9.5 KiB
Dart
305 lines
9.5 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.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 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,
|
|
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 {
|
|
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<String, dynamic>;
|
|
final pages = (json['query']?['pages'] as Map?)?.values;
|
|
if (pages == null) {
|
|
_cache[key] = null;
|
|
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 > 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
_cache[key] = best;
|
|
return best;
|
|
} catch (error) {
|
|
debugPrint('Motorcycle image lookup failed: $error');
|
|
_cache[key] = null;
|
|
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();
|
|
}
|
|
}
|