Files
lunchpick/lib/data/repositories/restaurant_repository_impl.dart
2025-12-01 17:22:21 +09:00

331 lines
10 KiB
Dart

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';
import 'package:lunchpick/core/utils/app_logger.dart';
class RestaurantRepositoryImpl implements RestaurantRepository {
static const String _boxName = 'restaurants';
final NaverSearchService _naverSearchService;
RestaurantRepositoryImpl({NaverSearchService? naverSearchService})
: _naverSearchService = naverSearchService ?? NaverSearchService();
Future<Box<Restaurant>> get _box async =>
await Hive.openBox<Restaurant>(_boxName);
@override
Future<List<Restaurant>> getAllRestaurants() async {
final box = await _box;
return box.values.toList();
}
@override
Future<Restaurant?> getRestaurantById(String id) async {
final box = await _box;
return box.get(id);
}
@override
Future<void> addRestaurant(Restaurant restaurant) async {
final box = await _box;
await box.put(restaurant.id, restaurant);
}
@override
Future<void> updateRestaurant(Restaurant restaurant) async {
final box = await _box;
await box.put(restaurant.id, restaurant);
}
@override
Future<void> deleteRestaurant(String id) async {
final box = await _box;
await box.delete(id);
}
@override
Future<List<Restaurant>> getRestaurantsByCategory(String category) async {
final restaurants = await getAllRestaurants();
return restaurants.where((r) => r.category == category).toList();
}
@override
Future<List<String>> getAllCategories() async {
final restaurants = await getAllRestaurants();
final categories = restaurants.map((r) => r.category).toSet().toList();
categories.sort();
return categories;
}
@override
Stream<List<Restaurant>> watchRestaurants() async* {
final box = await _box;
final initial = box.values.toList();
AppLogger.debug('[restaurant_repo] initial load count: ${initial.length}');
yield initial;
yield* box.watch().map((event) {
final values = box.values.toList();
AppLogger.debug(
'[restaurant_repo] box watch event -> count: ${values.length} '
'(key=${event.key}, deleted=${event.deleted})',
);
return values;
});
}
@override
Future<void> 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,
needsAddressVerification: restaurant.needsAddressVerification,
);
await updateRestaurant(updatedRestaurant);
}
}
@override
Future<List<Restaurant>> 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<List<Restaurant>> 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<List<Restaurant>> 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<List<Restaurant>> searchRestaurantsFromNaver({
required String query,
double? latitude,
double? longitude,
}) async {
return _naverSearchService.searchNearbyRestaurants(
query: query,
latitude: latitude,
longitude: longitude,
);
}
@override
Future<Restaurant> addRestaurantFromUrl(String url) async {
return _processRestaurantFromUrl(url, persist: true);
}
@override
Future<Restaurant> previewRestaurantFromUrl(String url) async {
return _processRestaurantFromUrl(url, persist: false);
}
Future<Restaurant> _processRestaurantFromUrl(
String url, {
required bool persist,
}) 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) {
AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e');
}
}
if (persist) {
await _ensureRestaurantIsUnique(restaurant);
await addRestaurant(restaurant);
}
return restaurant;
} catch (e) {
if (e is NaverMapParseException) {
throw Exception('네이버 지도 파싱 실패: ${e.message}');
}
rethrow;
}
}
Future<void> _ensureRestaurantIsUnique(Restaurant restaurant) async {
final restaurants = await getAllRestaurants();
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 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;
},
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}',
);
}
}
@override
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId) async {
final restaurants = await getAllRestaurants();
try {
return restaurants.firstWhere((r) => r.naverPlaceId == naverPlaceId);
} catch (e) {
return null;
}
}
}