import 'package:hive_flutter/hive_flutter.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/repositories/restaurant_repository.dart'; import 'package:lunchpick/core/utils/distance_calculator.dart'; import 'package:lunchpick/data/datasources/remote/naver_search_service.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/core/constants/api_keys.dart'; class RestaurantRepositoryImpl implements RestaurantRepository { static const String _boxName = 'restaurants'; final NaverSearchService _naverSearchService; RestaurantRepositoryImpl({ NaverSearchService? naverSearchService, }) : _naverSearchService = naverSearchService ?? NaverSearchService(); Future> get _box async => await Hive.openBox(_boxName); @override Future> getAllRestaurants() async { final box = await _box; return box.values.toList(); } @override Future getRestaurantById(String id) async { final box = await _box; return box.get(id); } @override Future addRestaurant(Restaurant restaurant) async { final box = await _box; await box.put(restaurant.id, restaurant); } @override Future updateRestaurant(Restaurant restaurant) async { final box = await _box; await box.put(restaurant.id, restaurant); } @override Future deleteRestaurant(String id) async { final box = await _box; await box.delete(id); } @override Future> getRestaurantsByCategory(String category) async { final restaurants = await getAllRestaurants(); return restaurants.where((r) => r.category == category).toList(); } @override Future> getAllCategories() async { final restaurants = await getAllRestaurants(); final categories = restaurants.map((r) => r.category).toSet().toList(); categories.sort(); return categories; } @override Stream> watchRestaurants() async* { final box = await _box; yield box.values.toList(); yield* box.watch().map((_) => box.values.toList()); } @override Future updateLastVisitDate(String restaurantId, DateTime visitDate) async { final restaurant = await getRestaurantById(restaurantId); if (restaurant != null) { final updatedRestaurant = Restaurant( id: restaurant.id, name: restaurant.name, category: restaurant.category, subCategory: restaurant.subCategory, description: restaurant.description, phoneNumber: restaurant.phoneNumber, roadAddress: restaurant.roadAddress, jibunAddress: restaurant.jibunAddress, latitude: restaurant.latitude, longitude: restaurant.longitude, lastVisitDate: visitDate, source: restaurant.source, createdAt: restaurant.createdAt, updatedAt: DateTime.now(), naverPlaceId: restaurant.naverPlaceId, naverUrl: restaurant.naverUrl, businessHours: restaurant.businessHours, lastVisited: visitDate, visitCount: restaurant.visitCount + 1, ); await updateRestaurant(updatedRestaurant); } } @override Future> getRestaurantsWithinDistance({ required double userLatitude, required double userLongitude, required double maxDistanceInMeters, }) async { final restaurants = await getAllRestaurants(); return restaurants.where((restaurant) { final distanceInKm = DistanceCalculator.calculateDistance( lat1: userLatitude, lon1: userLongitude, lat2: restaurant.latitude, lon2: restaurant.longitude, ); final distanceInMeters = distanceInKm * 1000; return distanceInMeters <= maxDistanceInMeters; }).toList(); } @override Future> getRestaurantsNotVisitedInDays(int days) async { final restaurants = await getAllRestaurants(); final cutoffDate = DateTime.now().subtract(Duration(days: days)); return restaurants.where((restaurant) { if (restaurant.lastVisitDate == null) return true; return restaurant.lastVisitDate!.isBefore(cutoffDate); }).toList(); } @override Future> searchRestaurants(String query) async { if (query.isEmpty) { return await getAllRestaurants(); } final restaurants = await getAllRestaurants(); final lowercaseQuery = query.toLowerCase(); return restaurants.where((restaurant) { return restaurant.name.toLowerCase().contains(lowercaseQuery) || (restaurant.description?.toLowerCase().contains(lowercaseQuery) ?? false) || restaurant.category.toLowerCase().contains(lowercaseQuery) || restaurant.roadAddress.toLowerCase().contains(lowercaseQuery); }).toList(); } @override Future addRestaurantFromUrl(String url) async { try { // URL 유효성 검증 if (!url.contains('naver.com') && !url.contains('naver.me')) { throw Exception('유효하지 않은 네이버 지도 URL입니다.'); } // NaverSearchService로 식당 정보 추출 Restaurant restaurant = await _naverSearchService.getRestaurantFromUrl(url); // API 키가 설정되어 있으면 추가 정보 검색 if (ApiKeys.areKeysConfigured() && restaurant.name != '네이버 지도 장소') { try { final detailedRestaurant = await _naverSearchService.searchRestaurantDetails( name: restaurant.name, address: restaurant.roadAddress, latitude: restaurant.latitude, longitude: restaurant.longitude, ); if (detailedRestaurant != null) { // 기존 정보와 API 검색 결과 병합 restaurant = Restaurant( id: restaurant.id, name: restaurant.name, category: detailedRestaurant.category, subCategory: detailedRestaurant.subCategory, description: detailedRestaurant.description ?? restaurant.description, phoneNumber: detailedRestaurant.phoneNumber ?? restaurant.phoneNumber, roadAddress: detailedRestaurant.roadAddress, jibunAddress: detailedRestaurant.jibunAddress, latitude: detailedRestaurant.latitude, longitude: detailedRestaurant.longitude, lastVisitDate: restaurant.lastVisitDate, source: DataSource.NAVER, createdAt: restaurant.createdAt, updatedAt: DateTime.now(), naverPlaceId: restaurant.naverPlaceId, naverUrl: restaurant.naverUrl, businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours, lastVisited: restaurant.lastVisited, visitCount: restaurant.visitCount, ); } } catch (e) { print('API 검색 실패, 스크래핑된 정보만 사용: $e'); } } // 중복 체크 개선 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}'); } } // 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: '', category: '', subCategory: '', roadAddress: '', jibunAddress: '', latitude: 0, longitude: 0, source: DataSource.USER_INPUT, createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); if (locationDuplicate.id.isNotEmpty) { throw Exception('50m 이내에 동일한 이름의 맛집이 이미 존재합니다: ${locationDuplicate.name}'); } // 새 맛집 추가 await addRestaurant(restaurant); return restaurant; } catch (e) { if (e is NaverMapParseException) { throw Exception('네이버 지도 파싱 실패: ${e.message}'); } rethrow; } } @override Future getRestaurantByNaverPlaceId(String naverPlaceId) async { final restaurants = await getAllRestaurants(); try { return restaurants.firstWhere( (r) => r.naverPlaceId == naverPlaceId, ); } catch (e) { return null; } } }