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:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import 'package:hive/hive.dart';
part 'recommendation_record.g.dart';
@HiveType(typeId: 3)
class RecommendationRecord extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String restaurantId;
@HiveField(2)
final DateTime recommendationDate;
@HiveField(3)
final bool visited;
@HiveField(4)
final DateTime createdAt;
RecommendationRecord({
required this.id,
required this.restaurantId,
required this.recommendationDate,
required this.visited,
required this.createdAt,
});
}

View File

@@ -0,0 +1,138 @@
import 'package:hive/hive.dart';
part 'restaurant.g.dart';
@HiveType(typeId: 0)
class Restaurant extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String category;
@HiveField(3)
final String subCategory;
@HiveField(4)
final String? description;
@HiveField(5)
final String? phoneNumber;
@HiveField(6)
final String roadAddress;
@HiveField(7)
final String jibunAddress;
@HiveField(8)
final double latitude;
@HiveField(9)
final double longitude;
@HiveField(10)
final DateTime? lastVisitDate;
@HiveField(11)
final DataSource source;
@HiveField(12)
final DateTime createdAt;
@HiveField(13)
final DateTime updatedAt;
@HiveField(14)
final String? naverPlaceId;
@HiveField(15)
final String? naverUrl;
@HiveField(16)
final String? businessHours;
@HiveField(17)
final DateTime? lastVisited;
@HiveField(18)
final int visitCount;
Restaurant({
required this.id,
required this.name,
required this.category,
required this.subCategory,
this.description,
this.phoneNumber,
required this.roadAddress,
required this.jibunAddress,
required this.latitude,
required this.longitude,
this.lastVisitDate,
required this.source,
required this.createdAt,
required this.updatedAt,
this.naverPlaceId,
this.naverUrl,
this.businessHours,
this.lastVisited,
this.visitCount = 0,
});
Restaurant copyWith({
String? id,
String? name,
String? category,
String? subCategory,
String? description,
String? phoneNumber,
String? roadAddress,
String? jibunAddress,
double? latitude,
double? longitude,
DateTime? lastVisitDate,
DataSource? source,
DateTime? createdAt,
DateTime? updatedAt,
String? naverPlaceId,
String? naverUrl,
String? businessHours,
DateTime? lastVisited,
int? visitCount,
}) {
return Restaurant(
id: id ?? this.id,
name: name ?? this.name,
category: category ?? this.category,
subCategory: subCategory ?? this.subCategory,
description: description ?? this.description,
phoneNumber: phoneNumber ?? this.phoneNumber,
roadAddress: roadAddress ?? this.roadAddress,
jibunAddress: jibunAddress ?? this.jibunAddress,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
lastVisitDate: lastVisitDate ?? this.lastVisitDate,
source: source ?? this.source,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
naverPlaceId: naverPlaceId ?? this.naverPlaceId,
naverUrl: naverUrl ?? this.naverUrl,
businessHours: businessHours ?? this.businessHours,
lastVisited: lastVisited ?? this.lastVisited,
visitCount: visitCount ?? this.visitCount,
);
}
}
@HiveType(typeId: 1)
enum DataSource {
@HiveField(0)
NAVER,
@HiveField(1)
USER_INPUT
}

View File

@@ -0,0 +1,11 @@
class ShareDevice {
final String code;
final String deviceId;
final DateTime discoveredAt;
ShareDevice({
required this.code,
required this.deviceId,
required this.discoveredAt,
});
}

View File

@@ -0,0 +1,45 @@
import 'package:hive/hive.dart';
part 'user_settings.g.dart';
@HiveType(typeId: 4)
class UserSettings {
@HiveField(0)
final int revisitPreventionDays;
@HiveField(1)
final bool notificationEnabled;
@HiveField(2)
final String notificationTime;
@HiveField(3)
final Map<String, double> categoryWeights;
@HiveField(4)
final int notificationDelayMinutes;
UserSettings({
this.revisitPreventionDays = 7,
this.notificationEnabled = true,
this.notificationTime = "14:00",
Map<String, double>? categoryWeights,
this.notificationDelayMinutes = 90,
}) : categoryWeights = categoryWeights ?? {};
UserSettings copyWith({
int? revisitPreventionDays,
bool? notificationEnabled,
String? notificationTime,
Map<String, double>? categoryWeights,
int? notificationDelayMinutes,
}) {
return UserSettings(
revisitPreventionDays: revisitPreventionDays ?? this.revisitPreventionDays,
notificationEnabled: notificationEnabled ?? this.notificationEnabled,
notificationTime: notificationTime ?? this.notificationTime,
categoryWeights: categoryWeights ?? this.categoryWeights,
notificationDelayMinutes: notificationDelayMinutes ?? this.notificationDelayMinutes,
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:hive/hive.dart';
part 'visit_record.g.dart';
@HiveType(typeId: 2)
class VisitRecord extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String restaurantId;
@HiveField(2)
final DateTime visitDate;
@HiveField(3)
final bool isConfirmed;
@HiveField(4)
final DateTime createdAt;
VisitRecord({
required this.id,
required this.restaurantId,
required this.visitDate,
required this.isConfirmed,
required this.createdAt,
});
}

View File

@@ -0,0 +1,21 @@
class WeatherInfo {
final WeatherData current;
final WeatherData nextHour;
WeatherInfo({
required this.current,
required this.nextHour,
});
}
class WeatherData {
final int temperature;
final bool isRainy;
final String description;
WeatherData({
required this.temperature,
required this.isRainy,
required this.description,
});
}

View File

@@ -0,0 +1,39 @@
import 'package:lunchpick/domain/entities/recommendation_record.dart';
abstract class RecommendationRepository {
/// 모든 추천 기록을 가져옵니다
Future<List<RecommendationRecord>> getAllRecommendationRecords();
/// 특정 맛집의 추천 기록을 가져옵니다
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId);
/// 날짜별 추천 기록을 가져옵니다
Future<List<RecommendationRecord>> getRecommendationsByDate(DateTime date);
/// 날짜 범위로 추천 기록을 가져옵니다
Future<List<RecommendationRecord>> getRecommendationsByDateRange({
required DateTime startDate,
required DateTime endDate,
});
/// 새로운 추천 기록을 추가합니다
Future<void> addRecommendationRecord(RecommendationRecord record);
/// 추천 기록을 업데이트합니다
Future<void> updateRecommendationRecord(RecommendationRecord record);
/// 추천 기록을 삭제합니다
Future<void> deleteRecommendationRecord(String id);
/// 추천 후 방문 여부를 업데이트합니다
Future<void> markAsVisited(String recommendationId);
/// 오늘의 추천 횟수를 가져옵니다
Future<int> getTodayRecommendationCount();
/// 추천 기록을 스트림으로 감시합니다
Stream<List<RecommendationRecord>> watchRecommendationRecords();
/// 월별 추천 통계를 가져옵니다
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month);
}

View File

@@ -0,0 +1,49 @@
import 'package:lunchpick/domain/entities/restaurant.dart';
abstract class RestaurantRepository {
/// 모든 맛집 목록을 가져옵니다
Future<List<Restaurant>> getAllRestaurants();
/// 특정 맛집을 ID로 가져옵니다
Future<Restaurant?> getRestaurantById(String id);
/// 새로운 맛집을 추가합니다
Future<void> addRestaurant(Restaurant restaurant);
/// 맛집 정보를 업데이트합니다
Future<void> updateRestaurant(Restaurant restaurant);
/// 맛집을 삭제합니다
Future<void> deleteRestaurant(String id);
/// 카테고리별로 맛집을 가져옵니다
Future<List<Restaurant>> getRestaurantsByCategory(String category);
/// 모든 카테고리 목록을 가져옵니다
Future<List<String>> getAllCategories();
/// 맛집 목록을 스트림으로 감시합니다
Stream<List<Restaurant>> watchRestaurants();
/// 맛집 방문일을 업데이트합니다
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate);
/// 거리 내의 맛집을 가져옵니다
Future<List<Restaurant>> getRestaurantsWithinDistance({
required double userLatitude,
required double userLongitude,
required double maxDistanceInMeters,
});
/// 최근 n일 이내에 방문하지 않은 맛집을 가져옵니다
Future<List<Restaurant>> getRestaurantsNotVisitedInDays(int days);
/// 검색어로 맛집을 검색합니다
Future<List<Restaurant>> searchRestaurants(String query);
/// 네이버 지도 URL로부터 맛집을 추가합니다
Future<Restaurant> addRestaurantFromUrl(String url);
/// 네이버 Place ID로 맛집을 찾습니다
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId);
}

View File

@@ -0,0 +1,60 @@
import 'package:lunchpick/domain/entities/user_settings.dart';
abstract class SettingsRepository {
/// 사용자 설정 전체를 가져옵니다
Future<UserSettings> getUserSettings();
/// 사용자 설정을 업데이트합니다
Future<void> updateUserSettings(UserSettings settings);
/// 재방문 금지 일수를 가져옵니다
Future<int> getDaysToExclude();
/// 재방문 금지 일수를 설정합니다
Future<void> setDaysToExclude(int days);
/// 우천시 최대 거리를 가져옵니다
Future<int> getMaxDistanceRainy();
/// 우천시 최대 거리를 설정합니다
Future<void> setMaxDistanceRainy(int meters);
/// 평상시 최대 거리를 가져옵니다
Future<int> getMaxDistanceNormal();
/// 평상시 최대 거리를 설정합니다
Future<void> setMaxDistanceNormal(int meters);
/// 알림 시간 설정을 가져옵니다 (분 단위)
Future<int> getNotificationDelayMinutes();
/// 알림 시간을 설정합니다 (분 단위)
Future<void> setNotificationDelayMinutes(int minutes);
/// 알림 활성화 여부를 가져옵니다
Future<bool> isNotificationEnabled();
/// 알림 활성화 여부를 설정합니다
Future<void> setNotificationEnabled(bool enabled);
/// 다크모드 설정을 가져옵니다
Future<bool> isDarkModeEnabled();
/// 다크모드를 설정합니다
Future<void> setDarkModeEnabled(bool enabled);
/// 첫 실행 여부를 확인합니다
Future<bool> isFirstRun();
/// 첫 실행 상태를 업데이트합니다
Future<void> setFirstRun(bool isFirst);
/// 모든 설정을 초기화합니다
Future<void> resetSettings();
/// 설정 변경사항을 스트림으로 감시합니다
Stream<Map<String, dynamic>> watchSettings();
/// UserSettings 변경사항을 스트림으로 감시합니다
Stream<UserSettings> watchUserSettings();
}

View File

@@ -0,0 +1,42 @@
import 'package:lunchpick/domain/entities/visit_record.dart';
abstract class VisitRepository {
/// 모든 방문 기록을 가져옵니다
Future<List<VisitRecord>> getAllVisitRecords();
/// 특정 맛집의 방문 기록을 가져옵니다
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(String restaurantId);
/// 특정 날짜의 방문 기록을 가져옵니다
Future<List<VisitRecord>> getVisitRecordsByDate(DateTime date);
/// 날짜 범위로 방문 기록을 가져옵니다
Future<List<VisitRecord>> getVisitRecordsByDateRange({
required DateTime startDate,
required DateTime endDate,
});
/// 새로운 방문 기록을 추가합니다
Future<void> addVisitRecord(VisitRecord visitRecord);
/// 방문 기록을 업데이트합니다
Future<void> updateVisitRecord(VisitRecord visitRecord);
/// 방문 기록을 삭제합니다
Future<void> deleteVisitRecord(String id);
/// 방문 확인 상태를 업데이트합니다
Future<void> confirmVisit(String visitRecordId);
/// 방문 기록을 스트림으로 감시합니다
Stream<List<VisitRecord>> watchVisitRecords();
/// 특정 맛집의 마지막 방문일을 가져옵니다
Future<DateTime?> getLastVisitDate(String restaurantId);
/// 월별 방문 통계를 가져옵니다
Future<Map<String, int>> getMonthlyVisitStats(int year, int month);
/// 카테고리별 방문 통계를 가져옵니다
Future<Map<String, int>> getCategoryVisitStats();
}

View File

@@ -0,0 +1,21 @@
import 'package:lunchpick/domain/entities/weather_info.dart';
abstract class WeatherRepository {
/// 현재 위치의 날씨 정보를 가져옵니다
Future<WeatherInfo> getCurrentWeather({
required double latitude,
required double longitude,
});
/// 캐시된 날씨 정보를 가져옵니다
Future<WeatherInfo?> getCachedWeather();
/// 날씨 정보를 캐시에 저장합니다
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo);
/// 날씨 캐시를 삭제합니다
Future<void> clearWeatherCache();
/// 날씨 정보 업데이트가 필요한지 확인합니다
Future<bool> isWeatherUpdateNeeded();
}

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