208 lines
6.5 KiB
Dart
208 lines
6.5 KiB
Dart
|
|
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<SakenowaBrand>? _brandsCache;
|
|||
|
|
List<SakenowaBrewery>? _breweriesCache;
|
|||
|
|
List<SakenowaArea>? _areasCache;
|
|||
|
|
List<SakenowaFlavorChart>? _flavorChartsCache;
|
|||
|
|
DateTime? _lastFetch;
|
|||
|
|
|
|||
|
|
// キャッシュ有効期限(24時間)
|
|||
|
|
static const _cacheDuration = Duration(hours: 24);
|
|||
|
|
|
|||
|
|
/// 全銘柄データを取得
|
|||
|
|
Future<List<SakenowaBrand>> 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<List<SakenowaBrewery>> 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<List<SakenowaArea>> 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<List<SakenowaFlavorChart>> 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<SakenowaFlavorChart?> getFlavorChartByBrandId(int brandId) async {
|
|||
|
|
final charts = await getFlavorCharts();
|
|||
|
|
return charts.cast<SakenowaFlavorChart?>().firstWhere(
|
|||
|
|
(c) => c?.brandId == brandId,
|
|||
|
|
orElse: () => null,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// ランキングを取得(全国)
|
|||
|
|
Future<List<SakenowaRanking>> 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<List<SakenowaFlavorTag>> 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<List<SakenowaBrand>> searchBrandsByName(String query) async {
|
|||
|
|
final brands = await getBrands();
|
|||
|
|
final normalizedQuery = query.toLowerCase();
|
|||
|
|
|
|||
|
|
return brands.where((brand) {
|
|||
|
|
return brand.name.toLowerCase().contains(normalizedQuery);
|
|||
|
|
}).toList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 銘柄名から最も近いマッチを取得
|
|||
|
|
Future<SakenowaBrand?> 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<SakenowaBrewery?> getBreweryByBrandId(int brandId) async {
|
|||
|
|
final brands = await getBrands();
|
|||
|
|
final brand = brands.cast<SakenowaBrand?>().firstWhere(
|
|||
|
|
(b) => b?.id == brandId,
|
|||
|
|
orElse: () => null,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (brand == null) return null;
|
|||
|
|
|
|||
|
|
final breweries = await getBreweries();
|
|||
|
|
return breweries.cast<SakenowaBrewery?>().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;
|
|||
|
|
}
|
|||
|
|
}
|