feat(app): add manual entry and sharing flows

This commit is contained in:
JiWoong Sul
2025-11-19 16:36:39 +09:00
parent 5ade584370
commit 947fe59486
110 changed files with 5937 additions and 3781 deletions

View File

@@ -5,17 +5,19 @@ 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/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();
});
final recommendationRecordsProvider =
StreamProvider<List<RecommendationRecord>>((ref) {
final repository = ref.watch(recommendationRepositoryProvider);
return repository.watchRecommendationRecords();
});
/// 오늘의 추천 횟수 Provider
final todayRecommendationCountProvider = FutureProvider<int>((ref) async {
@@ -44,7 +46,8 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
final Ref _ref;
final RecommendationEngine _recommendationEngine = RecommendationEngine();
RecommendationNotifier(this._repository, this._ref) : super(const AsyncValue.data(null));
RecommendationNotifier(this._repository, this._ref)
: super(const AsyncValue.data(null));
/// 랜덤 추천 실행
Future<void> getRandomRecommendation({
@@ -52,7 +55,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
required List<String> selectedCategories,
}) async {
state = const AsyncValue.loading();
try {
// 현재 위치 가져오기
final location = await _ref.read(currentLocationProvider.future);
@@ -62,16 +65,16 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
// 날씨 정보 가져오기
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 config = RecommendationConfig(
userLatitude: location.latitude,
@@ -81,14 +84,15 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
userSettings: userSettings,
weather: weather,
);
// 추천 엔진 사용
final selectedRestaurant = await _recommendationEngine.generateRecommendation(
allRestaurants: allRestaurants,
recentVisits: allVisitRecords,
config: config,
);
final selectedRestaurant = await _recommendationEngine
.generateRecommendation(
allRestaurants: allRestaurants,
recentVisits: allVisitRecords,
config: config,
);
if (selectedRestaurant == null) {
state = const AsyncValue.data(null);
return;
@@ -120,11 +124,15 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
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 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,
@@ -146,16 +154,26 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
}
/// RecommendationNotifier Provider
final recommendationNotifierProvider = StateNotifierProvider<RecommendationNotifier, AsyncValue<Restaurant?>>((ref) {
final repository = ref.watch(recommendationRepositoryProvider);
return RecommendationNotifier(repository, ref);
});
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);
});
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 {
@@ -163,14 +181,14 @@ class RecommendationState {
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,
@@ -178,7 +196,8 @@ class RecommendationState {
String? error,
}) {
return RecommendationState(
currentRecommendation: currentRecommendation ?? this.currentRecommendation,
currentRecommendation:
currentRecommendation ?? this.currentRecommendation,
excludedRestaurants: excludedRestaurants ?? this.excludedRestaurants,
isLoading: isLoading ?? this.isLoading,
error: error,
@@ -187,28 +206,35 @@ class RecommendationState {
}
/// 향상된 추천 StateNotifier (다시 추천 기능 포함)
class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState> {
class EnhancedRecommendationNotifier
extends StateNotifier<RecommendationState> {
final Ref _ref;
final RecommendationEngine _recommendationEngine = RecommendationEngine();
EnhancedRecommendationNotifier(this._ref) : super(const RecommendationState());
EnhancedRecommendationNotifier(this._ref)
: super(const RecommendationState());
/// 다시 추천 (현재 추천 제외)
Future<void> rerollRecommendation() async {
if (state.currentRecommendation == null) return;
// 현재 추천을 제외 목록에 추가
final excluded = [...state.excludedRestaurants, state.currentRecommendation!];
final excluded = [
...state.excludedRestaurants,
state.currentRecommendation!,
];
state = state.copyWith(excludedRestaurants: excluded);
// 다시 추천 생성 (제외 목록 적용)
await generateRecommendation(excludedRestaurants: excluded);
}
/// 추천 생성 (새로운 추천 엔진 활용)
Future<void> generateRecommendation({List<Restaurant>? excludedRestaurants}) async {
Future<void> generateRecommendation({
List<Restaurant>? excludedRestaurants,
}) async {
state = state.copyWith(isLoading: true);
try {
// 현재 위치 가져오기
final location = await _ref.read(currentLocationProvider.future);
@@ -216,21 +242,27 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
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 maxDistanceNormal = await _ref.read(
maxDistanceNormalProvider.future,
);
final selectedCategory = _ref.read(selectedCategoryProvider);
final categories = selectedCategory != null ? [selectedCategory] : <String>[];
final categories = selectedCategory != null
? [selectedCategory]
: <String>[];
// 제외 리스트 포함한 식당 필터링
final availableRestaurants = excludedRestaurants != null
? allRestaurants.where((r) => !excludedRestaurants.any((ex) => ex.id == r.id)).toList()
? allRestaurants
.where((r) => !excludedRestaurants.any((ex) => ex.id == r.id))
.toList()
: allRestaurants;
// 추천 설정 구성
final config = RecommendationConfig(
userLatitude: location.latitude,
@@ -240,14 +272,15 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
userSettings: userSettings,
weather: weather,
);
// 추천 엔진 사용
final selectedRestaurant = await _recommendationEngine.generateRecommendation(
allRestaurants: availableRestaurants,
recentVisits: allVisitRecords,
config: config,
);
final selectedRestaurant = await _recommendationEngine
.generateRecommendation(
allRestaurants: availableRestaurants,
recentVisits: allVisitRecords,
config: config,
);
if (selectedRestaurant != null) {
// 추천 기록 저장
final record = RecommendationRecord(
@@ -257,28 +290,22 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
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,
);
state = state.copyWith(error: '조건에 맞는 맛집이 없습니다', isLoading: false);
}
} catch (e) {
state = state.copyWith(
error: e.toString(),
isLoading: false,
);
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
/// 추천 초기화
void resetRecommendation() {
state = const RecommendationState();
@@ -286,33 +313,39 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
}
/// 향상된 추천 Provider
final enhancedRecommendationProvider =
StateNotifierProvider<EnhancedRecommendationNotifier, RecommendationState>((ref) {
return EnhancedRecommendationNotifier(ref);
});
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
restaurantsNotVisitedInDaysProvider(daysToExclude).future,
);
return recentlyVisited.length;
});
/// 카테고리별 추천 통계 Provider
final recommendationStatsByCategoryProvider = FutureProvider<Map<String, int>>((ref) async {
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);
final restaurant = await ref.watch(
restaurantProvider(record.restaurantId).future,
);
if (restaurant != null) {
stats[restaurant.category] = (stats[restaurant.category] ?? 0) + 1;
}
}
return stats;
});
@@ -320,22 +353,26 @@ final recommendationStatsByCategoryProvider = FutureProvider<Map<String, int>>((
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();
});
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();
});