ponshu-room-lite/lib/services/sakenowa_service.dart

208 lines
6.5 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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