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;
|
||
}
|
||
}
|