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:
JiWoong Sul
2025-11-22 00:10:51 +09:00
parent 947fe59486
commit 2a01fa50c6
43 changed files with 1777 additions and 571 deletions

View File

@@ -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);
});

View File

@@ -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());
}
}
}

View File

@@ -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);
// 에러 발생 시 기본적으로 캘린더 화면으로 이동

View File

@@ -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;
}
/// 추천 후 방문 확인

View File

@@ -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) {