301 lines
10 KiB
Dart
301 lines
10 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lunchpick/core/utils/category_mapper.dart';
|
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
|
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
|
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
|
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
|
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
/// 맛집 목록 Provider
|
|
final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return repository.watchRestaurants();
|
|
});
|
|
|
|
/// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반)
|
|
/// StreamProvider 의존으로 초기 이벤트를 놓치는 문제를 피하기 위해
|
|
/// 기존 리스트 스트림의 AsyncValue를 그대로 전달하며 정렬만 적용한다.
|
|
final sortedRestaurantsByDistanceProvider =
|
|
Provider<AsyncValue<List<({Restaurant restaurant, double? distanceKm})>>>((
|
|
ref,
|
|
) {
|
|
final restaurantsAsync = ref.watch(restaurantListProvider);
|
|
final positionAsync = ref.watch(currentLocationWithFallbackProvider);
|
|
final position = positionAsync.maybeWhen(
|
|
data: (pos) => pos,
|
|
orElse: () => defaultPosition(),
|
|
);
|
|
|
|
AppLogger.debug(
|
|
'[restaurant_list] position ready for sorting: '
|
|
'${position.latitude}, ${position.longitude}',
|
|
);
|
|
|
|
return restaurantsAsync.whenData((restaurants) {
|
|
AppLogger.debug(
|
|
'[restaurant_list] incoming restaurants: ${restaurants.length}',
|
|
);
|
|
final sorted =
|
|
restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) {
|
|
final distanceKm = DistanceCalculator.calculateDistance(
|
|
lat1: position.latitude,
|
|
lon1: position.longitude,
|
|
lat2: r.latitude,
|
|
lon2: r.longitude,
|
|
);
|
|
return (restaurant: r, distanceKm: distanceKm);
|
|
}).toList()..sort(
|
|
(a, b) => (a.distanceKm ?? double.infinity).compareTo(
|
|
b.distanceKm ?? double.infinity,
|
|
),
|
|
);
|
|
AppLogger.debug(
|
|
'[restaurant_list] sorted list emitted, first distanceKm: '
|
|
'${sorted.isNotEmpty ? sorted.first.distanceKm?.toStringAsFixed(3) : 'none'}',
|
|
);
|
|
return sorted;
|
|
});
|
|
});
|
|
|
|
/// 특정 맛집 Provider
|
|
final restaurantProvider = FutureProvider.family<Restaurant?, String>((
|
|
ref,
|
|
id,
|
|
) async {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return repository.getRestaurantById(id);
|
|
});
|
|
|
|
/// 카테고리 목록 Provider (맛집 스트림을 구독해 즉시 갱신)
|
|
final categoriesProvider = StreamProvider<List<String>>((ref) {
|
|
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
|
return restaurantsStream.map((restaurants) {
|
|
final categories = restaurants.map((r) => r.category).toSet().toList()
|
|
..sort();
|
|
return categories;
|
|
});
|
|
});
|
|
|
|
/// 세부 카테고리 목록 Provider
|
|
final subCategoriesProvider = StreamProvider<List<String>>((ref) {
|
|
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
|
return restaurantsStream.map((restaurants) {
|
|
final subCategories =
|
|
restaurants
|
|
.map((r) => r.subCategory)
|
|
.where((s) => s.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
return subCategories;
|
|
});
|
|
});
|
|
|
|
/// 맛집 관리 StateNotifier
|
|
class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
|
final RestaurantRepository _repository;
|
|
|
|
RestaurantNotifier(this._repository) : super(const AsyncValue.data(null));
|
|
|
|
/// 맛집 추가
|
|
Future<void> addRestaurant({
|
|
required String name,
|
|
required String category,
|
|
required String subCategory,
|
|
String? description,
|
|
String? phoneNumber,
|
|
required String roadAddress,
|
|
required String jibunAddress,
|
|
required double latitude,
|
|
required double longitude,
|
|
required DataSource source,
|
|
}) async {
|
|
state = const AsyncValue.loading();
|
|
|
|
try {
|
|
final restaurant = Restaurant(
|
|
id: const Uuid().v4(),
|
|
name: name,
|
|
category: category,
|
|
subCategory: subCategory,
|
|
description: description,
|
|
phoneNumber: phoneNumber,
|
|
roadAddress: roadAddress,
|
|
jibunAddress: jibunAddress,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
source: source,
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
|
|
await _repository.addRestaurant(restaurant);
|
|
state = const AsyncValue.data(null);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
|
|
/// 맛집 수정
|
|
Future<void> updateRestaurant(Restaurant restaurant) async {
|
|
state = const AsyncValue.loading();
|
|
|
|
try {
|
|
final nextSource = restaurant.source == DataSource.PRESET
|
|
? DataSource.USER_INPUT
|
|
: restaurant.source;
|
|
await _repository.updateRestaurant(
|
|
restaurant.copyWith(source: nextSource, updatedAt: DateTime.now()),
|
|
);
|
|
state = const AsyncValue.data(null);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
|
|
/// 맛집 삭제
|
|
Future<void> deleteRestaurant(String id) async {
|
|
state = const AsyncValue.loading();
|
|
|
|
try {
|
|
await _repository.deleteRestaurant(id);
|
|
state = const AsyncValue.data(null);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
|
|
/// 마지막 방문일 업데이트
|
|
Future<void> updateLastVisitDate(
|
|
String restaurantId,
|
|
DateTime visitDate,
|
|
) async {
|
|
try {
|
|
await _repository.updateLastVisitDate(restaurantId, visitDate);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
|
|
/// 네이버 지도 URL로부터 맛집 추가
|
|
Future<Restaurant> addRestaurantFromUrl(String url) async {
|
|
state = const AsyncValue.loading();
|
|
|
|
try {
|
|
final restaurant = await _repository.addRestaurantFromUrl(url);
|
|
state = const AsyncValue.data(null);
|
|
return restaurant;
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 미리 생성된 Restaurant 객체를 직접 추가
|
|
Future<void> addRestaurantDirect(Restaurant restaurant) async {
|
|
state = const AsyncValue.loading();
|
|
|
|
try {
|
|
await _repository.addRestaurant(restaurant);
|
|
state = const AsyncValue.data(null);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// RestaurantNotifier Provider
|
|
final restaurantNotifierProvider =
|
|
StateNotifierProvider<RestaurantNotifier, AsyncValue<void>>((ref) {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return RestaurantNotifier(repository);
|
|
});
|
|
|
|
/// 거리 내 맛집 Provider
|
|
final restaurantsWithinDistanceProvider =
|
|
FutureProvider.family<
|
|
List<Restaurant>,
|
|
({double latitude, double longitude, double maxDistance})
|
|
>((ref, params) async {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return repository.getRestaurantsWithinDistance(
|
|
userLatitude: params.latitude,
|
|
userLongitude: params.longitude,
|
|
maxDistanceInMeters: params.maxDistance,
|
|
);
|
|
});
|
|
|
|
/// n일 이내 방문하지 않은 맛집 Provider
|
|
final restaurantsNotVisitedInDaysProvider =
|
|
FutureProvider.family<List<Restaurant>, int>((ref, days) async {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return repository.getRestaurantsNotVisitedInDays(days);
|
|
});
|
|
|
|
/// 검색어로 맛집 검색 Provider
|
|
final searchRestaurantsProvider =
|
|
FutureProvider.family<List<Restaurant>, String>((ref, query) async {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return repository.searchRestaurants(query);
|
|
});
|
|
|
|
/// 카테고리별 맛집 Provider
|
|
final restaurantsByCategoryProvider =
|
|
FutureProvider.family<List<Restaurant>, String>((ref, category) async {
|
|
final repository = ref.watch(restaurantRepositoryProvider);
|
|
return repository.getRestaurantsByCategory(category);
|
|
});
|
|
|
|
/// 검색 쿼리 상태 Provider
|
|
final searchQueryProvider = StateProvider<String>((ref) => '');
|
|
|
|
/// 선택된 카테고리 상태 Provider
|
|
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
|
|
|
|
/// 필터링된 맛집 목록 Provider (검색 + 카테고리)
|
|
final filteredRestaurantsProvider = Provider<AsyncValue<List<Restaurant>>>((
|
|
ref,
|
|
) {
|
|
final searchQuery = ref.watch(searchQueryProvider);
|
|
final selectedCategory = ref.watch(selectedCategoryProvider);
|
|
final restaurantsAsync = ref.watch(restaurantListProvider);
|
|
|
|
return restaurantsAsync.whenData((restaurants) {
|
|
var filtered = restaurants;
|
|
|
|
// 검색 필터 적용
|
|
if (searchQuery.isNotEmpty) {
|
|
final lowercaseQuery = searchQuery.toLowerCase();
|
|
filtered = filtered.where((restaurant) {
|
|
return restaurant.name.toLowerCase().contains(lowercaseQuery) ||
|
|
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ??
|
|
false) ||
|
|
restaurant.category.toLowerCase().contains(lowercaseQuery);
|
|
}).toList();
|
|
}
|
|
|
|
// 카테고리 필터 적용
|
|
if (selectedCategory != null) {
|
|
filtered = filtered.where((restaurant) {
|
|
// 정확한 일치 또는 부분 일치 확인
|
|
// restaurant.category가 "음식점>한식>백반/한정식" 형태일 때
|
|
// selectedCategory가 "백반/한정식"이면 매칭
|
|
return restaurant.category == selectedCategory ||
|
|
restaurant.category.contains(selectedCategory) ||
|
|
CategoryMapper.normalizeNaverCategory(
|
|
restaurant.category,
|
|
restaurant.subCategory,
|
|
) ==
|
|
selectedCategory ||
|
|
CategoryMapper.getDisplayName(restaurant.category) ==
|
|
selectedCategory;
|
|
}).toList();
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
});
|