diff --git a/lib/data/datasources/remote/naver_search_service.dart b/lib/data/datasources/remote/naver_search_service.dart index 3c2c7d7..3c50727 100644 --- a/lib/data/datasources/remote/naver_search_service.dart +++ b/lib/data/datasources/remote/naver_search_service.dart @@ -4,6 +4,7 @@ import '../../api/naver_api_client.dart'; import '../../api/naver/naver_local_search_api.dart'; import '../../../domain/entities/restaurant.dart'; import '../../../core/errors/network_exceptions.dart'; +import '../../../core/utils/distance_calculator.dart'; import 'naver_map_parser.dart'; /// 네이버 검색 서비스 @@ -111,8 +112,14 @@ class NaverSearchService { return null; } - // 가장 유사한 결과 찾기 - final bestMatch = _findBestMatch(name, searchResults); + // 가장 유사한 결과 찾기 (주소가 없으면 거리 기반 선택 포함) + final bestMatch = _findBestMatch( + name, + searchResults, + latitude: latitude, + longitude: longitude, + address: address, + ); if (bestMatch != null) { final restaurant = bestMatch.toRestaurant(id: _uuid.v4()); @@ -172,8 +179,11 @@ class NaverSearchService { /// 가장 유사한 검색 결과 찾기 NaverLocalSearchResult? _findBestMatch( String targetName, - List results, - ) { + List results, { + double? latitude, + double? longitude, + String? address, + }) { if (results.isEmpty) return null; // 정확히 일치하는 결과 우선 @@ -186,6 +196,28 @@ class NaverSearchService { return exactMatch; } + // 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택 + if ((address == null || address.isEmpty) && latitude != null && longitude != null) { + NaverLocalSearchResult? closestResult; + double minDistance = double.infinity; + + for (final result in results) { + final distance = DistanceCalculator.calculateDistance( + lat1: latitude, + lon1: longitude, + lat2: result.latitude, + lon2: result.longitude, + ); + + if (distance < minDistance) { + minDistance = distance; + closestResult = result; + } + } + + return closestResult ?? results.first; + } + // 유사도 계산 (간단한 버전) NaverLocalSearchResult? bestMatch; double bestScore = 0.0; @@ -239,9 +271,18 @@ class NaverSearchService { @visibleForTesting NaverLocalSearchResult? findBestMatchForTesting( String targetName, - List results, - ) { - return _findBestMatch(targetName, results); + List results, { + double? latitude, + double? longitude, + String? address, + }) { + return _findBestMatch( + targetName, + results, + latitude: latitude, + longitude: longitude, + address: address, + ); } @visibleForTesting diff --git a/lib/data/repositories/restaurant_repository_impl.dart b/lib/data/repositories/restaurant_repository_impl.dart index ef3510b..6f3effb 100644 --- a/lib/data/repositories/restaurant_repository_impl.dart +++ b/lib/data/repositories/restaurant_repository_impl.dart @@ -194,20 +194,49 @@ class RestaurantRepositoryImpl implements RestaurantRepository { } } - // 중복 체크 - Place ID가 있는 경우 - if (restaurant.naverPlaceId != null) { - final existingRestaurant = await getRestaurantByNaverPlaceId(restaurant.naverPlaceId!); - if (existingRestaurant != null) { - throw Exception('이미 등록된 맛집입니다: ${existingRestaurant.name}'); + // 중복 체크 개선 + final restaurants = await getAllRestaurants(); + + // 1. 주소 기반 중복 체크 + if (restaurant.roadAddress.isNotEmpty || restaurant.jibunAddress.isNotEmpty) { + final addressDuplicate = restaurants.firstWhere( + (r) => r.name == restaurant.name && + (r.roadAddress == restaurant.roadAddress || + r.jibunAddress == restaurant.jibunAddress), + orElse: () => Restaurant( + id: '', + name: '', + category: '', + subCategory: '', + roadAddress: '', + jibunAddress: '', + latitude: 0, + longitude: 0, + source: DataSource.USER_INPUT, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + if (addressDuplicate.id.isNotEmpty) { + throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${addressDuplicate.name}'); } } - // 중복 체크 - 이름과 주소로 추가 확인 - final restaurants = await getAllRestaurants(); - final duplicate = restaurants.firstWhere( - (r) => r.name == restaurant.name && - (r.roadAddress == restaurant.roadAddress || - r.jibunAddress == restaurant.jibunAddress), + // 2. 위치 기반 중복 체크 (50m 이내 같은 이름) + final locationDuplicate = restaurants.firstWhere( + (r) { + if (r.name != restaurant.name) return false; + + final distanceInKm = DistanceCalculator.calculateDistance( + lat1: r.latitude, + lon1: r.longitude, + lat2: restaurant.latitude, + lon2: restaurant.longitude, + ); + final distanceInMeters = distanceInKm * 1000; + return distanceInMeters < 50; // 50m 이내 + }, orElse: () => Restaurant( id: '', name: '', @@ -223,8 +252,8 @@ class RestaurantRepositoryImpl implements RestaurantRepository { ), ); - if (duplicate.id.isNotEmpty) { - throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${duplicate.name}'); + if (locationDuplicate.id.isNotEmpty) { + throw Exception('50m 이내에 동일한 이름의 맛집이 이미 존재합니다: ${locationDuplicate.name}'); } // 새 맛집 추가 diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart index 2b05a91..5dd98e6 100644 --- a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart +++ b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart @@ -150,25 +150,50 @@ class _RestaurantListScreenState extends ConsumerState { } Widget _buildEmptyState(bool isDark) { + final selectedCategory = ref.watch(selectedCategoryProvider); + final searchQuery = ref.watch(searchQueryProvider); + final isFiltering = selectedCategory != null || searchQuery.isNotEmpty; + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.restaurant_menu, + isFiltering ? Icons.search_off : Icons.restaurant_menu, size: 80, color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, ), const SizedBox(height: 16), Text( - '아직 등록된 맛집이 없어요', + isFiltering + ? '조건에 맞는 맛집이 없어요' + : '아직 등록된 맛집이 없어요', style: AppTypography.heading2(isDark), ), const SizedBox(height: 8), Text( - '+ 버튼을 눌러 맛집을 추가해보세요', + isFiltering + ? selectedCategory != null + ? '선택한 카테고리에 해당하는 맛집이 없습니다' + : '검색 결과가 없습니다' + : '+ 버튼을 눌러 맛집을 추가해보세요', style: AppTypography.body2(isDark), ), + if (isFiltering) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () { + ref.read(selectedCategoryProvider.notifier).state = null; + ref.read(searchQueryProvider.notifier).state = ''; + }, + child: Text( + '필터 초기화', + style: TextStyle( + color: AppColors.lightPrimary, + ), + ), + ), + ], ], ), ); diff --git a/lib/presentation/providers/restaurant_provider.dart b/lib/presentation/providers/restaurant_provider.dart index 9796c71..d70b1d0 100644 --- a/lib/presentation/providers/restaurant_provider.dart +++ b/lib/presentation/providers/restaurant_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lunchpick/core/utils/category_mapper.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/repositories/restaurant_repository.dart'; import 'package:lunchpick/presentation/providers/di_providers.dart'; @@ -207,7 +208,13 @@ final filteredRestaurantsProvider = StreamProvider>((ref) async // 카테고리 필터 적용 if (selectedCategory != null) { filtered = filtered.where((restaurant) { - return restaurant.category == selectedCategory; + // 정확한 일치 또는 부분 일치 확인 + // restaurant.category가 "음식점>한식>백반/한정식" 형태일 때 + // selectedCategory가 "백반/한정식"이면 매칭 + return restaurant.category == selectedCategory || + restaurant.category.contains(selectedCategory) || + CategoryMapper.normalizeNaverCategory(restaurant.category, restaurant.subCategory) == selectedCategory || + CategoryMapper.getDisplayName(restaurant.category) == selectedCategory; }).toList(); }