- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
494 lines
15 KiB
Dart
494 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 currentMap = map['current'] as Map<String, dynamic>?;
|
|
if (currentMap == null) {
|
|
throw FormatException('Missing current weather data');
|
|
}
|
|
|
|
// nextHour 필드 검증
|
|
final nextHourMap = map['nextHour'] as Map<String, dynamic>?;
|
|
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,
|
|
});
|
|
}
|