import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/sakenowa/sakenowa_models.dart'; /// さけのわAPI クライアントサービス /// /// API Documentation: https://muro.sakenowa.com/sakenowa-data/api/ /// 利用規約: 無料・商用OK、帰属表示必須("Powered by さけのわデータ") class SakenowaService { static const _baseUrl = 'https://muro.sakenowa.com/sakenowa-data/api'; // キャッシュ(メモリ内) List? _brandsCache; List? _breweriesCache; List? _areasCache; List? _flavorChartsCache; DateTime? _lastFetch; // キャッシュ有効期限(24時間) static const _cacheDuration = Duration(hours: 24); /// 全銘柄データを取得 Future> getBrands({bool forceRefresh = false}) async { if (!forceRefresh && _brandsCache != null && _isCacheValid()) { return _brandsCache!; } final response = await http.get(Uri.parse('$_baseUrl/brands')); if (response.statusCode != 200) { throw Exception('Failed to fetch brands: ${response.statusCode}'); } final data = jsonDecode(response.body); _brandsCache = (data['brands'] as List) .map((e) => SakenowaBrand.fromJson(e)) .toList(); _lastFetch = DateTime.now(); return _brandsCache!; } /// 全蔵元データを取得 Future> getBreweries({bool forceRefresh = false}) async { if (!forceRefresh && _breweriesCache != null && _isCacheValid()) { return _breweriesCache!; } final response = await http.get(Uri.parse('$_baseUrl/breweries')); if (response.statusCode != 200) { throw Exception('Failed to fetch breweries: ${response.statusCode}'); } final data = jsonDecode(response.body); _breweriesCache = (data['breweries'] as List) .map((e) => SakenowaBrewery.fromJson(e)) .toList(); return _breweriesCache!; } /// 全地域データを取得 Future> getAreas({bool forceRefresh = false}) async { if (!forceRefresh && _areasCache != null && _isCacheValid()) { return _areasCache!; } final response = await http.get(Uri.parse('$_baseUrl/areas')); if (response.statusCode != 200) { throw Exception('Failed to fetch areas: ${response.statusCode}'); } final data = jsonDecode(response.body); _areasCache = (data['areas'] as List) .map((e) => SakenowaArea.fromJson(e)) .toList(); return _areasCache!; } /// 全フレーバーチャートを取得 Future> getFlavorCharts({bool forceRefresh = false}) async { if (!forceRefresh && _flavorChartsCache != null && _isCacheValid()) { return _flavorChartsCache!; } final response = await http.get(Uri.parse('$_baseUrl/flavor-charts')); if (response.statusCode != 200) { throw Exception('Failed to fetch flavor charts: ${response.statusCode}'); } final data = jsonDecode(response.body); _flavorChartsCache = (data['flavorCharts'] as List) .map((e) => SakenowaFlavorChart.fromJson(e)) .toList(); return _flavorChartsCache!; } /// 銘柄IDからフレーバーチャートを取得 Future getFlavorChartByBrandId(int brandId) async { final charts = await getFlavorCharts(); return charts.cast().firstWhere( (c) => c?.brandId == brandId, orElse: () => null, ); } /// ランキングを取得(全国) Future> getRankings() async { try { final response = await http.get(Uri.parse('$_baseUrl/rankings')); if (response.statusCode != 200) { throw Exception('Failed to fetch rankings: ${response.statusCode}'); } final data = jsonDecode(response.body); // APIレスポンスは 'overall' キーに全国ランキングが入っている final overallList = data['overall'] as List?; if (overallList == null) { throw Exception('Rankings data not found in response'); } return overallList .map((e) => SakenowaRanking.fromJson(e)) .toList(); } catch (e) { rethrow; } } /// フレーバータグ一覧を取得 Future> getFlavorTags() async { final response = await http.get(Uri.parse('$_baseUrl/flavor-tags')); if (response.statusCode != 200) { throw Exception('Failed to fetch flavor tags: ${response.statusCode}'); } final data = jsonDecode(response.body); return (data['tags'] as List) .map((e) => SakenowaFlavorTag.fromJson(e)) .toList(); } /// 銘柄名で検索(部分一致) Future> searchBrandsByName(String query) async { final brands = await getBrands(); final normalizedQuery = query.toLowerCase(); return brands.where((brand) { return brand.name.toLowerCase().contains(normalizedQuery); }).toList(); } /// 銘柄名から最も近いマッチを取得 Future findBestMatch(String name) async { final brands = await getBrands(); final normalizedName = name.toLowerCase().replaceAll(' ', ''); // 完全一致を優先 for (final brand in brands) { if (brand.name.toLowerCase().replaceAll(' ', '') == normalizedName) { return brand; } } // 部分一致 for (final brand in brands) { if (brand.name.toLowerCase().contains(normalizedName) || normalizedName.contains(brand.name.toLowerCase())) { return brand; } } return null; } /// 銘柄の蔵元情報を取得 Future getBreweryByBrandId(int brandId) async { final brands = await getBrands(); final brand = brands.cast().firstWhere( (b) => b?.id == brandId, orElse: () => null, ); if (brand == null) return null; final breweries = await getBreweries(); return breweries.cast().firstWhere( (b) => b?.id == brand.breweryId, orElse: () => null, ); } /// キャッシュが有効かチェック bool _isCacheValid() { if (_lastFetch == null) return false; return DateTime.now().difference(_lastFetch!) < _cacheDuration; } /// キャッシュをクリア void clearCache() { _brandsCache = null; _breweriesCache = null; _areasCache = null; _flavorChartsCache = null; _lastFetch = null; } }