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:
117
lib/data/repositories/recommendation_repository_impl.dart
Normal file
117
lib/data/repositories/recommendation_repository_impl.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
|
||||
class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
static const String _boxName = 'recommendations';
|
||||
|
||||
Future<Box<RecommendationRecord>> get _box async =>
|
||||
await Hive.openBox<RecommendationRecord>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getAllRecommendationRecords() async {
|
||||
final box = await _box;
|
||||
final records = box.values.toList();
|
||||
records.sort((a, b) => b.recommendationDate.compareTo(a.recommendationDate));
|
||||
return records;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(DateTime date) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.year == date.year &&
|
||||
record.recommendationDate.month == date.month &&
|
||||
record.recommendationDate.day == date.day;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
record.recommendationDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addRecommendationRecord(RecommendationRecord record) async {
|
||||
final box = await _box;
|
||||
await box.put(record.id, record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRecommendationRecord(RecommendationRecord record) async {
|
||||
final box = await _box;
|
||||
await box.put(record.id, record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRecommendationRecord(String id) async {
|
||||
final box = await _box;
|
||||
await box.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markAsVisited(String recommendationId) async {
|
||||
final box = await _box;
|
||||
final record = box.get(recommendationId);
|
||||
if (record != null) {
|
||||
final updatedRecord = RecommendationRecord(
|
||||
id: record.id,
|
||||
restaurantId: record.restaurantId,
|
||||
recommendationDate: record.recommendationDate,
|
||||
visited: true,
|
||||
createdAt: record.createdAt,
|
||||
);
|
||||
await updateRecommendationRecord(updatedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getTodayRecommendationCount() async {
|
||||
final today = DateTime.now();
|
||||
final todayRecords = await getRecommendationsByDate(today);
|
||||
return todayRecords.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<RecommendationRecord>> watchRecommendationRecords() async* {
|
||||
final box = await _box;
|
||||
try {
|
||||
yield await getAllRecommendationRecords();
|
||||
} catch (_) {
|
||||
yield <RecommendationRecord>[];
|
||||
}
|
||||
yield* box.watch().asyncMap((_) async => await getAllRecommendationRecords());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month) async {
|
||||
final startDate = DateTime(year, month, 1);
|
||||
final endDate = DateTime(year, month + 1, 0); // 해당 월의 마지막 날
|
||||
|
||||
final records = await getRecommendationsByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
|
||||
final stats = <String, int>{};
|
||||
for (final record in records) {
|
||||
final dayKey = record.recommendationDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
254
lib/data/repositories/restaurant_repository_impl.dart
Normal file
254
lib/data/repositories/restaurant_repository_impl.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
|
||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
static const String _boxName = 'restaurants';
|
||||
final NaverSearchService _naverSearchService;
|
||||
|
||||
RestaurantRepositoryImpl({
|
||||
NaverSearchService? naverSearchService,
|
||||
}) : _naverSearchService = naverSearchService ?? NaverSearchService();
|
||||
|
||||
Future<Box<Restaurant>> get _box async =>
|
||||
await Hive.openBox<Restaurant>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getAllRestaurants() async {
|
||||
final box = await _box;
|
||||
return box.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant?> getRestaurantById(String id) async {
|
||||
final box = await _box;
|
||||
return box.get(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addRestaurant(Restaurant restaurant) async {
|
||||
final box = await _box;
|
||||
await box.put(restaurant.id, restaurant);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRestaurant(Restaurant restaurant) async {
|
||||
final box = await _box;
|
||||
await box.put(restaurant.id, restaurant);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRestaurant(String id) async {
|
||||
final box = await _box;
|
||||
await box.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getRestaurantsByCategory(String category) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
return restaurants.where((r) => r.category == category).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getAllCategories() async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
final categories = restaurants.map((r) => r.category).toSet().toList();
|
||||
categories.sort();
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<Restaurant>> watchRestaurants() async* {
|
||||
final box = await _box;
|
||||
yield box.values.toList();
|
||||
yield* box.watch().map((_) => box.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate) async {
|
||||
final restaurant = await getRestaurantById(restaurantId);
|
||||
if (restaurant != null) {
|
||||
final updatedRestaurant = 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: visitDate,
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: restaurant.businessHours,
|
||||
lastVisited: visitDate,
|
||||
visitCount: restaurant.visitCount + 1,
|
||||
);
|
||||
await updateRestaurant(updatedRestaurant);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getRestaurantsWithinDistance({
|
||||
required double userLatitude,
|
||||
required double userLongitude,
|
||||
required double maxDistanceInMeters,
|
||||
}) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
return restaurants.where((restaurant) {
|
||||
final distanceInKm = DistanceCalculator.calculateDistance(
|
||||
lat1: userLatitude,
|
||||
lon1: userLongitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceInMeters = distanceInKm * 1000;
|
||||
return distanceInMeters <= maxDistanceInMeters;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> getRestaurantsNotVisitedInDays(int days) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
final cutoffDate = DateTime.now().subtract(Duration(days: days));
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
if (restaurant.lastVisitDate == null) return true;
|
||||
return restaurant.lastVisitDate!.isBefore(cutoffDate);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> searchRestaurants(String query) async {
|
||||
if (query.isEmpty) {
|
||||
return await getAllRestaurants();
|
||||
}
|
||||
|
||||
final restaurants = await getAllRestaurants();
|
||||
final lowercaseQuery = query.toLowerCase();
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
return restaurant.name.toLowerCase().contains(lowercaseQuery) ||
|
||||
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ?? false) ||
|
||||
restaurant.category.toLowerCase().contains(lowercaseQuery) ||
|
||||
restaurant.roadAddress.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant> addRestaurantFromUrl(String url) async {
|
||||
try {
|
||||
// URL 유효성 검증
|
||||
if (!url.contains('naver.com') && !url.contains('naver.me')) {
|
||||
throw Exception('유효하지 않은 네이버 지도 URL입니다.');
|
||||
}
|
||||
|
||||
// NaverSearchService로 식당 정보 추출
|
||||
Restaurant restaurant = await _naverSearchService.getRestaurantFromUrl(url);
|
||||
|
||||
// API 키가 설정되어 있으면 추가 정보 검색
|
||||
if (ApiKeys.areKeysConfigured() && restaurant.name != '네이버 지도 장소') {
|
||||
try {
|
||||
final detailedRestaurant = await _naverSearchService.searchRestaurantDetails(
|
||||
name: restaurant.name,
|
||||
address: restaurant.roadAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
);
|
||||
|
||||
if (detailedRestaurant != null) {
|
||||
// 기존 정보와 API 검색 결과 병합
|
||||
restaurant = Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: detailedRestaurant.category,
|
||||
subCategory: detailedRestaurant.subCategory,
|
||||
description: detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: detailedRestaurant.phoneNumber ?? restaurant.phoneNumber,
|
||||
roadAddress: detailedRestaurant.roadAddress,
|
||||
jibunAddress: detailedRestaurant.jibunAddress,
|
||||
latitude: detailedRestaurant.latitude,
|
||||
longitude: detailedRestaurant.longitude,
|
||||
lastVisitDate: restaurant.lastVisitDate,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 체크 - Place ID가 있는 경우
|
||||
if (restaurant.naverPlaceId != null) {
|
||||
final existingRestaurant = await getRestaurantByNaverPlaceId(restaurant.naverPlaceId!);
|
||||
if (existingRestaurant != null) {
|
||||
throw Exception('이미 등록된 맛집입니다: ${existingRestaurant.name}');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 체크 - 이름과 주소로 추가 확인
|
||||
final restaurants = await getAllRestaurants();
|
||||
final duplicate = restaurants.firstWhere(
|
||||
(r) => r.name == restaurant.name &&
|
||||
(r.roadAddress == restaurant.roadAddress ||
|
||||
r.jibunAddress == restaurant.jibunAddress),
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
category: '',
|
||||
subCategory: '',
|
||||
roadAddress: '',
|
||||
jibunAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (duplicate.id.isNotEmpty) {
|
||||
throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${duplicate.name}');
|
||||
}
|
||||
|
||||
// 새 맛집 추가
|
||||
await addRestaurant(restaurant);
|
||||
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
throw Exception('네이버 지도 파싱 실패: ${e.message}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
try {
|
||||
return restaurants.firstWhere(
|
||||
(r) => r.naverPlaceId == naverPlaceId,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
204
lib/data/repositories/settings_repository_impl.dart
Normal file
204
lib/data/repositories/settings_repository_impl.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
import 'package:lunchpick/domain/entities/user_settings.dart';
|
||||
|
||||
class SettingsRepositoryImpl implements SettingsRepository {
|
||||
static const String _boxName = 'settings';
|
||||
|
||||
// Setting keys
|
||||
static const String _keyDaysToExclude = 'days_to_exclude';
|
||||
static const String _keyMaxDistanceRainy = 'max_distance_rainy';
|
||||
static const String _keyMaxDistanceNormal = 'max_distance_normal';
|
||||
static const String _keyNotificationDelayMinutes = 'notification_delay_minutes';
|
||||
static const String _keyNotificationEnabled = 'notification_enabled';
|
||||
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
||||
static const String _keyFirstRun = 'first_run';
|
||||
static const String _keyCategoryWeights = 'category_weights';
|
||||
|
||||
// Default values
|
||||
static const int _defaultDaysToExclude = 7;
|
||||
static const int _defaultMaxDistanceRainy = 500;
|
||||
static const int _defaultMaxDistanceNormal = 1000;
|
||||
static const int _defaultNotificationDelayMinutes = 90;
|
||||
static const bool _defaultNotificationEnabled = true;
|
||||
static const bool _defaultDarkModeEnabled = false;
|
||||
static const bool _defaultFirstRun = true;
|
||||
|
||||
Future<Box> get _box async => await Hive.openBox(_boxName);
|
||||
|
||||
@override
|
||||
Future<UserSettings> getUserSettings() async {
|
||||
final box = await _box;
|
||||
|
||||
// 저장된 설정값들을 읽어옴
|
||||
final revisitPreventionDays = box.get(_keyDaysToExclude, defaultValue: _defaultDaysToExclude);
|
||||
final notificationEnabled = box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
final notificationDelayMinutes = box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
|
||||
// 카테고리 가중치 읽기 (Map<String, double>으로 저장됨)
|
||||
final categoryWeightsData = box.get(_keyCategoryWeights);
|
||||
Map<String, double> categoryWeights = {};
|
||||
if (categoryWeightsData != null) {
|
||||
categoryWeights = Map<String, double>.from(categoryWeightsData);
|
||||
}
|
||||
|
||||
// 알림 시간은 분을 시간:분 형식으로 변환
|
||||
final hours = notificationDelayMinutes ~/ 60;
|
||||
final minutes = notificationDelayMinutes % 60;
|
||||
final notificationTime = '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}';
|
||||
|
||||
return UserSettings(
|
||||
revisitPreventionDays: revisitPreventionDays,
|
||||
notificationEnabled: notificationEnabled,
|
||||
notificationTime: notificationTime,
|
||||
categoryWeights: categoryWeights,
|
||||
notificationDelayMinutes: notificationDelayMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateUserSettings(UserSettings settings) async {
|
||||
final box = await _box;
|
||||
|
||||
// 각 설정값 저장
|
||||
await box.put(_keyDaysToExclude, settings.revisitPreventionDays);
|
||||
await box.put(_keyNotificationEnabled, settings.notificationEnabled);
|
||||
await box.put(_keyNotificationDelayMinutes, settings.notificationDelayMinutes);
|
||||
|
||||
// 카테고리 가중치 저장
|
||||
await box.put(_keyCategoryWeights, settings.categoryWeights);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getDaysToExclude() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyDaysToExclude, defaultValue: _defaultDaysToExclude);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDaysToExclude(int days) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyDaysToExclude, days);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getMaxDistanceRainy() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceRainy, defaultValue: _defaultMaxDistanceRainy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMaxDistanceRainy(int meters) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyMaxDistanceRainy, meters);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getMaxDistanceNormal() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceNormal, defaultValue: _defaultMaxDistanceNormal);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMaxDistanceNormal(int meters) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyMaxDistanceNormal, meters);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getNotificationDelayMinutes() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setNotificationDelayMinutes(int minutes) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyNotificationDelayMinutes, minutes);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isNotificationEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setNotificationEnabled(bool enabled) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyNotificationEnabled, enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isDarkModeEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyDarkModeEnabled, defaultValue: _defaultDarkModeEnabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDarkModeEnabled(bool enabled) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyDarkModeEnabled, enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isFirstRun() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyFirstRun, defaultValue: _defaultFirstRun);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFirstRun(bool isFirst) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyFirstRun, isFirst);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resetSettings() async {
|
||||
final box = await _box;
|
||||
await box.clear();
|
||||
|
||||
// 기본값으로 재설정
|
||||
await box.put(_keyDaysToExclude, _defaultDaysToExclude);
|
||||
await box.put(_keyMaxDistanceRainy, _defaultMaxDistanceRainy);
|
||||
await box.put(_keyMaxDistanceNormal, _defaultMaxDistanceNormal);
|
||||
await box.put(_keyNotificationDelayMinutes, _defaultNotificationDelayMinutes);
|
||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> watchSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
// 초기 값 전송
|
||||
yield await _getCurrentSettings();
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await _getCurrentSettings());
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _getCurrentSettings() async {
|
||||
return {
|
||||
_keyDaysToExclude: await getDaysToExclude(),
|
||||
_keyMaxDistanceRainy: await getMaxDistanceRainy(),
|
||||
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
||||
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
||||
_keyNotificationEnabled: await isNotificationEnabled(),
|
||||
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
||||
_keyFirstRun: await isFirstRun(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<UserSettings> watchUserSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
// 초기 값 전송
|
||||
yield await getUserSettings();
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await getUserSettings());
|
||||
}
|
||||
}
|
||||
127
lib/data/repositories/visit_repository_impl.dart
Normal file
127
lib/data/repositories/visit_repository_impl.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
|
||||
class VisitRepositoryImpl implements VisitRepository {
|
||||
static const String _boxName = 'visit_records';
|
||||
|
||||
Future<Box<VisitRecord>> get _box async =>
|
||||
await Hive.openBox<VisitRecord>(_boxName);
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getAllVisitRecords() async {
|
||||
final box = await _box;
|
||||
final records = box.values.toList();
|
||||
records.sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
return records;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(String restaurantId) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByDate(DateTime date) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((record) {
|
||||
return record.visitDate.year == date.year &&
|
||||
record.visitDate.month == date.month &&
|
||||
record.visitDate.day == date.day;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((record) {
|
||||
return record.visitDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
record.visitDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addVisitRecord(VisitRecord visitRecord) async {
|
||||
final box = await _box;
|
||||
await box.put(visitRecord.id, visitRecord);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVisitRecord(VisitRecord visitRecord) async {
|
||||
final box = await _box;
|
||||
await box.put(visitRecord.id, visitRecord);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteVisitRecord(String id) async {
|
||||
final box = await _box;
|
||||
await box.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> confirmVisit(String visitRecordId) async {
|
||||
final box = await _box;
|
||||
final record = box.get(visitRecordId);
|
||||
if (record != null) {
|
||||
final updatedRecord = VisitRecord(
|
||||
id: record.id,
|
||||
restaurantId: record.restaurantId,
|
||||
visitDate: record.visitDate,
|
||||
isConfirmed: true,
|
||||
createdAt: record.createdAt,
|
||||
);
|
||||
await updateVisitRecord(updatedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<VisitRecord>> watchVisitRecords() async* {
|
||||
final box = await _box;
|
||||
try {
|
||||
yield await getAllVisitRecords();
|
||||
} catch (_) {
|
||||
yield <VisitRecord>[];
|
||||
}
|
||||
yield* box.watch().asyncMap((_) async => await getAllVisitRecords());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> getLastVisitDate(String restaurantId) async {
|
||||
final records = await getVisitRecordsByRestaurantId(restaurantId);
|
||||
if (records.isEmpty) return null;
|
||||
|
||||
// 이미 visitDate 기준으로 정렬되어 있으므로 첫 번째가 가장 최근
|
||||
return records.first.visitDate;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getMonthlyVisitStats(int year, int month) async {
|
||||
final startDate = DateTime(year, month, 1);
|
||||
final endDate = DateTime(year, month + 1, 0); // 해당 월의 마지막 날
|
||||
|
||||
final records = await getVisitRecordsByDateRange(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
|
||||
final stats = <String, int>{};
|
||||
for (final record in records) {
|
||||
final dayKey = record.visitDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getCategoryVisitStats() async {
|
||||
// 이 메서드는 RestaurantRepository와 연동이 필요하므로
|
||||
// 실제 구현은 UseCase나 Provider 레벨에서 처리
|
||||
// 여기서는 빈 Map 반환
|
||||
return {};
|
||||
}
|
||||
}
|
||||
194
lib/data/repositories/weather_repository_impl.dart
Normal file
194
lib/data/repositories/weather_repository_impl.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
|
||||
class WeatherRepositoryImpl implements WeatherRepository {
|
||||
static const String _boxName = 'weather_cache';
|
||||
static const String _keyCachedWeather = 'cached_weather';
|
||||
static const String _keyLastUpdateTime = 'last_update_time';
|
||||
static const Duration _cacheValidDuration = Duration(hours: 1);
|
||||
|
||||
Future<Box> get _box async => await Hive.openBox(_boxName);
|
||||
|
||||
@override
|
||||
Future<WeatherInfo> getCurrentWeather({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: 22,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
);
|
||||
|
||||
// 캐시에 저장
|
||||
await cacheWeatherInfo(dummyWeather);
|
||||
|
||||
return dummyWeather;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WeatherInfo?> getCachedWeather() async {
|
||||
final box = await _box;
|
||||
|
||||
// 캐시가 유효한지 확인
|
||||
final isValid = await _isCacheValid();
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 캐시된 데이터 가져오기
|
||||
final cachedData = box.get(_keyCachedWeather);
|
||||
if (cachedData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print('WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> weatherMap = Map<String, dynamic>.from(cachedData);
|
||||
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') || !weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
print('WeatherCache: Error parsing cached weather data: $e');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo) async {
|
||||
final box = await _box;
|
||||
|
||||
// WeatherInfo를 Map으로 변환하여 저장
|
||||
final weatherMap = _weatherInfoToMap(weatherInfo);
|
||||
await box.put(_keyCachedWeather, weatherMap);
|
||||
await box.put(_keyLastUpdateTime, DateTime.now().toIso8601String());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearWeatherCache() async {
|
||||
final box = await _box;
|
||||
await box.delete(_keyCachedWeather);
|
||||
await box.delete(_keyLastUpdateTime);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isWeatherUpdateNeeded() async {
|
||||
final box = await _box;
|
||||
|
||||
// 캐시된 날씨 정보가 없으면 업데이트 필요
|
||||
if (!box.containsKey(_keyCachedWeather)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 캐시가 유효한지 확인
|
||||
return !(await _isCacheValid());
|
||||
}
|
||||
|
||||
Future<bool> _isCacheValid() async {
|
||||
final box = await _box;
|
||||
|
||||
final lastUpdateTimeStr = box.get(_keyLastUpdateTime);
|
||||
if (lastUpdateTimeStr == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 날짜 파싱 시도
|
||||
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
||||
if (lastUpdateTime == null) {
|
||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
||||
return false;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastUpdateTime);
|
||||
|
||||
return difference < _cacheValidDuration;
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error checking cache validity: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _weatherInfoToMap(WeatherInfo weatherInfo) {
|
||||
return {
|
||||
'current': {
|
||||
'temperature': weatherInfo.current.temperature,
|
||||
'isRainy': weatherInfo.current.isRainy,
|
||||
'description': weatherInfo.current.description,
|
||||
},
|
||||
'nextHour': {
|
||||
'temperature': weatherInfo.nextHour.temperature,
|
||||
'isRainy': weatherInfo.nextHour.isRainy,
|
||||
'description': weatherInfo.nextHour.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
|
||||
try {
|
||||
// current 필드 검증
|
||||
final currentMap = map['current'] as Map<String, dynamic>?;
|
||||
if (currentMap == null) {
|
||||
throw FormatException('Missing current weather data');
|
||||
}
|
||||
|
||||
// nextHour 필드 검증
|
||||
final nextHourMap = map['nextHour'] as Map<String, dynamic>?;
|
||||
if (nextHourMap == null) {
|
||||
throw FormatException('Missing nextHour weather data');
|
||||
}
|
||||
|
||||
// 필수 필드 검증 및 기본값 제공
|
||||
final currentTemp = currentMap['temperature'] as num? ?? 20;
|
||||
final currentRainy = currentMap['isRainy'] as bool? ?? false;
|
||||
final currentDesc = currentMap['description'] as String? ?? '알 수 없음';
|
||||
|
||||
final nextTemp = nextHourMap['temperature'] as num? ?? 20;
|
||||
final nextRainy = nextHourMap['isRainy'] as bool? ?? false;
|
||||
final nextDesc = nextHourMap['description'] as String? ?? '알 수 없음';
|
||||
|
||||
return WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: currentTemp.round(),
|
||||
isRainy: currentRainy,
|
||||
description: currentDesc,
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: nextTemp.round(),
|
||||
isRainy: nextRainy,
|
||||
description: nextDesc,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error converting map to WeatherInfo: $e');
|
||||
print('WeatherCache: Map data: $map');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user