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:
@@ -4,6 +4,9 @@ import 'package:lunchpick/data/repositories/visit_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/settings_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/weather_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/recommendation_repository_impl.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_url_processor.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/data/api/naver_api_client.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
@@ -36,3 +39,21 @@ final recommendationRepositoryProvider = Provider<RecommendationRepository>((
|
||||
) {
|
||||
return RecommendationRepositoryImpl();
|
||||
});
|
||||
|
||||
/// NaverApiClient Provider
|
||||
final naverApiClientProvider = Provider<NaverApiClient>((ref) {
|
||||
return NaverApiClient();
|
||||
});
|
||||
|
||||
/// NaverMapParser Provider
|
||||
final naverMapParserProvider = Provider<NaverMapParser>((ref) {
|
||||
final apiClient = ref.watch(naverApiClientProvider);
|
||||
return NaverMapParser(apiClient: apiClient);
|
||||
});
|
||||
|
||||
/// NaverUrlProcessor Provider
|
||||
final naverUrlProcessorProvider = Provider<NaverUrlProcessor>((ref) {
|
||||
final apiClient = ref.watch(naverApiClientProvider);
|
||||
final parser = ref.watch(naverMapParserProvider);
|
||||
return NaverUrlProcessor(apiClient: apiClient, mapParser: parser);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
const double _defaultLatitude = 37.5666805;
|
||||
const double _defaultLongitude = 126.9784147;
|
||||
|
||||
/// 위치 정보를 사용할 수 없을 때 활용하는 기본 좌표(서울 시청).
|
||||
Position defaultPosition() {
|
||||
return Position(
|
||||
latitude: _defaultLatitude,
|
||||
longitude: _defaultLongitude,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0,
|
||||
altitude: 0,
|
||||
altitudeAccuracy: 0,
|
||||
heading: 0,
|
||||
headingAccuracy: 0,
|
||||
speed: 0,
|
||||
speedAccuracy: 0,
|
||||
isMocked: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 위치 권한 상태 Provider
|
||||
final locationPermissionProvider = FutureProvider<PermissionStatus>((
|
||||
ref,
|
||||
@@ -18,14 +39,16 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
// 권한이 없으면 요청
|
||||
final result = await Permission.location.request();
|
||||
if (!result.isGranted) {
|
||||
return null;
|
||||
AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청) 사용');
|
||||
return defaultPosition();
|
||||
}
|
||||
}
|
||||
|
||||
// 위치 서비스 활성화 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
throw Exception('위치 서비스가 비활성화되어 있습니다');
|
||||
AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청) 사용');
|
||||
return defaultPosition();
|
||||
}
|
||||
|
||||
// 현재 위치 가져오기
|
||||
@@ -36,7 +59,12 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
);
|
||||
} catch (e) {
|
||||
// 타임아웃이나 오류 발생 시 마지막 알려진 위치 반환
|
||||
return await Geolocator.getLastKnownPosition();
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
if (lastPosition != null) {
|
||||
return lastPosition;
|
||||
}
|
||||
AppLogger.debug('현재 위치를 가져오지 못해 기본 좌표(서울 시청)를 반환');
|
||||
return defaultPosition();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,7 +111,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
if (!permissionStatus.isGranted) {
|
||||
final granted = await requestLocationPermission();
|
||||
if (!granted) {
|
||||
state = const AsyncValue.data(null);
|
||||
AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +120,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
// 위치 서비스 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
state = AsyncValue.error('위치 서비스가 비활성화되어 있습니다', StackTrace.current);
|
||||
AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,13 +132,18 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
);
|
||||
|
||||
state = AsyncValue.data(position);
|
||||
} catch (e, stack) {
|
||||
} catch (e) {
|
||||
// 오류 발생 시 마지막 알려진 위치 시도
|
||||
try {
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
state = AsyncValue.data(lastPosition);
|
||||
if (lastPosition != null) {
|
||||
state = AsyncValue.data(lastPosition);
|
||||
} else {
|
||||
AppLogger.debug('마지막 위치도 없어 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
}
|
||||
} catch (_) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/presentation/pages/calendar/widgets/visit_confirmation_dialog.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
@@ -54,8 +55,8 @@ class NotificationPayload {
|
||||
);
|
||||
} catch (e) {
|
||||
// 더 상세한 오류 정보 제공
|
||||
print('NotificationPayload parsing error: $e');
|
||||
print('Original payload: $payload');
|
||||
AppLogger.error('NotificationPayload parsing error', error: e);
|
||||
AppLogger.debug('Original payload: $payload');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -77,17 +78,17 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
String? payload,
|
||||
) async {
|
||||
if (payload == null || payload.isEmpty) {
|
||||
print('Notification payload is null or empty');
|
||||
AppLogger.debug('Notification payload is null or empty');
|
||||
return;
|
||||
}
|
||||
|
||||
print('Handling notification with payload: $payload');
|
||||
AppLogger.debug('Handling notification with payload: $payload');
|
||||
|
||||
try {
|
||||
// 기존 형식 (visit_reminder:restaurantName) 처리
|
||||
if (payload.startsWith('visit_reminder:')) {
|
||||
final restaurantName = payload.substring(15);
|
||||
print('Legacy format - Restaurant name: $restaurantName');
|
||||
AppLogger.debug('Legacy format - Restaurant name: $restaurantName');
|
||||
|
||||
// 맛집 이름으로 ID 찾기
|
||||
final restaurantsAsync = await _ref.read(restaurantListProvider.future);
|
||||
@@ -110,11 +111,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
} else {
|
||||
// 새로운 형식의 payload 처리
|
||||
print('Attempting to parse new format payload');
|
||||
AppLogger.debug('Attempting to parse new format payload');
|
||||
|
||||
try {
|
||||
final notificationPayload = NotificationPayload.fromString(payload);
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}',
|
||||
);
|
||||
|
||||
@@ -135,8 +136,10 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
print('Failed to parse new format, attempting fallback parsing');
|
||||
print('Parse error: $parseError');
|
||||
AppLogger.debug(
|
||||
'Failed to parse new format, attempting fallback parsing',
|
||||
);
|
||||
AppLogger.debug('Parse error: $parseError');
|
||||
|
||||
// Fallback: 간단한 파싱 시도
|
||||
if (payload.contains('|')) {
|
||||
@@ -158,8 +161,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print('Error handling notification: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
AppLogger.error(
|
||||
'Error handling notification',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
|
||||
// 에러 발생 시 기본적으로 캘린더 화면으로 이동
|
||||
|
||||
@@ -50,74 +50,109 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
: super(const AsyncValue.data(null));
|
||||
|
||||
/// 랜덤 추천 실행
|
||||
Future<void> getRandomRecommendation({
|
||||
Future<Restaurant?> getRandomRecommendation({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
bool shouldSaveRecord = true,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
final selectedRestaurant = await _generateCandidate(
|
||||
maxDistance: maxDistance,
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
excludedRestaurantIds: excludedRestaurantIds,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
final selectedRestaurant = await _recommendationEngine
|
||||
.generateRecommendation(
|
||||
allRestaurants: allRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
if (selectedRestaurant == null) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추천 기록 저장
|
||||
await _saveRecommendationRecord(selectedRestaurant);
|
||||
if (shouldSaveRecord) {
|
||||
await saveRecommendationRecord(selectedRestaurant);
|
||||
}
|
||||
|
||||
state = AsyncValue.data(selectedRestaurant);
|
||||
return selectedRestaurant;
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Restaurant?> _generateCandidate({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
}) async {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
// 제외된 식당 제거
|
||||
final availableRestaurants = excludedRestaurantIds.isEmpty
|
||||
? allRestaurants
|
||||
: allRestaurants
|
||||
.where(
|
||||
(restaurant) => !excludedRestaurantIds.contains(restaurant.id),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (availableRestaurants.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
maxDistance: maxDistance,
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
return _recommendationEngine.generateRecommendation(
|
||||
allRestaurants: availableRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
}
|
||||
|
||||
/// 추천 기록 저장
|
||||
Future<void> _saveRecommendationRecord(Restaurant restaurant) async {
|
||||
Future<RecommendationRecord> saveRecommendationRecord(
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendationTime,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
final record = RecommendationRecord(
|
||||
id: const Uuid().v4(),
|
||||
restaurantId: restaurant.id,
|
||||
recommendationDate: DateTime.now(),
|
||||
recommendationDate: recommendationTime ?? now,
|
||||
visited: false,
|
||||
createdAt: DateTime.now(),
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
await _repository.addRecommendationRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/// 추천 후 방문 확인
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
@@ -7,12 +8,17 @@ import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
/// 현재 날씨 Provider
|
||||
final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
final location = await ref.watch(currentLocationProvider.future);
|
||||
Position? location;
|
||||
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
try {
|
||||
location = await ref.watch(currentLocationProvider.future);
|
||||
} catch (_) {
|
||||
// 위치 호출 실패 시 기본 좌표 사용
|
||||
location = defaultPosition();
|
||||
}
|
||||
|
||||
location ??= defaultPosition();
|
||||
|
||||
// 캐시된 날씨 정보 확인
|
||||
final cached = await repository.getCachedWeather();
|
||||
if (cached != null) {
|
||||
|
||||
Reference in New Issue
Block a user