Files
lunchpick/lib/data/repositories/weather_repository_impl.dart

500 lines
15 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:lunchpick/core/constants/api_keys.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import 'package:lunchpick/domain/entities/weather_info.dart';
import 'package:lunchpick/domain/repositories/weather_repository.dart';
class WeatherRepositoryImpl implements WeatherRepository {
static const String _boxName = 'weather_cache';
static const String _keyCachedWeather = 'cached_weather';
static const String _keyLastUpdateTime = 'last_update_time';
static const Duration _cacheValidDuration = Duration(hours: 1);
Future<Box> get _box async => await Hive.openBox(_boxName);
@override
Future<WeatherInfo> getCurrentWeather({
required double latitude,
required double longitude,
}) async {
final cached = await getCachedWeather();
try {
final weather = await _fetchWeatherFromKma(
latitude: latitude,
longitude: longitude,
);
await cacheWeatherInfo(weather);
return weather;
} catch (_) {
if (cached != null) {
return cached;
}
return WeatherInfo(
current: WeatherData(
temperature: 20,
isRainy: false,
description: '날씨 정보를 불러오지 못했어요',
),
nextHour: WeatherData(
temperature: 20,
isRainy: false,
description: '날씨 정보를 불러오지 못했어요',
),
);
}
}
@override
Future<WeatherInfo?> getCachedWeather() async {
final box = await _box;
// 캐시가 유효한지 확인
final isValid = await _isCacheValid();
if (!isValid) {
return null;
}
// 캐시된 데이터 가져오기
final cachedData = box.get(_keyCachedWeather);
if (cachedData == null) {
return null;
}
try {
// 안전한 타입 변환
if (cachedData is! Map) {
AppLogger.debug(
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
);
await clearWeatherCache();
return null;
}
final Map<String, dynamic> weatherMap = Map<String, dynamic>.from(
cachedData,
);
// Map 구조 검증
if (!weatherMap.containsKey('current') ||
!weatherMap.containsKey('nextHour')) {
AppLogger.debug(
'WeatherCache: Missing required fields in weather data',
);
await clearWeatherCache();
return null;
}
return _weatherInfoFromMap(weatherMap);
} catch (e) {
// 캐시 데이터가 손상된 경우
AppLogger.error(
'WeatherCache: Error parsing cached weather data',
error: e,
);
await clearWeatherCache();
return null;
}
}
@override
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo) async {
final box = await _box;
// WeatherInfo를 Map으로 변환하여 저장
final weatherMap = _weatherInfoToMap(weatherInfo);
await box.put(_keyCachedWeather, weatherMap);
await box.put(_keyLastUpdateTime, DateTime.now().toIso8601String());
}
@override
Future<void> clearWeatherCache() async {
final box = await _box;
await box.delete(_keyCachedWeather);
await box.delete(_keyLastUpdateTime);
}
@override
Future<bool> isWeatherUpdateNeeded() async {
final box = await _box;
// 캐시된 날씨 정보가 없으면 업데이트 필요
if (!box.containsKey(_keyCachedWeather)) {
return true;
}
// 캐시가 유효한지 확인
return !(await _isCacheValid());
}
Future<bool> _isCacheValid() async {
final box = await _box;
final lastUpdateTimeStr = box.get(_keyLastUpdateTime);
if (lastUpdateTimeStr == null) {
return false;
}
try {
// 날짜 파싱 시도
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
if (lastUpdateTime == null) {
AppLogger.debug(
'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr',
);
return false;
}
final now = DateTime.now();
final difference = now.difference(lastUpdateTime);
return difference < _cacheValidDuration;
} catch (e) {
AppLogger.error('WeatherCache: Error checking cache validity', error: e);
return false;
}
}
Map<String, dynamic> _weatherInfoToMap(WeatherInfo weatherInfo) {
return {
'current': {
'temperature': weatherInfo.current.temperature,
'isRainy': weatherInfo.current.isRainy,
'description': weatherInfo.current.description,
},
'nextHour': {
'temperature': weatherInfo.nextHour.temperature,
'isRainy': weatherInfo.nextHour.isRainy,
'description': weatherInfo.nextHour.description,
},
};
}
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
try {
// current 필드 검증
final currentRaw = map['current'];
final currentMap = currentRaw is Map
? Map<String, dynamic>.from(currentRaw)
: null;
if (currentMap == null) {
throw FormatException('Missing current weather data');
}
// nextHour 필드 검증
final nextHourRaw = map['nextHour'];
final nextHourMap = nextHourRaw is Map
? Map<String, dynamic>.from(nextHourRaw)
: null;
if (nextHourMap == null) {
throw FormatException('Missing nextHour weather data');
}
// 필수 필드 검증 및 기본값 제공
final currentTemp = currentMap['temperature'] as num? ?? 20;
final currentRainy = currentMap['isRainy'] as bool? ?? false;
final currentDesc = currentMap['description'] as String? ?? '알 수 없음';
final nextTemp = nextHourMap['temperature'] as num? ?? 20;
final nextRainy = nextHourMap['isRainy'] as bool? ?? false;
final nextDesc = nextHourMap['description'] as String? ?? '알 수 없음';
return WeatherInfo(
current: WeatherData(
temperature: currentTemp.round(),
isRainy: currentRainy,
description: currentDesc,
),
nextHour: WeatherData(
temperature: nextTemp.round(),
isRainy: nextRainy,
description: nextDesc,
),
);
} catch (e) {
AppLogger.error(
'WeatherCache: Error converting map to WeatherInfo',
error: e,
stackTrace: StackTrace.current,
);
AppLogger.debug('WeatherCache: Map data: $map');
rethrow;
}
}
Future<WeatherInfo> _fetchWeatherFromKma({
required double latitude,
required double longitude,
}) async {
final serviceKey = _encodeServiceKey(ApiKeys.weatherServiceKey);
if (serviceKey.isEmpty) {
throw Exception('기상청 서비스 키가 설정되지 않았습니다.');
}
final gridPoint = _latLonToGrid(latitude, longitude);
final baseDateTime = _resolveBaseDateTime();
final ncstUri = Uri.https(
'apis.data.go.kr',
'/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst',
{
'serviceKey': serviceKey,
'numOfRows': '100',
'pageNo': '1',
'dataType': 'JSON',
'base_date': baseDateTime.date,
'base_time': baseDateTime.ncstTime,
'nx': gridPoint.x.toString(),
'ny': gridPoint.y.toString(),
},
);
final fcstUri = Uri.https(
'apis.data.go.kr',
'/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst',
{
'serviceKey': serviceKey,
'numOfRows': '200',
'pageNo': '1',
'dataType': 'JSON',
'base_date': baseDateTime.date,
'base_time': baseDateTime.fcstTime,
'nx': gridPoint.x.toString(),
'ny': gridPoint.y.toString(),
},
);
final ncstItems = await _requestKmaItems(ncstUri);
final fcstItems = await _requestKmaItems(fcstUri);
final currentTemp = _extractLatestValue(ncstItems, 'T1H')?.round();
final currentPty = _extractLatestValue(ncstItems, 'PTY')?.round() ?? 0;
final currentSky = _extractLatestValue(ncstItems, 'SKY')?.round() ?? 1;
final now = DateTime.now();
final nextHourData = _extractForecast(fcstItems, after: now);
final nextTemp = nextHourData.temperature?.round();
final nextPty = nextHourData.pty ?? 0;
final nextSky = nextHourData.sky ?? 1;
final currentWeather = WeatherData(
temperature: currentTemp ?? 20,
isRainy: _isRainy(currentPty),
description: _describeWeather(currentSky, currentPty),
);
final nextWeather = WeatherData(
temperature: nextTemp ?? currentTemp ?? 20,
isRainy: _isRainy(nextPty),
description: _describeWeather(nextSky, nextPty),
);
return WeatherInfo(current: currentWeather, nextHour: nextWeather);
}
Future<List<dynamic>> _requestKmaItems(Uri uri) async {
final response = await http.get(uri);
if (response.statusCode != 200) {
throw Exception('Weather API 호출 실패: ${response.statusCode}');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = body['response']?['body']?['items']?['item'];
if (items is List<dynamic>) {
return items;
}
throw Exception('Weather API 응답 파싱 실패');
}
double? _extractLatestValue(List<dynamic> items, String category) {
final filtered = items.where((item) => item['category'] == category);
if (filtered.isEmpty) return null;
final sorted = filtered.toList()
..sort((a, b) {
final dateA = a['baseDate'] as String? ?? '';
final timeA = a['baseTime'] as String? ?? '';
final dateB = b['baseDate'] as String? ?? '';
final timeB = b['baseTime'] as String? ?? '';
final dtA = _parseKmaDateTime(dateA, timeA);
final dtB = _parseKmaDateTime(dateB, timeB);
return dtB.compareTo(dtA);
});
final value = sorted.first['obsrValue'] ?? sorted.first['fcstValue'];
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
({double? temperature, int? pty, int? sky}) _extractForecast(
List<dynamic> items, {
required DateTime after,
}) {
DateTime? targetTime;
double? temperature;
int? pty;
int? sky;
DateTime fcstDateTime(Map<String, dynamic> item) {
final date = item['fcstDate'] as String? ?? '';
final time = item['fcstTime'] as String? ?? '';
return _parseKmaDateTime(date, time);
}
for (final item in items) {
final dt = fcstDateTime(item as Map<String, dynamic>);
if (!dt.isAfter(after)) continue;
if (targetTime == null || dt.isBefore(targetTime)) {
targetTime = dt;
}
}
if (targetTime == null) {
return (temperature: null, pty: null, sky: null);
}
for (final item in items) {
final map = item as Map<String, dynamic>;
final dt = fcstDateTime(map);
if (dt != targetTime) continue;
final category = map['category'];
final value = map['fcstValue'];
if (value == null) continue;
if (category == 'T1H' && temperature == null) {
temperature = value is num
? value.toDouble()
: double.tryParse('$value');
} else if (category == 'PTY' && pty == null) {
pty = value is num ? value.toInt() : int.tryParse('$value');
} else if (category == 'SKY' && sky == null) {
sky = value is num ? value.toInt() : int.tryParse('$value');
}
}
return (temperature: temperature, pty: pty, sky: sky);
}
_GridPoint _latLonToGrid(double lat, double lon) {
const re = 6371.00877;
const grid = 5.0;
const slat1 = 30.0 * pi / 180.0;
const slat2 = 60.0 * pi / 180.0;
const olon = 126.0 * pi / 180.0;
const olat = 38.0 * pi / 180.0;
const xo = 43.0;
const yo = 136.0;
final sn =
log(cos(slat1) / cos(slat2)) /
log(tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5));
final sf = pow(tan(pi * 0.25 + slat1 * 0.5), sn) * cos(slat1) / sn;
final ro = re / grid * sf / pow(tan(pi * 0.25 + olat * 0.5), sn);
final ra =
re / grid * sf / pow(tan(pi * 0.25 + (lat * pi / 180.0) * 0.5), sn);
var theta = lon * pi / 180.0 - olon;
if (theta > pi) theta -= 2.0 * pi;
if (theta < -pi) theta += 2.0 * pi;
theta *= sn;
final x = (ra * sin(theta) + xo + 0.5).floor();
final y = (ro - ra * cos(theta) + yo + 0.5).floor();
return _GridPoint(x: x, y: y);
}
bool _isRainy(int pty) => pty > 0;
String _describeWeather(int sky, int pty) {
if (pty == 1) return '';
if (pty == 2) return '비/눈';
if (pty == 3) return '';
if (pty == 4) return '소나기';
if (pty == 5) return '빗방울';
if (pty == 6) return '빗방울/눈날림';
if (pty == 7) return '눈날림';
switch (sky) {
case 1:
return '맑음';
case 3:
return '구름 많음';
case 4:
return '흐림';
default:
return '맑음';
}
}
/// 서비스 키를 안전하게 URL 인코딩한다.
/// 이미 인코딩된 값(%)이 포함되어 있으면 그대로 사용한다.
String _encodeServiceKey(String key) {
if (key.isEmpty) return '';
if (key.contains('%')) return key;
return Uri.encodeComponent(key);
}
_BaseDateTime _resolveBaseDateTime() {
final now = DateTime.now();
// 초단기실황은 매시 정시 발표(정시+10분 이후 호출 권장)
// 초단기예보는 매시 30분 발표(30분+10분 이후 호출 권장)
final ncstAnchor = now.minute >= 10
? DateTime(now.year, now.month, now.day, now.hour, 0)
: DateTime(now.year, now.month, now.day, now.hour - 1, 0);
final fcstAnchor = now.minute >= 40
? DateTime(now.year, now.month, now.day, now.hour, 30)
: DateTime(now.year, now.month, now.day, now.hour - 1, 30);
final date = _formatDate(fcstAnchor); // 둘 다 같은 날짜/시점 기준
final ncstTime = _formatTime(ncstAnchor);
final fcstTime = _formatTime(fcstAnchor);
return _BaseDateTime(date: date, ncstTime: ncstTime, fcstTime: fcstTime);
}
String _formatDate(DateTime dt) {
final y = dt.year.toString().padLeft(4, '0');
final m = dt.month.toString().padLeft(2, '0');
final d = dt.day.toString().padLeft(2, '0');
return '$y$m$d';
}
String _formatTime(DateTime dt) {
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
return '$h$m';
}
DateTime _parseKmaDateTime(String date, String time) {
final year = int.parse(date.substring(0, 4));
final month = int.parse(date.substring(4, 6));
final day = int.parse(date.substring(6, 8));
final hour = int.parse(time.substring(0, 2));
final minute = int.parse(time.substring(2, 4));
return DateTime(year, month, day, hour, minute);
}
}
class _GridPoint {
final int x;
final int y;
_GridPoint({required this.x, required this.y});
}
class _BaseDateTime {
final String date;
final String ncstTime;
final String fcstTime;
_BaseDateTime({
required this.date,
required this.ncstTime,
required this.fcstTime,
});
}