feat: 초기 프로젝트 설정 및 LunchPick 앱 구현
LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
257
lib/domain/usecases/recommendation_engine.dart
Normal file
257
lib/domain/usecases/recommendation_engine.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
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<String> 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<Restaurant?> generateRecommendation({
|
||||
required List<Restaurant> allRestaurants,
|
||||
required List<VisitRecord> 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<Restaurant> _filterByDistance(List<Restaurant> 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<Restaurant> _filterByRevisitPrevention(
|
||||
List<Restaurant> restaurants,
|
||||
List<VisitRecord> 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<Restaurant> _filterByCategory(List<Restaurant> restaurants, List<String> selectedCategories) {
|
||||
if (selectedCategories.isEmpty) {
|
||||
return restaurants;
|
||||
}
|
||||
return restaurants.where((restaurant) {
|
||||
return selectedCategories.contains(restaurant.category);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 가중치 기반 선택
|
||||
Restaurant? _selectWithWeights(List<Restaurant> 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<double>(
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user