525 lines
16 KiB
Dart
525 lines
16 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
|
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
|
import 'package:lunchpick/domain/usecases/recommendation_engine.dart';
|
|
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
|
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
|
import 'package:lunchpick/presentation/providers/settings_provider.dart'
|
|
hide currentLocationProvider, locationPermissionProvider;
|
|
import 'package:lunchpick/presentation/providers/weather_provider.dart';
|
|
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
|
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
/// 추천 기록 목록 Provider
|
|
final recommendationRecordsProvider =
|
|
StreamProvider<List<RecommendationRecord>>((ref) {
|
|
final repository = ref.watch(recommendationRepositoryProvider);
|
|
return repository.watchRecommendationRecords();
|
|
});
|
|
|
|
/// 오늘의 추천 횟수 Provider
|
|
final todayRecommendationCountProvider = FutureProvider<int>((ref) async {
|
|
final repository = ref.watch(recommendationRepositoryProvider);
|
|
return repository.getTodayRecommendationCount();
|
|
});
|
|
|
|
Future<bool> _isScreenshotModeEnabled(Ref ref) async {
|
|
try {
|
|
return await ref.read(screenshotModeEnabledProvider.future);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Restaurant _buildScreenshotRestaurant() {
|
|
final now = DateTime.now();
|
|
final random = Random();
|
|
final templates = [
|
|
(
|
|
id: 'screenshot-basil-bistro',
|
|
name: 'Basil Breeze Bistro',
|
|
category: '양식',
|
|
subCategory: '파스타 · 그릴',
|
|
description: '바질 향이 가득한 파스타와 스테이크를 즐길 수 있는 다이닝.',
|
|
phoneNumber: '02-1234-5678',
|
|
roadAddress: '서울 중구 세종대로 110',
|
|
jibunAddress: '서울 중구 태평로1가 31',
|
|
latitude: 37.5665,
|
|
longitude: 126.9780,
|
|
),
|
|
(
|
|
id: 'screenshot-komorebi-sushi',
|
|
name: 'Komorebi Sushi',
|
|
category: '일식',
|
|
subCategory: '스시 · 사시미',
|
|
description: '제철 재료로 선보이는 오마카세 콘셉트 스시 바.',
|
|
phoneNumber: '02-2468-1357',
|
|
roadAddress: '서울 강남구 테헤란로 311',
|
|
jibunAddress: '서울 강남구 역삼동 647-9',
|
|
latitude: 37.5009,
|
|
longitude: 127.0365,
|
|
),
|
|
(
|
|
id: 'screenshot-brunch-lab',
|
|
name: 'Sunny Brunch Lab',
|
|
category: '카페/디저트',
|
|
subCategory: '브런치 · 디저트',
|
|
description: '스크램블 에그와 시그니처 라떼가 인기인 브런치 카페.',
|
|
phoneNumber: '02-9753-8642',
|
|
roadAddress: '서울 마포구 독막로 12길 5',
|
|
jibunAddress: '서울 마포구 합정동 374-6',
|
|
latitude: 37.5509,
|
|
longitude: 126.9143,
|
|
),
|
|
];
|
|
|
|
final template = templates[random.nextInt(templates.length)];
|
|
|
|
return Restaurant(
|
|
id: '${template.id}-${now.millisecondsSinceEpoch}',
|
|
name: template.name,
|
|
category: template.category,
|
|
subCategory: template.subCategory,
|
|
description: template.description,
|
|
phoneNumber: template.phoneNumber,
|
|
roadAddress: template.roadAddress,
|
|
jibunAddress: template.jibunAddress,
|
|
latitude: template.latitude,
|
|
longitude: template.longitude,
|
|
source: DataSource.PRESET,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
needsAddressVerification: false,
|
|
);
|
|
}
|
|
|
|
/// 추천 설정 모델
|
|
class RecommendationSettings {
|
|
final int daysToExclude;
|
|
final int maxDistanceRainy;
|
|
final int maxDistanceNormal;
|
|
final List<String> selectedCategories;
|
|
|
|
RecommendationSettings({
|
|
required this.daysToExclude,
|
|
required this.maxDistanceRainy,
|
|
required this.maxDistanceNormal,
|
|
required this.selectedCategories,
|
|
});
|
|
}
|
|
|
|
/// 추천 관리 StateNotifier
|
|
class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
|
final RecommendationRepository _repository;
|
|
final Ref _ref;
|
|
final RecommendationEngine _recommendationEngine = RecommendationEngine();
|
|
|
|
RecommendationNotifier(this._repository, this._ref)
|
|
: super(const AsyncValue.data(null));
|
|
|
|
/// 랜덤 추천 실행
|
|
Future<Restaurant?> getRandomRecommendation({
|
|
required double maxDistance,
|
|
required List<String> selectedCategories,
|
|
List<String> excludedRestaurantIds = const [],
|
|
bool shouldSaveRecord = true,
|
|
}) async {
|
|
state = const AsyncValue.loading();
|
|
|
|
try {
|
|
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
|
|
if (screenshotModeEnabled) {
|
|
final mock = _buildScreenshotRestaurant();
|
|
state = AsyncValue.data(mock);
|
|
return mock;
|
|
}
|
|
|
|
final selectedRestaurant = await _generateCandidate(
|
|
maxDistance: maxDistance,
|
|
selectedCategories: selectedCategories,
|
|
excludedRestaurantIds: excludedRestaurantIds,
|
|
);
|
|
|
|
if (selectedRestaurant == null) {
|
|
state = const AsyncValue.data(null);
|
|
return null;
|
|
}
|
|
|
|
if (shouldSaveRecord) {
|
|
await saveRecommendationRecord(selectedRestaurant);
|
|
}
|
|
|
|
state = AsyncValue.data(selectedRestaurant);
|
|
return selectedRestaurant;
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<Restaurant?> _generateCandidate({
|
|
required double maxDistance,
|
|
required List<String> selectedCategories,
|
|
List<String> excludedRestaurantIds = const [],
|
|
}) async {
|
|
// 현재 위치 가져오기
|
|
final location = await _ref.read(currentLocationProvider.future);
|
|
if (location == null) {
|
|
throw Exception('위치 정보를 가져올 수 없습니다');
|
|
}
|
|
|
|
// 날씨 정보 가져오기
|
|
final weather = await _ref.read(weatherProvider.future);
|
|
|
|
// 사용자 설정 가져오기
|
|
final userSettings = await _ref.read(userSettingsProvider.future);
|
|
|
|
// 모든 식당 가져오기
|
|
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
|
|
|
// 방문 기록 가져오기
|
|
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
|
|
|
// 제외된 식당 제거
|
|
final availableRestaurants = excludedRestaurantIds.isEmpty
|
|
? allRestaurants
|
|
: allRestaurants
|
|
.where(
|
|
(restaurant) => !excludedRestaurantIds.contains(restaurant.id),
|
|
)
|
|
.toList();
|
|
|
|
if (availableRestaurants.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
// 추천 설정 구성
|
|
final config = RecommendationConfig(
|
|
userLatitude: location.latitude,
|
|
userLongitude: location.longitude,
|
|
maxDistance: maxDistance / 1000, // 미터 입력을 km 단위로 변환
|
|
selectedCategories: selectedCategories,
|
|
userSettings: userSettings,
|
|
weather: weather,
|
|
);
|
|
|
|
// 추천 엔진 사용
|
|
return _recommendationEngine.generateRecommendation(
|
|
allRestaurants: availableRestaurants,
|
|
recentVisits: allVisitRecords,
|
|
config: config,
|
|
);
|
|
}
|
|
|
|
/// 추천 기록 저장
|
|
Future<RecommendationRecord> saveRecommendationRecord(
|
|
Restaurant restaurant, {
|
|
DateTime? recommendationTime,
|
|
bool visited = false,
|
|
}) async {
|
|
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
|
|
final now = DateTime.now();
|
|
|
|
if (screenshotModeEnabled) {
|
|
return RecommendationRecord(
|
|
id: 'screenshot-${now.millisecondsSinceEpoch}',
|
|
restaurantId: restaurant.id,
|
|
recommendationDate: recommendationTime ?? now,
|
|
visited: visited,
|
|
createdAt: now,
|
|
);
|
|
}
|
|
|
|
final record = RecommendationRecord(
|
|
id: const Uuid().v4(),
|
|
restaurantId: restaurant.id,
|
|
recommendationDate: recommendationTime ?? now,
|
|
visited: visited,
|
|
createdAt: now,
|
|
);
|
|
|
|
await _repository.addRecommendationRecord(record);
|
|
return record;
|
|
}
|
|
|
|
/// 추천 후 방문 확인
|
|
Future<void> confirmVisit(String recommendationId) async {
|
|
try {
|
|
await _repository.markAsVisited(recommendationId);
|
|
|
|
// 방문 기록도 생성
|
|
final recommendations = await _ref.read(
|
|
recommendationRecordsProvider.future,
|
|
);
|
|
final recommendation = recommendations.firstWhere(
|
|
(r) => r.id == recommendationId,
|
|
);
|
|
|
|
final visitNotifier = _ref.read(visitNotifierProvider.notifier);
|
|
await visitNotifier.createVisitFromRecommendation(
|
|
restaurantId: recommendation.restaurantId,
|
|
recommendationTime: recommendation.recommendationDate,
|
|
isConfirmed: true,
|
|
);
|
|
|
|
// 방문 기록을 만들었으므로 추천 기록은 숨김 처리
|
|
await _repository.deleteRecommendationRecord(recommendationId);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
|
|
/// 추천 기록 삭제
|
|
Future<void> deleteRecommendation(String id) async {
|
|
try {
|
|
await _repository.deleteRecommendationRecord(id);
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// RecommendationNotifier Provider
|
|
final recommendationNotifierProvider =
|
|
StateNotifierProvider<RecommendationNotifier, AsyncValue<Restaurant?>>((
|
|
ref,
|
|
) {
|
|
final repository = ref.watch(recommendationRepositoryProvider);
|
|
return RecommendationNotifier(repository, ref);
|
|
});
|
|
|
|
/// 월별 추천 통계 Provider
|
|
final monthlyRecommendationStatsProvider =
|
|
FutureProvider.family<Map<String, int>, ({int year, int month})>((
|
|
ref,
|
|
params,
|
|
) async {
|
|
final repository = ref.watch(recommendationRepositoryProvider);
|
|
return repository.getMonthlyRecommendationStats(
|
|
params.year,
|
|
params.month,
|
|
);
|
|
});
|
|
|
|
/// 추천 상태 관리 (다시 추천 기능 포함)
|
|
class RecommendationState {
|
|
final Restaurant? currentRecommendation;
|
|
final List<Restaurant> excludedRestaurants;
|
|
final bool isLoading;
|
|
final String? error;
|
|
|
|
const RecommendationState({
|
|
this.currentRecommendation,
|
|
this.excludedRestaurants = const [],
|
|
this.isLoading = false,
|
|
this.error,
|
|
});
|
|
|
|
RecommendationState copyWith({
|
|
Restaurant? currentRecommendation,
|
|
List<Restaurant>? excludedRestaurants,
|
|
bool? isLoading,
|
|
String? error,
|
|
}) {
|
|
return RecommendationState(
|
|
currentRecommendation:
|
|
currentRecommendation ?? this.currentRecommendation,
|
|
excludedRestaurants: excludedRestaurants ?? this.excludedRestaurants,
|
|
isLoading: isLoading ?? this.isLoading,
|
|
error: error,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 향상된 추천 StateNotifier (다시 추천 기능 포함)
|
|
class EnhancedRecommendationNotifier
|
|
extends StateNotifier<RecommendationState> {
|
|
final Ref _ref;
|
|
final RecommendationEngine _recommendationEngine = RecommendationEngine();
|
|
|
|
EnhancedRecommendationNotifier(this._ref)
|
|
: super(const RecommendationState());
|
|
|
|
/// 다시 추천 (현재 추천 제외)
|
|
Future<void> rerollRecommendation() async {
|
|
if (state.currentRecommendation == null) return;
|
|
|
|
if (await _isScreenshotModeEnabled(_ref)) {
|
|
await generateRecommendation();
|
|
return;
|
|
}
|
|
|
|
// 현재 추천을 제외 목록에 추가
|
|
final excluded = [
|
|
...state.excludedRestaurants,
|
|
state.currentRecommendation!,
|
|
];
|
|
state = state.copyWith(excludedRestaurants: excluded);
|
|
|
|
// 다시 추천 생성 (제외 목록 적용)
|
|
await generateRecommendation(excludedRestaurants: excluded);
|
|
}
|
|
|
|
/// 추천 생성 (새로운 추천 엔진 활용)
|
|
Future<void> generateRecommendation({
|
|
List<Restaurant>? excludedRestaurants,
|
|
}) async {
|
|
state = state.copyWith(isLoading: true);
|
|
|
|
try {
|
|
if (await _isScreenshotModeEnabled(_ref)) {
|
|
final mock = _buildScreenshotRestaurant();
|
|
state = state.copyWith(
|
|
currentRecommendation: mock,
|
|
excludedRestaurants: const [],
|
|
isLoading: false,
|
|
error: null,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 현재 위치 가져오기
|
|
final location = await _ref.read(currentLocationProvider.future);
|
|
if (location == null) {
|
|
state = state.copyWith(error: '위치 정보를 가져올 수 없습니다', isLoading: false);
|
|
return;
|
|
}
|
|
|
|
// 필요한 데이터 가져오기
|
|
final weather = await _ref.read(weatherProvider.future);
|
|
final userSettings = await _ref.read(userSettingsProvider.future);
|
|
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
|
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
|
final maxDistanceNormal = await _ref.read(
|
|
maxDistanceNormalProvider.future,
|
|
);
|
|
final selectedCategory = _ref.read(selectedCategoryProvider);
|
|
final categories = selectedCategory != null
|
|
? [selectedCategory]
|
|
: <String>[];
|
|
|
|
// 제외 리스트 포함한 식당 필터링
|
|
final availableRestaurants = excludedRestaurants != null
|
|
? allRestaurants
|
|
.where((r) => !excludedRestaurants.any((ex) => ex.id == r.id))
|
|
.toList()
|
|
: allRestaurants;
|
|
|
|
// 추천 설정 구성
|
|
final config = RecommendationConfig(
|
|
userLatitude: location.latitude,
|
|
userLongitude: location.longitude,
|
|
maxDistance: maxDistanceNormal.toDouble() / 1000, // 미터 입력을 km 단위로 변환
|
|
selectedCategories: categories,
|
|
userSettings: userSettings,
|
|
weather: weather,
|
|
);
|
|
|
|
// 추천 엔진 사용
|
|
final selectedRestaurant = await _recommendationEngine
|
|
.generateRecommendation(
|
|
allRestaurants: availableRestaurants,
|
|
recentVisits: allVisitRecords,
|
|
config: config,
|
|
);
|
|
|
|
if (selectedRestaurant != null) {
|
|
// 추천 기록 저장
|
|
final record = RecommendationRecord(
|
|
id: const Uuid().v4(),
|
|
restaurantId: selectedRestaurant.id,
|
|
recommendationDate: DateTime.now(),
|
|
visited: false,
|
|
createdAt: DateTime.now(),
|
|
);
|
|
|
|
final repository = _ref.read(recommendationRepositoryProvider);
|
|
await repository.addRecommendationRecord(record);
|
|
|
|
state = state.copyWith(
|
|
currentRecommendation: selectedRestaurant,
|
|
isLoading: false,
|
|
);
|
|
} else {
|
|
state = state.copyWith(error: '조건에 맞는 맛집이 없습니다', isLoading: false);
|
|
}
|
|
} catch (e) {
|
|
state = state.copyWith(error: e.toString(), isLoading: false);
|
|
}
|
|
}
|
|
|
|
/// 추천 초기화
|
|
void resetRecommendation() {
|
|
state = const RecommendationState();
|
|
}
|
|
}
|
|
|
|
/// 향상된 추천 Provider
|
|
final enhancedRecommendationProvider =
|
|
StateNotifierProvider<EnhancedRecommendationNotifier, RecommendationState>((
|
|
ref,
|
|
) {
|
|
return EnhancedRecommendationNotifier(ref);
|
|
});
|
|
|
|
/// 추천 가능한 맛집 수 Provider
|
|
final recommendableRestaurantsCountProvider = FutureProvider<int>((ref) async {
|
|
final daysToExclude = await ref.watch(daysToExcludeProvider.future);
|
|
final recentlyVisited = await ref.watch(
|
|
restaurantsNotVisitedInDaysProvider(daysToExclude).future,
|
|
);
|
|
|
|
return recentlyVisited.length;
|
|
});
|
|
|
|
/// 카테고리별 추천 통계 Provider
|
|
final recommendationStatsByCategoryProvider = FutureProvider<Map<String, int>>((
|
|
ref,
|
|
) async {
|
|
final records = await ref.watch(recommendationRecordsProvider.future);
|
|
|
|
final stats = <String, int>{};
|
|
for (final record in records) {
|
|
final restaurant = await ref.watch(
|
|
restaurantProvider(record.restaurantId).future,
|
|
);
|
|
if (restaurant != null) {
|
|
stats[restaurant.category] = (stats[restaurant.category] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
return stats;
|
|
});
|
|
|
|
/// 추천 성공률 Provider
|
|
final recommendationSuccessRateProvider = FutureProvider<double>((ref) async {
|
|
final records = await ref.watch(recommendationRecordsProvider.future);
|
|
if (records.isEmpty) return 0.0;
|
|
|
|
final visitedCount = records.where((r) => r.visited).length;
|
|
return (visitedCount / records.length) * 100;
|
|
});
|
|
|
|
/// 가장 많이 추천된 맛집 Top 5 Provider
|
|
final topRecommendedRestaurantsProvider =
|
|
FutureProvider<List<({String restaurantId, int count})>>((ref) async {
|
|
final records = await ref.watch(recommendationRecordsProvider.future);
|
|
|
|
final counts = <String, int>{};
|
|
for (final record in records) {
|
|
counts[record.restaurantId] = (counts[record.restaurantId] ?? 0) + 1;
|
|
}
|
|
|
|
final sorted = counts.entries.toList()
|
|
..sort((a, b) => b.value.compareTo(a.value));
|
|
|
|
return sorted
|
|
.take(5)
|
|
.map((e) => (restaurantId: e.key, count: e.value))
|
|
.toList();
|
|
});
|