feat(app): finalize ad gated flows and weather
- 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
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
static const String _boxName = 'restaurants';
|
||||
@@ -224,7 +225,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
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';
|
||||
|
||||
@@ -15,18 +21,32 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
final cached = await getCachedWeather();
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(temperature: 20, isRainy: false, description: '맑음'),
|
||||
nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'),
|
||||
);
|
||||
|
||||
// 캐시에 저장
|
||||
await cacheWeatherInfo(dummyWeather);
|
||||
|
||||
return dummyWeather;
|
||||
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
|
||||
@@ -48,7 +68,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
@@ -62,7 +82,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') ||
|
||||
!weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Missing required fields in weather data',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
@@ -70,7 +92,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
print('WeatherCache: Error parsing cached weather data: $e');
|
||||
AppLogger.error(
|
||||
'WeatherCache: Error parsing cached weather data',
|
||||
error: e,
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
@@ -118,7 +143,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
// 날짜 파싱 시도
|
||||
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
||||
if (lastUpdateTime == null) {
|
||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -127,7 +154,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
|
||||
return difference < _cacheValidDuration;
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error checking cache validity: $e');
|
||||
AppLogger.error('WeatherCache: Error checking cache validity', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -183,9 +210,284 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error converting map to WeatherInfo: $e');
|
||||
print('WeatherCache: Map data: $map');
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user