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

@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/core/services/ad_service.dart';
/// 광고 서비스 Provider
final adServiceProvider = Provider<AdService>((ref) {
return AdService();
});

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/core/services/bluetooth_service.dart';
final bluetoothServiceProvider = Provider<BluetoothService>((ref) {
final service = BluetoothService();
ref.onDispose(service.dispose);
return service;
});

View File

@@ -31,6 +31,8 @@ final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
});
/// RecommendationRepository Provider
final recommendationRepositoryProvider = Provider<RecommendationRepository>((ref) {
final recommendationRepositoryProvider = Provider<RecommendationRepository>((
ref,
) {
return RecommendationRepositoryImpl();
});
});

View File

@@ -3,7 +3,9 @@ import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
/// 위치 권한 상태 Provider
final locationPermissionProvider = FutureProvider<PermissionStatus>((ref) async {
final locationPermissionProvider = FutureProvider<PermissionStatus>((
ref,
) async {
return await Permission.location.status;
});
@@ -11,7 +13,7 @@ final locationPermissionProvider = FutureProvider<PermissionStatus>((ref) async
final currentLocationProvider = FutureProvider<Position?>((ref) async {
// 위치 권한 확인
final permissionStatus = await Permission.location.status;
if (!permissionStatus.isGranted) {
// 권한이 없으면 요청
final result = await Permission.location.request();
@@ -74,7 +76,7 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
/// 현재 위치 가져오기
Future<void> getCurrentLocation() async {
state = const AsyncValue.loading();
try {
// 권한 확인
final permissionStatus = await Permission.location.status;
@@ -128,6 +130,7 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
}
/// LocationNotifier Provider
final locationNotifierProvider = StateNotifierProvider<LocationNotifier, AsyncValue<Position?>>((ref) {
return LocationNotifier();
});
final locationNotifierProvider =
StateNotifierProvider<LocationNotifier, AsyncValue<Position?>>((ref) {
return LocationNotifier();
});

View File

@@ -22,7 +22,9 @@ class NotificationPayload {
try {
final parts = payload.split('|');
if (parts.length < 4) {
throw FormatException('Invalid payload format - expected 4 parts but got ${parts.length}: $payload');
throw FormatException(
'Invalid payload format - expected 4 parts but got ${parts.length}: $payload',
);
}
// 각 필드 유효성 검증
@@ -66,11 +68,14 @@ class NotificationPayload {
/// 알림 핸들러 StateNotifier
class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
final Ref _ref;
NotificationHandlerNotifier(this._ref) : super(const AsyncValue.data(null));
/// 알림 클릭 처리
Future<void> handleNotificationTap(BuildContext context, String? payload) async {
Future<void> handleNotificationTap(
BuildContext context,
String? payload,
) async {
if (payload == null || payload.isEmpty) {
print('Notification payload is null or empty');
return;
@@ -83,12 +88,13 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
if (payload.startsWith('visit_reminder:')) {
final restaurantName = payload.substring(15);
print('Legacy format - Restaurant name: $restaurantName');
// 맛집 이름으로 ID 찾기
final restaurantsAsync = await _ref.read(restaurantListProvider.future);
final restaurant = restaurantsAsync.firstWhere(
(r) => r.name == restaurantName,
orElse: () => throw Exception('Restaurant not found: $restaurantName'),
orElse: () =>
throw Exception('Restaurant not found: $restaurantName'),
);
// 방문 확인 다이얼로그 표시
@@ -97,17 +103,21 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
context: context,
restaurantId: restaurant.id,
restaurantName: restaurant.name,
recommendationTime: DateTime.now().subtract(const Duration(hours: 2)),
recommendationTime: DateTime.now().subtract(
const Duration(hours: 2),
),
);
}
} else {
// 새로운 형식의 payload 처리
print('Attempting to parse new format payload');
try {
final notificationPayload = NotificationPayload.fromString(payload);
print('Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}');
print(
'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}',
);
if (notificationPayload.type == 'visit_reminder') {
// 방문 확인 다이얼로그 표시
if (context.mounted) {
@@ -127,7 +137,7 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
} catch (parseError) {
print('Failed to parse new format, attempting fallback parsing');
print('Parse error: $parseError');
// Fallback: 간단한 파싱 시도
if (payload.contains('|')) {
final parts = payload.split('|');
@@ -135,16 +145,14 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
// 최소한 캘린더로 이동
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('알림을 처리했습니다. 방문 기록을 확인해주세요.'),
),
const SnackBar(content: Text('알림을 처리했습니다. 방문 기록을 확인해주세요.')),
);
context.go('/home?tab=calendar');
}
return;
}
}
// 파싱 실패 시 원래 에러 다시 발생
rethrow;
}
@@ -153,7 +161,7 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
print('Error handling notification: $e');
print('Stack trace: $stackTrace');
state = AsyncValue.error(e, stackTrace);
// 에러 발생 시 기본적으로 캘린더 화면으로 이동
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -169,6 +177,7 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
}
/// NotificationHandler Provider
final notificationHandlerProvider = StateNotifierProvider<NotificationHandlerNotifier, AsyncValue<void>>((ref) {
return NotificationHandlerNotifier(ref);
});
final notificationHandlerProvider =
StateNotifierProvider<NotificationHandlerNotifier, AsyncValue<void>>((ref) {
return NotificationHandlerNotifier(ref);
});

View File

@@ -16,4 +16,4 @@ final notificationPermissionProvider = FutureProvider<bool>((ref) async {
final pendingNotificationsProvider = FutureProvider((ref) async {
final service = ref.watch(notificationServiceProvider);
return await service.getPendingNotifications();
});
});

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();
});

View File

@@ -12,7 +12,10 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
});
/// 특정 맛집 Provider
final restaurantProvider = FutureProvider.family<Restaurant?, String>((ref, id) async {
final restaurantProvider = FutureProvider.family<Restaurant?, String>((
ref,
id,
) async {
final repository = ref.watch(restaurantRepositoryProvider);
return repository.getRestaurantById(id);
});
@@ -43,7 +46,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
required DataSource source,
}) async {
state = const AsyncValue.loading();
try {
final restaurant = Restaurant(
id: const Uuid().v4(),
@@ -71,7 +74,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
/// 맛집 수정
Future<void> updateRestaurant(Restaurant restaurant) async {
state = const AsyncValue.loading();
try {
final updated = Restaurant(
id: restaurant.id,
@@ -100,7 +103,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
/// 맛집 삭제
Future<void> deleteRestaurant(String id) async {
state = const AsyncValue.loading();
try {
await _repository.deleteRestaurant(id);
state = const AsyncValue.data(null);
@@ -110,7 +113,10 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
}
/// 마지막 방문일 업데이트
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate) async {
Future<void> updateLastVisitDate(
String restaurantId,
DateTime visitDate,
) async {
try {
await _repository.updateLastVisitDate(restaurantId, visitDate);
} catch (e, stack) {
@@ -121,7 +127,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
/// 네이버 지도 URL로부터 맛집 추가
Future<Restaurant> addRestaurantFromUrl(String url) async {
state = const AsyncValue.loading();
try {
final restaurant = await _repository.addRestaurantFromUrl(url);
state = const AsyncValue.data(null);
@@ -135,7 +141,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
/// 미리 생성된 Restaurant 객체를 직접 추가
Future<void> addRestaurantDirect(Restaurant restaurant) async {
state = const AsyncValue.loading();
try {
await _repository.addRestaurant(restaurant);
state = const AsyncValue.data(null);
@@ -147,38 +153,46 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
}
/// RestaurantNotifier Provider
final restaurantNotifierProvider = StateNotifierProvider<RestaurantNotifier, AsyncValue<void>>((ref) {
final repository = ref.watch(restaurantRepositoryProvider);
return RestaurantNotifier(repository);
});
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,
);
});
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);
});
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);
});
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);
});
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) => '');
@@ -187,37 +201,45 @@ final searchQueryProvider = StateProvider<String>((ref) => '');
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
/// 필터링된 맛집 목록 Provider (검색 + 카테고리)
final filteredRestaurantsProvider = StreamProvider<List<Restaurant>>((ref) async* {
final filteredRestaurantsProvider = StreamProvider<List<Restaurant>>((
ref,
) async* {
final searchQuery = ref.watch(searchQueryProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
final restaurantsStream = ref.watch(restaurantListProvider.stream);
await for (final restaurants in restaurantsStream) {
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.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;
return restaurant.category == selectedCategory ||
restaurant.category.contains(selectedCategory) ||
CategoryMapper.normalizeNaverCategory(
restaurant.category,
restaurant.subCategory,
) ==
selectedCategory ||
CategoryMapper.getDisplayName(restaurant.category) ==
selectedCategory;
}).toList();
}
yield filtered;
}
});
});

View File

@@ -170,10 +170,11 @@ class SettingsNotifier extends StateNotifier<AsyncValue<void>> {
}
/// SettingsNotifier Provider
final settingsNotifierProvider = StateNotifierProvider<SettingsNotifier, AsyncValue<void>>((ref) {
final repository = ref.watch(settingsRepositoryProvider);
return SettingsNotifier(repository);
});
final settingsNotifierProvider =
StateNotifierProvider<SettingsNotifier, AsyncValue<void>>((ref) {
final repository = ref.watch(settingsRepositoryProvider);
return SettingsNotifier(repository);
});
/// 설정 프리셋
enum SettingsPreset {
@@ -210,16 +211,20 @@ enum SettingsPreset {
}
/// 프리셋 적용 Provider
final applyPresetProvider = Provider.family<Future<void>, SettingsPreset>((ref, preset) async {
final applyPresetProvider = Provider.family<Future<void>, SettingsPreset>((
ref,
preset,
) async {
final notifier = ref.read(settingsNotifierProvider.notifier);
await notifier.setDaysToExclude(preset.daysToExclude);
await notifier.setMaxDistanceNormal(preset.maxDistanceNormal);
await notifier.setMaxDistanceRainy(preset.maxDistanceRainy);
});
/// 현재 위치 Provider
final currentLocationProvider = StateProvider<({double latitude, double longitude})?>((ref) => null);
final currentLocationProvider =
StateProvider<({double latitude, double longitude})?>((ref) => null);
/// 선호 카테고리 Provider
final preferredCategoriesProvider = StateProvider<List<String>>((ref) => []);
@@ -241,8 +246,10 @@ final allSettingsProvider = Provider<Map<String, dynamic>>((ref) {
final daysToExclude = ref.watch(daysToExcludeProvider).value ?? 7;
final maxDistanceRainy = ref.watch(maxDistanceRainyProvider).value ?? 500;
final maxDistanceNormal = ref.watch(maxDistanceNormalProvider).value ?? 1000;
final notificationDelay = ref.watch(notificationDelayMinutesProvider).value ?? 90;
final notificationEnabled = ref.watch(notificationEnabledProvider).value ?? false;
final notificationDelay =
ref.watch(notificationDelayMinutesProvider).value ?? 90;
final notificationEnabled =
ref.watch(notificationEnabledProvider).value ?? false;
final darkMode = ref.watch(darkModeEnabledProvider).value ?? false;
final currentLocation = ref.watch(currentLocationProvider);
final preferredCategories = ref.watch(preferredCategoriesProvider);
@@ -261,4 +268,4 @@ final allSettingsProvider = Provider<Map<String, dynamic>>((ref) {
'excludedCategories': excludedCategories,
'language': language,
};
});
});

View File

@@ -12,29 +12,36 @@ final visitRecordsProvider = StreamProvider<List<VisitRecord>>((ref) {
});
/// 날짜별 방문 기록 Provider
final visitRecordsByDateProvider = FutureProvider.family<List<VisitRecord>, DateTime>((ref, date) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getVisitRecordsByDate(date);
});
final visitRecordsByDateProvider =
FutureProvider.family<List<VisitRecord>, DateTime>((ref, date) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getVisitRecordsByDate(date);
});
/// 맛집별 방문 기록 Provider
final visitRecordsByRestaurantProvider = FutureProvider.family<List<VisitRecord>, String>((ref, restaurantId) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getVisitRecordsByRestaurantId(restaurantId);
});
final visitRecordsByRestaurantProvider =
FutureProvider.family<List<VisitRecord>, String>((ref, restaurantId) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getVisitRecordsByRestaurantId(restaurantId);
});
/// 월별 방문 통계 Provider
final monthlyVisitStatsProvider = FutureProvider.family<Map<String, int>, ({int year, int month})>((ref, params) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getMonthlyVisitStats(params.year, params.month);
});
final monthlyVisitStatsProvider =
FutureProvider.family<Map<String, int>, ({int year, int month})>((
ref,
params,
) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getMonthlyVisitStats(params.year, params.month);
});
/// 방문 기록 관리 StateNotifier
class VisitNotifier extends StateNotifier<AsyncValue<void>> {
final VisitRepository _repository;
final Ref _ref;
VisitNotifier(this._repository, this._ref) : super(const AsyncValue.data(null));
VisitNotifier(this._repository, this._ref)
: super(const AsyncValue.data(null));
/// 방문 기록 추가
Future<void> addVisitRecord({
@@ -43,7 +50,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
bool isConfirmed = false,
}) async {
state = const AsyncValue.loading();
try {
final visitRecord = VisitRecord(
id: const Uuid().v4(),
@@ -54,11 +61,11 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
);
await _repository.addVisitRecord(visitRecord);
// 맛집의 마지막 방문일도 업데이트
final restaurantNotifier = _ref.read(restaurantNotifierProvider.notifier);
await restaurantNotifier.updateLastVisitDate(restaurantId, visitDate);
state = const AsyncValue.data(null);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
@@ -68,7 +75,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
/// 방문 확인
Future<void> confirmVisit(String visitRecordId) async {
state = const AsyncValue.loading();
try {
await _repository.confirmVisit(visitRecordId);
state = const AsyncValue.data(null);
@@ -80,7 +87,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
/// 방문 기록 삭제
Future<void> deleteVisitRecord(String id) async {
state = const AsyncValue.loading();
try {
await _repository.deleteVisitRecord(id);
state = const AsyncValue.data(null);
@@ -96,7 +103,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
}) async {
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
final visitTime = recommendationTime.add(const Duration(minutes: 90));
await addVisitRecord(
restaurantId: restaurantId,
visitDate: visitTime,
@@ -106,109 +113,138 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
}
/// VisitNotifier Provider
final visitNotifierProvider = StateNotifierProvider<VisitNotifier, AsyncValue<void>>((ref) {
final repository = ref.watch(visitRepositoryProvider);
return VisitNotifier(repository, ref);
});
final visitNotifierProvider =
StateNotifierProvider<VisitNotifier, AsyncValue<void>>((ref) {
final repository = ref.watch(visitRepositoryProvider);
return VisitNotifier(repository, ref);
});
/// 특정 맛집의 마지막 방문일 Provider
final lastVisitDateProvider = FutureProvider.family<DateTime?, String>((ref, restaurantId) async {
final lastVisitDateProvider = FutureProvider.family<DateTime?, String>((
ref,
restaurantId,
) async {
final repository = ref.watch(visitRepositoryProvider);
return repository.getLastVisitDate(restaurantId);
});
/// 기간별 방문 기록 Provider
final visitRecordsByPeriodProvider = FutureProvider.family<List<VisitRecord>, ({DateTime startDate, DateTime endDate})>((ref, params) async {
final allRecords = await ref.watch(visitRecordsProvider.future);
return allRecords.where((record) {
return record.visitDate.isAfter(params.startDate) &&
record.visitDate.isBefore(params.endDate.add(const Duration(days: 1)));
}).toList()
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
});
final visitRecordsByPeriodProvider =
FutureProvider.family<
List<VisitRecord>,
({DateTime startDate, DateTime endDate})
>((ref, params) async {
final allRecords = await ref.watch(visitRecordsProvider.future);
return allRecords.where((record) {
return record.visitDate.isAfter(params.startDate) &&
record.visitDate.isBefore(
params.endDate.add(const Duration(days: 1)),
);
}).toList()..sort((a, b) => b.visitDate.compareTo(a.visitDate));
});
/// 주간 방문 통계 Provider (최근 7일)
final weeklyVisitStatsProvider = FutureProvider<Map<String, int>>((ref) async {
final now = DateTime.now();
final startOfWeek = DateTime(now.year, now.month, now.day).subtract(const Duration(days: 6));
final records = await ref.watch(visitRecordsByPeriodProvider((
startDate: startOfWeek,
endDate: now,
)).future);
final startOfWeek = DateTime(
now.year,
now.month,
now.day,
).subtract(const Duration(days: 6));
final records = await ref.watch(
visitRecordsByPeriodProvider((startDate: startOfWeek, endDate: now)).future,
);
final stats = <String, int>{};
for (var i = 0; i < 7; i++) {
final date = startOfWeek.add(Duration(days: i));
final dateKey = '${date.month}/${date.day}';
stats[dateKey] = records.where((r) =>
r.visitDate.year == date.year &&
r.visitDate.month == date.month &&
r.visitDate.day == date.day
).length;
stats[dateKey] = records
.where(
(r) =>
r.visitDate.year == date.year &&
r.visitDate.month == date.month &&
r.visitDate.day == date.day,
)
.length;
}
return stats;
});
/// 자주 방문하는 맛집 Provider (상위 10개)
final frequentRestaurantsProvider = FutureProvider<List<({String restaurantId, int visitCount})>>((ref) async {
final allRecords = await ref.watch(visitRecordsProvider.future);
final visitCounts = <String, int>{};
for (final record in allRecords) {
visitCounts[record.restaurantId] = (visitCounts[record.restaurantId] ?? 0) + 1;
}
final sorted = visitCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return sorted.take(10).map((e) => (restaurantId: e.key, visitCount: e.value)).toList();
});
final frequentRestaurantsProvider =
FutureProvider<List<({String restaurantId, int visitCount})>>((ref) async {
final allRecords = await ref.watch(visitRecordsProvider.future);
final visitCounts = <String, int>{};
for (final record in allRecords) {
visitCounts[record.restaurantId] =
(visitCounts[record.restaurantId] ?? 0) + 1;
}
final sorted = visitCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return sorted
.take(10)
.map((e) => (restaurantId: e.key, visitCount: e.value))
.toList();
});
/// 방문 기록 정렬 옵션
enum VisitSortOption {
dateDesc, // 최신순
dateAsc, // 오래된순
dateDesc, // 최신순
dateAsc, // 오래된순
restaurant, // 맛집별
}
/// 정렬된 방문 기록 Provider
final sortedVisitRecordsProvider = Provider.family<AsyncValue<List<VisitRecord>>, VisitSortOption>((ref, sortOption) {
final recordsAsync = ref.watch(visitRecordsProvider);
return recordsAsync.when(
data: (records) {
final sorted = List<VisitRecord>.from(records);
switch (sortOption) {
case VisitSortOption.dateDesc:
sorted.sort((a, b) => b.visitDate.compareTo(a.visitDate));
break;
case VisitSortOption.dateAsc:
sorted.sort((a, b) => a.visitDate.compareTo(b.visitDate));
break;
case VisitSortOption.restaurant:
sorted.sort((a, b) => a.restaurantId.compareTo(b.restaurantId));
break;
}
return AsyncValue.data(sorted);
},
loading: () => const AsyncValue.loading(),
error: (error, stack) => AsyncValue.error(error, stack),
);
});
final sortedVisitRecordsProvider =
Provider.family<AsyncValue<List<VisitRecord>>, VisitSortOption>((
ref,
sortOption,
) {
final recordsAsync = ref.watch(visitRecordsProvider);
return recordsAsync.when(
data: (records) {
final sorted = List<VisitRecord>.from(records);
switch (sortOption) {
case VisitSortOption.dateDesc:
sorted.sort((a, b) => b.visitDate.compareTo(a.visitDate));
break;
case VisitSortOption.dateAsc:
sorted.sort((a, b) => a.visitDate.compareTo(b.visitDate));
break;
case VisitSortOption.restaurant:
sorted.sort((a, b) => a.restaurantId.compareTo(b.restaurantId));
break;
}
return AsyncValue.data(sorted);
},
loading: () => const AsyncValue.loading(),
error: (error, stack) => AsyncValue.error(error, stack),
);
});
/// 카테고리별 방문 통계 Provider
final categoryVisitStatsProvider = FutureProvider<Map<String, int>>((ref) async {
final categoryVisitStatsProvider = FutureProvider<Map<String, int>>((
ref,
) async {
final allRecords = await ref.watch(visitRecordsProvider.future);
final restaurantsAsync = await ref.watch(restaurantListProvider.future);
final categoryCount = <String, int>{};
for (final record in allRecords) {
final restaurant = restaurantsAsync.where((r) => r.id == record.restaurantId).firstOrNull;
final restaurant = restaurantsAsync
.where((r) => r.id == record.restaurantId)
.firstOrNull;
if (restaurant != null) {
categoryCount[restaurant.category] = (categoryCount[restaurant.category] ?? 0) + 1;
categoryCount[restaurant.category] =
(categoryCount[restaurant.category] ?? 0) + 1;
}
}
return categoryCount;
});
});

View File

@@ -8,7 +8,7 @@ import 'package:lunchpick/presentation/providers/location_provider.dart';
final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
final repository = ref.watch(weatherRepositoryProvider);
final location = await ref.watch(currentLocationProvider.future);
if (location == null) {
throw Exception('위치 정보를 가져올 수 없습니다');
}
@@ -37,12 +37,13 @@ class WeatherNotifier extends StateNotifier<AsyncValue<WeatherInfo>> {
final WeatherRepository _repository;
final Ref _ref;
WeatherNotifier(this._repository, this._ref) : super(const AsyncValue.loading());
WeatherNotifier(this._repository, this._ref)
: super(const AsyncValue.loading());
/// 날씨 정보 새로고침
Future<void> refreshWeather() async {
state = const AsyncValue.loading();
try {
final location = await _ref.read(currentLocationProvider.future);
if (location == null) {
@@ -86,7 +87,8 @@ class WeatherNotifier extends StateNotifier<AsyncValue<WeatherInfo>> {
}
/// WeatherNotifier Provider
final weatherNotifierProvider = StateNotifierProvider<WeatherNotifier, AsyncValue<WeatherInfo>>((ref) {
final repository = ref.watch(weatherRepositoryProvider);
return WeatherNotifier(repository, ref);
});
final weatherNotifierProvider =
StateNotifierProvider<WeatherNotifier, AsyncValue<WeatherInfo>>((ref) {
final repository = ref.watch(weatherRepositoryProvider);
return WeatherNotifier(repository, ref);
});