import 'dart:math'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/user_settings.dart'; import 'package:lunchpick/domain/entities/visit_record.dart'; import 'package:lunchpick/domain/entities/weather_info.dart'; import 'package:lunchpick/core/utils/distance_calculator.dart'; /// 추천 엔진 설정 class RecommendationConfig { final double userLatitude; final double userLongitude; final double maxDistance; final List selectedCategories; final UserSettings userSettings; final WeatherInfo? weather; final DateTime currentTime; RecommendationConfig({ required this.userLatitude, required this.userLongitude, required this.maxDistance, required this.selectedCategories, required this.userSettings, this.weather, DateTime? currentTime, }) : currentTime = currentTime ?? DateTime.now(); } /// 추천 엔진 UseCase class RecommendationEngine { final Random _random = Random(); /// 추천 생성 Future generateRecommendation({ required List allRestaurants, required List recentVisits, required RecommendationConfig config, }) async { // 1단계: 거리 필터링 final restaurantsInRange = _filterByDistance(allRestaurants, config); if (restaurantsInRange.isEmpty) return null; // 2단계: 재방문 방지 필터링 final eligibleRestaurants = _filterByRevisitPrevention( restaurantsInRange, recentVisits, config.userSettings.revisitPreventionDays, ); if (eligibleRestaurants.isEmpty) return null; // 3단계: 카테고리 필터링 final filteredByCategory = _filterByCategory(eligibleRestaurants, config.selectedCategories); if (filteredByCategory.isEmpty) return null; // 4단계: 가중치 계산 및 선택 return _selectWithWeights(filteredByCategory, config); } /// 거리 기반 필터링 List _filterByDistance(List restaurants, RecommendationConfig config) { // 날씨에 따른 최대 거리 조정 double effectiveMaxDistance = config.maxDistance; if (config.weather != null && config.weather!.current.isRainy) { // 비가 올 때는 거리를 70%로 줄임 effectiveMaxDistance *= 0.7; } return restaurants.where((restaurant) { final distance = DistanceCalculator.calculateDistance( lat1: config.userLatitude, lon1: config.userLongitude, lat2: restaurant.latitude, lon2: restaurant.longitude, ); return distance <= effectiveMaxDistance; }).toList(); } /// 재방문 방지 필터링 List _filterByRevisitPrevention( List restaurants, List recentVisits, int preventionDays, ) { final now = DateTime.now(); final cutoffDate = now.subtract(Duration(days: preventionDays)); // 최근 n일 내 방문한 식당 ID 수집 final recentlyVisitedIds = recentVisits .where((visit) => visit.visitDate.isAfter(cutoffDate)) .map((visit) => visit.restaurantId) .toSet(); // 최근 방문하지 않은 식당만 필터링 return restaurants.where((restaurant) { return !recentlyVisitedIds.contains(restaurant.id); }).toList(); } /// 카테고리 필터링 List _filterByCategory(List restaurants, List selectedCategories) { if (selectedCategories.isEmpty) { return restaurants; } return restaurants.where((restaurant) { return selectedCategories.contains(restaurant.category); }).toList(); } /// 가중치 기반 선택 Restaurant? _selectWithWeights(List restaurants, RecommendationConfig config) { if (restaurants.isEmpty) return null; // 각 식당에 대한 가중치 계산 final weightedRestaurants = restaurants.map((restaurant) { double weight = 1.0; // 카테고리 가중치 적용 final categoryWeight = config.userSettings.categoryWeights[restaurant.category]; if (categoryWeight != null) { weight *= categoryWeight; } // 거리 가중치 적용 (가까울수록 높은 가중치) final distance = DistanceCalculator.calculateDistance( lat1: config.userLatitude, lon1: config.userLongitude, lat2: restaurant.latitude, lon2: restaurant.longitude, ); final distanceWeight = 1.0 - (distance / config.maxDistance); weight *= (0.5 + distanceWeight * 0.5); // 50% ~ 100% 범위 // 시간대별 가중치 적용 weight *= _getTimeBasedWeight(restaurant, config.currentTime); // 날씨 기반 가중치 적용 if (config.weather != null) { weight *= _getWeatherBasedWeight(restaurant, config.weather!); } return _WeightedRestaurant(restaurant, weight); }).toList(); // 가중치 기반 랜덤 선택 return _weightedRandomSelection(weightedRestaurants); } /// 시간대별 가중치 계산 double _getTimeBasedWeight(Restaurant restaurant, DateTime currentTime) { final hour = currentTime.hour; // 아침 시간대 (7-10시) if (hour >= 7 && hour < 10) { if (restaurant.category == 'cafe' || restaurant.category == 'korean') { return 1.2; } if (restaurant.category == 'bar') { return 0.3; } } // 점심 시간대 (11-14시) else if (hour >= 11 && hour < 14) { if (restaurant.category == 'korean' || restaurant.category == 'chinese' || restaurant.category == 'japanese') { return 1.3; } } // 저녁 시간대 (17-21시) else if (hour >= 17 && hour < 21) { if (restaurant.category == 'bar' || restaurant.category == 'western') { return 1.2; } } // 늦은 저녁 (21시 이후) else if (hour >= 21) { if (restaurant.category == 'bar' || restaurant.category == 'fastfood') { return 1.3; } if (restaurant.category == 'cafe') { return 0.5; } } return 1.0; } /// 날씨 기반 가중치 계산 double _getWeatherBasedWeight(Restaurant restaurant, WeatherInfo weather) { if (weather.current.isRainy) { // 비가 올 때는 가까운 식당 선호 // 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호 if (restaurant.category == 'cafe' || restaurant.category == 'fastfood') { return 1.2; } } // 더운 날씨 (25도 이상) if (weather.current.temperature >= 25) { if (restaurant.category == 'cafe' || restaurant.category == 'japanese') { return 1.1; } } // 추운 날씨 (10도 이하) if (weather.current.temperature <= 10) { if (restaurant.category == 'korean' || restaurant.category == 'chinese') { return 1.2; } } return 1.0; } /// 가중치 기반 랜덤 선택 Restaurant? _weightedRandomSelection(List<_WeightedRestaurant> weightedRestaurants) { if (weightedRestaurants.isEmpty) return null; // 전체 가중치 합계 계산 final totalWeight = weightedRestaurants.fold( 0, (sum, item) => sum + item.weight, ); // 랜덤 값 생성 final randomValue = _random.nextDouble() * totalWeight; // 누적 가중치로 선택 double cumulativeWeight = 0; for (final weightedRestaurant in weightedRestaurants) { cumulativeWeight += weightedRestaurant.weight; if (randomValue <= cumulativeWeight) { return weightedRestaurant.restaurant; } } // 예외 처리 (여기에 도달하면 안됨) return weightedRestaurants.last.restaurant; } } /// 가중치가 적용된 식당 모델 class _WeightedRestaurant { final Restaurant restaurant; final double weight; _WeightedRestaurant(this.restaurant, this.weight); }