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 get _box async => await Hive.openBox(_boxName); @override Future 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 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 weatherMap = Map.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 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 clearWeatherCache() async { final box = await _box; await box.delete(_keyCachedWeather); await box.delete(_keyLastUpdateTime); } @override Future isWeatherUpdateNeeded() async { final box = await _box; // 캐시된 날씨 정보가 없으면 업데이트 필요 if (!box.containsKey(_keyCachedWeather)) { return true; } // 캐시가 유효한지 확인 return !(await _isCacheValid()); } Future _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 _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 map) { try { // current 필드 검증 final currentMap = map['current'] as Map?; if (currentMap == null) { throw FormatException('Missing current weather data'); } // nextHour 필드 검증 final nextHourMap = map['nextHour'] as Map?; 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 _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> _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; final items = body['response']?['body']?['items']?['item']; if (items is List) { return items; } throw Exception('Weather API 응답 파싱 실패'); } double? _extractLatestValue(List 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 items, { required DateTime after, }) { DateTime? targetTime; double? temperature; int? pty; int? sky; DateTime fcstDateTime(Map 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); 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; 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, }); }