feat: 초기 프로젝트 설정 및 LunchPick 앱 구현
LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
36
lib/presentation/providers/di_providers.dart
Normal file
36
lib/presentation/providers/di_providers.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/data/repositories/restaurant_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/visit_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/settings_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/weather_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/recommendation_repository_impl.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
|
||||
/// RestaurantRepository Provider
|
||||
final restaurantRepositoryProvider = Provider<RestaurantRepository>((ref) {
|
||||
return RestaurantRepositoryImpl();
|
||||
});
|
||||
|
||||
/// VisitRepository Provider
|
||||
final visitRepositoryProvider = Provider<VisitRepository>((ref) {
|
||||
return VisitRepositoryImpl();
|
||||
});
|
||||
|
||||
/// SettingsRepository Provider
|
||||
final settingsRepositoryProvider = Provider<SettingsRepository>((ref) {
|
||||
return SettingsRepositoryImpl();
|
||||
});
|
||||
|
||||
/// WeatherRepository Provider
|
||||
final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
|
||||
return WeatherRepositoryImpl();
|
||||
});
|
||||
|
||||
/// RecommendationRepository Provider
|
||||
final recommendationRepositoryProvider = Provider<RecommendationRepository>((ref) {
|
||||
return RecommendationRepositoryImpl();
|
||||
});
|
||||
133
lib/presentation/providers/location_provider.dart
Normal file
133
lib/presentation/providers/location_provider.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 위치 권한 상태 Provider
|
||||
final locationPermissionProvider = FutureProvider<PermissionStatus>((ref) async {
|
||||
return await Permission.location.status;
|
||||
});
|
||||
|
||||
/// 현재 위치 Provider
|
||||
final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
// 위치 권한 확인
|
||||
final permissionStatus = await Permission.location.status;
|
||||
|
||||
if (!permissionStatus.isGranted) {
|
||||
// 권한이 없으면 요청
|
||||
final result = await Permission.location.request();
|
||||
if (!result.isGranted) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 위치 서비스 활성화 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
throw Exception('위치 서비스가 비활성화되어 있습니다');
|
||||
}
|
||||
|
||||
// 현재 위치 가져오기
|
||||
try {
|
||||
return await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
} catch (e) {
|
||||
// 타임아웃이나 오류 발생 시 마지막 알려진 위치 반환
|
||||
return await Geolocator.getLastKnownPosition();
|
||||
}
|
||||
});
|
||||
|
||||
/// 위치 스트림 Provider
|
||||
final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 10, // 10미터 이상 이동 시 업데이트
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// 위치 관리 StateNotifier
|
||||
class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
LocationNotifier() : super(const AsyncValue.loading());
|
||||
|
||||
/// 위치 권한 요청
|
||||
Future<bool> requestLocationPermission() async {
|
||||
try {
|
||||
final status = await Permission.location.request();
|
||||
return status.isGranted;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 위치 서비스 활성화 요청
|
||||
Future<bool> requestLocationService() async {
|
||||
try {
|
||||
return await Geolocator.openLocationSettings();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 위치 가져오기
|
||||
Future<void> getCurrentLocation() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
// 권한 확인
|
||||
final permissionStatus = await Permission.location.status;
|
||||
if (!permissionStatus.isGranted) {
|
||||
final granted = await requestLocationPermission();
|
||||
if (!granted) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 위치 서비스 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
state = AsyncValue.error('위치 서비스가 비활성화되어 있습니다', StackTrace.current);
|
||||
return;
|
||||
}
|
||||
|
||||
// 위치 가져오기
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
state = AsyncValue.data(position);
|
||||
} catch (e, stack) {
|
||||
// 오류 발생 시 마지막 알려진 위치 시도
|
||||
try {
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
state = AsyncValue.data(lastPosition);
|
||||
} catch (_) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 두 지점 간의 거리 계산 (미터 단위)
|
||||
double calculateDistance(
|
||||
double startLatitude,
|
||||
double startLongitude,
|
||||
double endLatitude,
|
||||
double endLongitude,
|
||||
) {
|
||||
return Geolocator.distanceBetween(
|
||||
startLatitude,
|
||||
startLongitude,
|
||||
endLatitude,
|
||||
endLongitude,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// LocationNotifier Provider
|
||||
final locationNotifierProvider = StateNotifierProvider<LocationNotifier, AsyncValue<Position?>>((ref) {
|
||||
return LocationNotifier();
|
||||
});
|
||||
174
lib/presentation/providers/notification_handler_provider.dart
Normal file
174
lib/presentation/providers/notification_handler_provider.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lunchpick/presentation/pages/calendar/widgets/visit_confirmation_dialog.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
/// 알림 payload 데이터 모델
|
||||
class NotificationPayload {
|
||||
final String type;
|
||||
final String restaurantId;
|
||||
final String restaurantName;
|
||||
final DateTime recommendationTime;
|
||||
|
||||
NotificationPayload({
|
||||
required this.type,
|
||||
required this.restaurantId,
|
||||
required this.restaurantName,
|
||||
required this.recommendationTime,
|
||||
});
|
||||
|
||||
factory NotificationPayload.fromString(String payload) {
|
||||
try {
|
||||
final parts = payload.split('|');
|
||||
if (parts.length < 4) {
|
||||
throw FormatException('Invalid payload format - expected 4 parts but got ${parts.length}: $payload');
|
||||
}
|
||||
|
||||
// 각 필드 유효성 검증
|
||||
if (parts[0].isEmpty) {
|
||||
throw FormatException('Type cannot be empty');
|
||||
}
|
||||
if (parts[1].isEmpty) {
|
||||
throw FormatException('Restaurant ID cannot be empty');
|
||||
}
|
||||
if (parts[2].isEmpty) {
|
||||
throw FormatException('Restaurant name cannot be empty');
|
||||
}
|
||||
|
||||
// DateTime 파싱 시도
|
||||
DateTime? recommendationTime;
|
||||
try {
|
||||
recommendationTime = DateTime.parse(parts[3]);
|
||||
} catch (e) {
|
||||
throw FormatException('Invalid date format: ${parts[3]}. Error: $e');
|
||||
}
|
||||
|
||||
return NotificationPayload(
|
||||
type: parts[0],
|
||||
restaurantId: parts[1],
|
||||
restaurantName: parts[2],
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
} catch (e) {
|
||||
// 더 상세한 오류 정보 제공
|
||||
print('NotificationPayload parsing error: $e');
|
||||
print('Original payload: $payload');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
String toString() {
|
||||
return '$type|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림 핸들러 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 {
|
||||
if (payload == null || payload.isEmpty) {
|
||||
print('Notification payload is null or empty');
|
||||
return;
|
||||
}
|
||||
|
||||
print('Handling notification with payload: $payload');
|
||||
|
||||
try {
|
||||
// 기존 형식 (visit_reminder:restaurantName) 처리
|
||||
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'),
|
||||
);
|
||||
|
||||
// 방문 확인 다이얼로그 표시
|
||||
if (context.mounted) {
|
||||
await VisitConfirmationDialog.show(
|
||||
context: context,
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
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}');
|
||||
|
||||
if (notificationPayload.type == 'visit_reminder') {
|
||||
// 방문 확인 다이얼로그 표시
|
||||
if (context.mounted) {
|
||||
final confirmed = await VisitConfirmationDialog.show(
|
||||
context: context,
|
||||
restaurantId: notificationPayload.restaurantId,
|
||||
restaurantName: notificationPayload.restaurantName,
|
||||
recommendationTime: notificationPayload.recommendationTime,
|
||||
);
|
||||
|
||||
// 확인 또는 취소 후 캘린더 화면으로 이동
|
||||
if (context.mounted && confirmed != null) {
|
||||
context.go('/home?tab=calendar');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
print('Failed to parse new format, attempting fallback parsing');
|
||||
print('Parse error: $parseError');
|
||||
|
||||
// Fallback: 간단한 파싱 시도
|
||||
if (payload.contains('|')) {
|
||||
final parts = payload.split('|');
|
||||
if (parts.isNotEmpty && parts[0] == 'visit_reminder') {
|
||||
// 최소한 캘린더로 이동
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('알림을 처리했습니다. 방문 기록을 확인해주세요.'),
|
||||
),
|
||||
);
|
||||
context.go('/home?tab=calendar');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 파싱 실패 시 원래 에러 다시 발생
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print('Error handling notification: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
|
||||
// 에러 발생 시 기본적으로 캘린더 화면으로 이동
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('알림 처리 중 오류가 발생했습니다: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
context.go('/home?tab=calendar');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NotificationHandler Provider
|
||||
final notificationHandlerProvider = StateNotifierProvider<NotificationHandlerNotifier, AsyncValue<void>>((ref) {
|
||||
return NotificationHandlerNotifier(ref);
|
||||
});
|
||||
19
lib/presentation/providers/notification_provider.dart
Normal file
19
lib/presentation/providers/notification_provider.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/services/notification_service.dart';
|
||||
|
||||
/// NotificationService 싱글톤 Provider
|
||||
final notificationServiceProvider = Provider<NotificationService>((ref) {
|
||||
return NotificationService();
|
||||
});
|
||||
|
||||
/// 알림 권한 상태 Provider
|
||||
final notificationPermissionProvider = FutureProvider<bool>((ref) async {
|
||||
final service = ref.watch(notificationServiceProvider);
|
||||
return await service.checkPermission();
|
||||
});
|
||||
|
||||
/// 예약된 알림 목록 Provider
|
||||
final pendingNotificationsProvider = FutureProvider((ref) async {
|
||||
final service = ref.watch(notificationServiceProvider);
|
||||
return await service.getPendingNotifications();
|
||||
});
|
||||
341
lib/presentation/providers/recommendation_provider.dart
Normal file
341
lib/presentation/providers/recommendation_provider.dart
Normal file
@@ -0,0 +1,341 @@
|
||||
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();
|
||||
});
|
||||
|
||||
/// 추천 설정 모델
|
||||
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<void> getRandomRecommendation({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
// 현재 위치 가져오기
|
||||
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 config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
maxDistance: maxDistance,
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
final selectedRestaurant = await _recommendationEngine.generateRecommendation(
|
||||
allRestaurants: allRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
if (selectedRestaurant == null) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 추천 기록 저장
|
||||
await _saveRecommendationRecord(selectedRestaurant);
|
||||
|
||||
state = AsyncValue.data(selectedRestaurant);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 추천 기록 저장
|
||||
Future<void> _saveRecommendationRecord(Restaurant restaurant) async {
|
||||
final record = RecommendationRecord(
|
||||
id: const Uuid().v4(),
|
||||
restaurantId: restaurant.id,
|
||||
recommendationDate: DateTime.now(),
|
||||
visited: false,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _repository.addRecommendationRecord(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,
|
||||
);
|
||||
} 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;
|
||||
|
||||
// 현재 추천을 제외 목록에 추가
|
||||
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 {
|
||||
// 현재 위치 가져오기
|
||||
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(),
|
||||
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();
|
||||
});
|
||||
216
lib/presentation/providers/restaurant_provider.dart
Normal file
216
lib/presentation/providers/restaurant_provider.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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:uuid/uuid.dart';
|
||||
|
||||
/// 맛집 목록 Provider
|
||||
final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.watchRestaurants();
|
||||
});
|
||||
|
||||
/// 특정 맛집 Provider
|
||||
final restaurantProvider = FutureProvider.family<Restaurant?, String>((ref, id) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantById(id);
|
||||
});
|
||||
|
||||
/// 카테고리 목록 Provider
|
||||
final categoriesProvider = FutureProvider<List<String>>((ref) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getAllCategories();
|
||||
});
|
||||
|
||||
/// 맛집 관리 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 updated = 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: restaurant.lastVisitDate,
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _repository.updateRestaurant(updated);
|
||||
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 = 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.category.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 카테고리 필터 적용
|
||||
if (selectedCategory != null) {
|
||||
filtered = filtered.where((restaurant) {
|
||||
return restaurant.category == selectedCategory;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
yield filtered;
|
||||
}
|
||||
});
|
||||
264
lib/presentation/providers/settings_provider.dart
Normal file
264
lib/presentation/providers/settings_provider.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
import 'package:lunchpick/domain/entities/user_settings.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
|
||||
/// 재방문 금지 일수 Provider
|
||||
final daysToExcludeProvider = FutureProvider<int>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.getDaysToExclude();
|
||||
});
|
||||
|
||||
/// 우천시 최대 거리 Provider
|
||||
final maxDistanceRainyProvider = FutureProvider<int>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.getMaxDistanceRainy();
|
||||
});
|
||||
|
||||
/// 평상시 최대 거리 Provider
|
||||
final maxDistanceNormalProvider = FutureProvider<int>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.getMaxDistanceNormal();
|
||||
});
|
||||
|
||||
/// 알림 지연 시간 Provider
|
||||
final notificationDelayMinutesProvider = FutureProvider<int>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.getNotificationDelayMinutes();
|
||||
});
|
||||
|
||||
/// 알림 활성화 여부 Provider
|
||||
final notificationEnabledProvider = FutureProvider<bool>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.isNotificationEnabled();
|
||||
});
|
||||
|
||||
/// 다크모드 활성화 여부 Provider
|
||||
final darkModeEnabledProvider = FutureProvider<bool>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.isDarkModeEnabled();
|
||||
});
|
||||
|
||||
/// 첫 실행 여부 Provider
|
||||
final isFirstRunProvider = FutureProvider<bool>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.isFirstRun();
|
||||
});
|
||||
|
||||
/// 설정 스트림 Provider
|
||||
final settingsStreamProvider = StreamProvider<Map<String, dynamic>>((ref) {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.watchSettings();
|
||||
});
|
||||
|
||||
/// UserSettings Provider
|
||||
final userSettingsProvider = FutureProvider<UserSettings>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.getUserSettings();
|
||||
});
|
||||
|
||||
/// UserSettings 스트림 Provider
|
||||
final userSettingsStreamProvider = StreamProvider<UserSettings>((ref) {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.watchUserSettings();
|
||||
});
|
||||
|
||||
/// 설정 관리 StateNotifier
|
||||
class SettingsNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
final SettingsRepository _repository;
|
||||
|
||||
SettingsNotifier(this._repository) : super(const AsyncValue.data(null));
|
||||
|
||||
/// 재방문 금지 일수 설정
|
||||
Future<void> setDaysToExclude(int days) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setDaysToExclude(days);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 우천시 최대 거리 설정
|
||||
Future<void> setMaxDistanceRainy(int meters) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setMaxDistanceRainy(meters);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 평상시 최대 거리 설정
|
||||
Future<void> setMaxDistanceNormal(int meters) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setMaxDistanceNormal(meters);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림 지연 시간 설정
|
||||
Future<void> setNotificationDelayMinutes(int minutes) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setNotificationDelayMinutes(minutes);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림 활성화 설정
|
||||
Future<void> setNotificationEnabled(bool enabled) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setNotificationEnabled(enabled);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 다크모드 설정
|
||||
Future<void> setDarkModeEnabled(bool enabled) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setDarkModeEnabled(enabled);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 첫 실행 상태 업데이트
|
||||
Future<void> setFirstRun(bool isFirst) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setFirstRun(isFirst);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정 초기화
|
||||
Future<void> resetSettings() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.resetSettings();
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// UserSettings 업데이트
|
||||
Future<void> updateUserSettings(UserSettings settings) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.updateUserSettings(settings);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SettingsNotifier Provider
|
||||
final settingsNotifierProvider = StateNotifierProvider<SettingsNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return SettingsNotifier(repository);
|
||||
});
|
||||
|
||||
/// 설정 프리셋
|
||||
enum SettingsPreset {
|
||||
normal(
|
||||
name: '일반 모드',
|
||||
daysToExclude: 7,
|
||||
maxDistanceNormal: 1000,
|
||||
maxDistanceRainy: 500,
|
||||
),
|
||||
economic(
|
||||
name: '절약 모드',
|
||||
daysToExclude: 3,
|
||||
maxDistanceNormal: 500,
|
||||
maxDistanceRainy: 300,
|
||||
),
|
||||
convenience(
|
||||
name: '편의 모드',
|
||||
daysToExclude: 14,
|
||||
maxDistanceNormal: 2000,
|
||||
maxDistanceRainy: 1000,
|
||||
);
|
||||
|
||||
final String name;
|
||||
final int daysToExclude;
|
||||
final int maxDistanceNormal;
|
||||
final int maxDistanceRainy;
|
||||
|
||||
const SettingsPreset({
|
||||
required this.name,
|
||||
required this.daysToExclude,
|
||||
required this.maxDistanceNormal,
|
||||
required this.maxDistanceRainy,
|
||||
});
|
||||
}
|
||||
|
||||
/// 프리셋 적용 Provider
|
||||
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);
|
||||
|
||||
/// 선호 카테고리 Provider
|
||||
final preferredCategoriesProvider = StateProvider<List<String>>((ref) => []);
|
||||
|
||||
/// 제외 카테고리 Provider
|
||||
final excludedCategoriesProvider = StateProvider<List<String>>((ref) => []);
|
||||
|
||||
/// 언어 설정 Provider
|
||||
final languageProvider = StateProvider<String>((ref) => 'ko');
|
||||
|
||||
/// 위치 권한 상태 Provider
|
||||
final locationPermissionProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
/// 알림 권한 상태 Provider
|
||||
final notificationPermissionProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
/// 모든 설정 상태를 통합한 Provider
|
||||
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 darkMode = ref.watch(darkModeEnabledProvider).value ?? false;
|
||||
final currentLocation = ref.watch(currentLocationProvider);
|
||||
final preferredCategories = ref.watch(preferredCategoriesProvider);
|
||||
final excludedCategories = ref.watch(excludedCategoriesProvider);
|
||||
final language = ref.watch(languageProvider);
|
||||
|
||||
return {
|
||||
'daysToExclude': daysToExclude,
|
||||
'maxDistanceRainy': maxDistanceRainy,
|
||||
'maxDistanceNormal': maxDistanceNormal,
|
||||
'notificationDelayMinutes': notificationDelay,
|
||||
'notificationEnabled': notificationEnabled,
|
||||
'darkModeEnabled': darkMode,
|
||||
'currentLocation': currentLocation,
|
||||
'preferredCategories': preferredCategories,
|
||||
'excludedCategories': excludedCategories,
|
||||
'language': language,
|
||||
};
|
||||
});
|
||||
214
lib/presentation/providers/visit_provider.dart
Normal file
214
lib/presentation/providers/visit_provider.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 방문 기록 목록 Provider
|
||||
final visitRecordsProvider = StreamProvider<List<VisitRecord>>((ref) {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.watchVisitRecords();
|
||||
});
|
||||
|
||||
/// 날짜별 방문 기록 Provider
|
||||
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);
|
||||
});
|
||||
|
||||
/// 월별 방문 통계 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);
|
||||
});
|
||||
|
||||
/// 방문 기록 관리 StateNotifier
|
||||
class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
final VisitRepository _repository;
|
||||
final Ref _ref;
|
||||
|
||||
VisitNotifier(this._repository, this._ref) : super(const AsyncValue.data(null));
|
||||
|
||||
/// 방문 기록 추가
|
||||
Future<void> addVisitRecord({
|
||||
required String restaurantId,
|
||||
required DateTime visitDate,
|
||||
bool isConfirmed = false,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
final visitRecord = VisitRecord(
|
||||
id: const Uuid().v4(),
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitDate,
|
||||
isConfirmed: isConfirmed,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 방문 확인
|
||||
Future<void> confirmVisit(String visitRecordId) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.confirmVisit(visitRecordId);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 방문 기록 삭제
|
||||
Future<void> deleteVisitRecord(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.deleteVisitRecord(id);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 추천 후 자동 방문 기록 생성
|
||||
Future<void> createVisitFromRecommendation({
|
||||
required String restaurantId,
|
||||
required DateTime recommendationTime,
|
||||
}) async {
|
||||
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
|
||||
final visitTime = recommendationTime.add(const Duration(minutes: 90));
|
||||
|
||||
await addVisitRecord(
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitTime,
|
||||
isConfirmed: false, // 나중에 확인 필요
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// VisitNotifier Provider
|
||||
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 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));
|
||||
});
|
||||
|
||||
/// 주간 방문 통계 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 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;
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
/// 방문 기록 정렬 옵션
|
||||
enum VisitSortOption {
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
/// 카테고리별 방문 통계 Provider
|
||||
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;
|
||||
if (restaurant != null) {
|
||||
categoryCount[restaurant.category] = (categoryCount[restaurant.category] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return categoryCount;
|
||||
});
|
||||
92
lib/presentation/providers/weather_provider.dart
Normal file
92
lib/presentation/providers/weather_provider.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
|
||||
/// 현재 날씨 Provider
|
||||
final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
final location = await ref.watch(currentLocationProvider.future);
|
||||
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
// 캐시된 날씨 정보 확인
|
||||
final cached = await repository.getCachedWeather();
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 새로운 날씨 정보 가져오기
|
||||
return repository.getCurrentWeather(
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
);
|
||||
});
|
||||
|
||||
/// 날씨 업데이트 필요 여부 Provider
|
||||
final isWeatherUpdateNeededProvider = FutureProvider<bool>((ref) async {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
return repository.isWeatherUpdateNeeded();
|
||||
});
|
||||
|
||||
/// 날씨 관리 StateNotifier
|
||||
class WeatherNotifier extends StateNotifier<AsyncValue<WeatherInfo>> {
|
||||
final WeatherRepository _repository;
|
||||
final Ref _ref;
|
||||
|
||||
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) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
final weather = await _repository.getCurrentWeather(
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
);
|
||||
|
||||
state = AsyncValue.data(weather);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 캐시에서 날씨 정보 로드
|
||||
Future<void> loadCachedWeather() async {
|
||||
try {
|
||||
final cached = await _repository.getCachedWeather();
|
||||
if (cached != null) {
|
||||
state = AsyncValue.data(cached);
|
||||
} else {
|
||||
// 캐시가 없으면 새로 가져오기
|
||||
await refreshWeather();
|
||||
}
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 날씨 캐시 삭제
|
||||
Future<void> clearCache() async {
|
||||
try {
|
||||
await _repository.clearWeatherCache();
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WeatherNotifier Provider
|
||||
final weatherNotifierProvider = StateNotifierProvider<WeatherNotifier, AsyncValue<WeatherInfo>>((ref) {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
return WeatherNotifier(repository, ref);
|
||||
});
|
||||
Reference in New Issue
Block a user