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:
27
lib/core/constants/app_colors.dart
Normal file
27
lib/core/constants/app_colors.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
// Light Theme Colors
|
||||
static const lightPrimary = Color(0xFF03C75A); // 네이버 그린
|
||||
static const lightSecondary = Color(0xFF00BF63);
|
||||
static const lightBackground = Color(0xFFF5F5F5);
|
||||
static const lightSurface = Colors.white;
|
||||
static const lightTextPrimary = Color(0xFF222222);
|
||||
static const lightTextSecondary = Color(0xFF767676);
|
||||
static const lightDivider = Color(0xFFE5E5E5);
|
||||
static const lightError = Color(0xFFFF5252);
|
||||
static const lightText = Color(0xFF222222); // 추가
|
||||
static const lightCard = Colors.white; // 추가
|
||||
|
||||
// Dark Theme Colors
|
||||
static const darkPrimary = Color(0xFF03C75A);
|
||||
static const darkSecondary = Color(0xFF00BF63);
|
||||
static const darkBackground = Color(0xFF121212);
|
||||
static const darkSurface = Color(0xFF1E1E1E);
|
||||
static const darkTextPrimary = Color(0xFFFFFFFF);
|
||||
static const darkTextSecondary = Color(0xFFB3B3B3);
|
||||
static const darkDivider = Color(0xFF2C2C2C);
|
||||
static const darkError = Color(0xFFFF5252);
|
||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||
}
|
||||
44
lib/core/constants/app_constants.dart
Normal file
44
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
class AppConstants {
|
||||
// App Info
|
||||
static const String appName = '오늘 뭐 먹Z?';
|
||||
static const String appDescription = '점심 메뉴 추천 앱';
|
||||
static const String appVersion = '1.0.0';
|
||||
static const String appCopyright = '© 2025. NatureBridgeAI. All rights reserved.';
|
||||
|
||||
// Animation Durations
|
||||
static const Duration splashAnimationDuration = Duration(seconds: 3);
|
||||
static const Duration defaultAnimationDuration = Duration(milliseconds: 300);
|
||||
|
||||
// API Keys (These should be moved to .env in production)
|
||||
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
||||
static const String weatherApiKey = 'YOUR_WEATHER_API_KEY';
|
||||
|
||||
// AdMob IDs (Test IDs - Replace with real IDs in production)
|
||||
static const String androidAdAppId = 'ca-app-pub-3940256099942544~3347511713';
|
||||
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
||||
static const String interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712';
|
||||
|
||||
// Hive Box Names
|
||||
static const String restaurantBox = 'restaurants';
|
||||
static const String visitRecordBox = 'visit_records';
|
||||
static const String recommendationBox = 'recommendations';
|
||||
static const String settingsBox = 'settings';
|
||||
|
||||
// Default Settings
|
||||
static const int defaultDaysToExclude = 7;
|
||||
static const int defaultNotificationMinutes = 90;
|
||||
static const int defaultMaxDistanceNormal = 1000; // meters
|
||||
static const int defaultMaxDistanceRainy = 500; // meters
|
||||
|
||||
// Categories
|
||||
static const List<String> foodCategories = [
|
||||
'한식',
|
||||
'중식',
|
||||
'일식',
|
||||
'양식',
|
||||
'분식',
|
||||
'카페',
|
||||
'패스트푸드',
|
||||
'기타',
|
||||
];
|
||||
}
|
||||
34
lib/core/constants/app_typography.dart
Normal file
34
lib/core/constants/app_typography.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTypography {
|
||||
static TextStyle heading1(bool isDark) => TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
|
||||
);
|
||||
|
||||
static TextStyle heading2(bool isDark) => TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
|
||||
);
|
||||
|
||||
static TextStyle body1(bool isDark) => TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
|
||||
);
|
||||
|
||||
static TextStyle body2(bool isDark) => TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
);
|
||||
|
||||
static TextStyle caption(bool isDark) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
);
|
||||
}
|
||||
178
lib/core/errors/app_exceptions.dart
Normal file
178
lib/core/errors/app_exceptions.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
/// 애플리케이션 전체 예외 클래스들
|
||||
///
|
||||
/// 각 레이어별로 명확한 예외 계층 구조를 제공합니다.
|
||||
|
||||
/// 앱 예외 기본 클래스
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const AppException({
|
||||
required this.message,
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// 비즈니스 로직 예외
|
||||
class BusinessException extends AppException {
|
||||
const BusinessException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 검증 예외
|
||||
class ValidationException extends AppException {
|
||||
final Map<String, String>? fieldErrors;
|
||||
|
||||
const ValidationException({
|
||||
required String message,
|
||||
this.fieldErrors,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
if (fieldErrors != null && fieldErrors!.isNotEmpty) {
|
||||
final errors = fieldErrors!.entries
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.join(', ');
|
||||
return '$base [필드 오류: $errors]';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 예외
|
||||
class DataException extends AppException {
|
||||
const DataException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 저장소 예외
|
||||
class StorageException extends DataException {
|
||||
const StorageException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 권한 예외
|
||||
class PermissionException extends AppException {
|
||||
final String permission;
|
||||
|
||||
const PermissionException({
|
||||
required String message,
|
||||
required this.permission,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message (권한: $permission)';
|
||||
}
|
||||
|
||||
/// 위치 서비스 예외
|
||||
class LocationException extends AppException {
|
||||
const LocationException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 설정 예외
|
||||
class ConfigurationException extends AppException {
|
||||
const ConfigurationException({
|
||||
required String message,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// UI 예외
|
||||
class UIException extends AppException {
|
||||
const UIException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 리소스를 찾을 수 없음 예외
|
||||
class NotFoundException extends AppException {
|
||||
final String resourceType;
|
||||
final dynamic resourceId;
|
||||
|
||||
const NotFoundException({
|
||||
required this.resourceType,
|
||||
required this.resourceId,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '$resourceType을(를) 찾을 수 없습니다 (ID: $resourceId)',
|
||||
code: 'NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
/// 중복 리소스 예외
|
||||
class DuplicateException extends AppException {
|
||||
final String resourceType;
|
||||
|
||||
const DuplicateException({
|
||||
required this.resourceType,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '이미 존재하는 $resourceType입니다',
|
||||
code: 'DUPLICATE',
|
||||
);
|
||||
}
|
||||
|
||||
/// 추천 엔진 예외
|
||||
class RecommendationException extends BusinessException {
|
||||
const RecommendationException({
|
||||
required String message,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// 알림 예외
|
||||
class NotificationException extends AppException {
|
||||
const NotificationException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
149
lib/core/errors/data_exceptions.dart
Normal file
149
lib/core/errors/data_exceptions.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
/// 데이터 레이어 예외 클래스들
|
||||
///
|
||||
/// API, 데이터베이스, 파싱 관련 예외를 정의합니다.
|
||||
|
||||
import 'app_exceptions.dart';
|
||||
|
||||
/// API 예외 기본 클래스
|
||||
abstract class ApiException extends DataException {
|
||||
final int? statusCode;
|
||||
|
||||
const ApiException({
|
||||
required String message,
|
||||
this.statusCode,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 네이버 API 예외
|
||||
class NaverApiException extends ApiException {
|
||||
const NaverApiException({
|
||||
required String message,
|
||||
int? statusCode,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// HTML 파싱 예외
|
||||
class HtmlParsingException extends DataException {
|
||||
final String? url;
|
||||
|
||||
const HtmlParsingException({
|
||||
required String message,
|
||||
this.url,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: 'HTML_PARSE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
return url != null ? '$base (URL: $url)' : base;
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 변환 예외
|
||||
class DataConversionException extends DataException {
|
||||
final String fromType;
|
||||
final String toType;
|
||||
|
||||
const DataConversionException({
|
||||
required String message,
|
||||
required this.fromType,
|
||||
required this.toType,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: 'DATA_CONVERSION_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message ($fromType → $toType)';
|
||||
}
|
||||
|
||||
/// 캐시 예외
|
||||
class CacheException extends StorageException {
|
||||
const CacheException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'CACHE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hive 예외
|
||||
class HiveException extends StorageException {
|
||||
const HiveException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'HIVE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// URL 처리 예외
|
||||
class UrlProcessingException extends DataException {
|
||||
final String url;
|
||||
|
||||
const UrlProcessingException({
|
||||
required String message,
|
||||
required this.url,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'URL_PROCESSING_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message (URL: $url)';
|
||||
}
|
||||
|
||||
/// 잘못된 URL 형식 예외
|
||||
class InvalidUrlException extends UrlProcessingException {
|
||||
const InvalidUrlException({
|
||||
required String url,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '올바르지 않은 URL 형식입니다',
|
||||
url: url,
|
||||
code: 'INVALID_URL',
|
||||
);
|
||||
}
|
||||
|
||||
/// 지원하지 않는 URL 예외
|
||||
class UnsupportedUrlException extends UrlProcessingException {
|
||||
const UnsupportedUrlException({
|
||||
required String url,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '지원하지 않는 URL입니다',
|
||||
url: url,
|
||||
code: 'UNSUPPORTED_URL',
|
||||
);
|
||||
}
|
||||
108
lib/core/errors/network_exceptions.dart
Normal file
108
lib/core/errors/network_exceptions.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
/// 네트워크 관련 예외 클래스들
|
||||
///
|
||||
/// 모든 네트워크 오류를 명확하게 분류하고 처리합니다.
|
||||
|
||||
/// 네트워크 예외 기본 클래스
|
||||
abstract class NetworkException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final dynamic originalError;
|
||||
|
||||
const NetworkException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 연결 타임아웃 예외
|
||||
class ConnectionTimeoutException extends NetworkException {
|
||||
const ConnectionTimeoutException({
|
||||
String message = '서버 연결 시간이 초과되었습니다',
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 네트워크 연결 없음 예외
|
||||
class NoInternetException extends NetworkException {
|
||||
const NoInternetException({
|
||||
String message = '인터넷 연결을 확인해주세요',
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 서버 오류 예외 (5xx)
|
||||
class ServerException extends NetworkException {
|
||||
const ServerException({
|
||||
required String message,
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 클라이언트 오류 예외 (4xx)
|
||||
class ClientException extends NetworkException {
|
||||
const ClientException({
|
||||
required String message,
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 파싱 오류 예외
|
||||
class ParseException extends NetworkException {
|
||||
const ParseException({
|
||||
required String message,
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// API 키 오류 예외
|
||||
class ApiKeyException extends NetworkException {
|
||||
const ApiKeyException({
|
||||
String message = 'API 키가 설정되지 않았습니다',
|
||||
}) : super(message: message);
|
||||
}
|
||||
|
||||
/// 재시도 횟수 초과 예외
|
||||
class MaxRetriesExceededException extends NetworkException {
|
||||
const MaxRetriesExceededException({
|
||||
String message = '최대 재시도 횟수를 초과했습니다',
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// Rate Limit (429) 예외
|
||||
class RateLimitException extends NetworkException {
|
||||
final String? retryAfter;
|
||||
|
||||
const RateLimitException({
|
||||
String message = '너무 많은 요청으로 인해 차단되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
this.retryAfter,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: 429,
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
if (retryAfter != null) {
|
||||
return '$base (재시도 가능: $retryAfter초 후)';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
172
lib/core/network/README.md
Normal file
172
lib/core/network/README.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 네트워크 모듈 사용 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
이 네트워크 모듈은 네이버 단축 URL 처리와 로컬 API 검색을 위한 통합 솔루션을 제공합니다. Dio 기반으로 구축되어 재시도, 캐싱, 로깅 등의 기능을 제공합니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
1. **네이버 단축 URL 리다이렉션 처리**
|
||||
2. **HTML 스크래핑으로 식당 정보 추출**
|
||||
3. **네이버 로컬 검색 API 통합**
|
||||
4. **자동 재시도 및 에러 처리**
|
||||
5. **응답 캐싱으로 성능 최적화**
|
||||
6. **네트워크 불안정 상황 대응**
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. 네이버 지도 URL에서 식당 정보 추출
|
||||
|
||||
```dart
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
|
||||
final searchService = NaverSearchService();
|
||||
|
||||
try {
|
||||
// 일반 네이버 지도 URL
|
||||
final restaurant = await searchService.getRestaurantFromUrl(
|
||||
'https://map.naver.com/p/restaurant/1234567890',
|
||||
);
|
||||
|
||||
// 단축 URL도 자동 처리
|
||||
final restaurant2 = await searchService.getRestaurantFromUrl(
|
||||
'https://naver.me/abc123',
|
||||
);
|
||||
|
||||
print('식당명: ${restaurant.name}');
|
||||
print('카테고리: ${restaurant.category}');
|
||||
print('주소: ${restaurant.roadAddress}');
|
||||
} catch (e) {
|
||||
print('오류 발생: $e');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 키워드로 주변 식당 검색
|
||||
|
||||
```dart
|
||||
// 현재 위치 기반 검색
|
||||
final restaurants = await searchService.searchNearbyRestaurants(
|
||||
query: '파스타',
|
||||
latitude: 37.5666805,
|
||||
longitude: 126.9784147,
|
||||
maxResults: 20,
|
||||
sort: 'random', // 정확도순 정렬 (기본값)
|
||||
);
|
||||
|
||||
for (final restaurant in restaurants) {
|
||||
print('${restaurant.name} - ${restaurant.roadAddress}');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 식당 상세 정보 검색
|
||||
|
||||
```dart
|
||||
// 식당 이름과 주소로 상세 정보 검색
|
||||
final details = await searchService.searchRestaurantDetails(
|
||||
name: '맛있는 한식당',
|
||||
address: '서울 중구 세종대로',
|
||||
latitude: 37.5666805,
|
||||
longitude: 126.9784147,
|
||||
);
|
||||
|
||||
if (details != null) {
|
||||
print('영업시간: ${details.businessHours}');
|
||||
print('전화번호: ${details.phoneNumber}');
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 네트워크 에러 처리
|
||||
|
||||
```dart
|
||||
import 'package:lunchpick/core/errors/network_exceptions.dart';
|
||||
|
||||
try {
|
||||
final restaurant = await searchService.getRestaurantFromUrl(url);
|
||||
} on ConnectionTimeoutException {
|
||||
// 연결 타임아웃
|
||||
showSnackBar('네트워크 연결이 느립니다. 다시 시도해주세요.');
|
||||
} on NoInternetException {
|
||||
// 인터넷 연결 없음
|
||||
showSnackBar('인터넷 연결을 확인해주세요.');
|
||||
} on ApiKeyException {
|
||||
// API 키 설정 필요
|
||||
showSnackBar('네이버 API 키를 설정해주세요.');
|
||||
} on NaverMapParseException catch (e) {
|
||||
// 파싱 오류
|
||||
showSnackBar('식당 정보를 가져올 수 없습니다: ${e.message}');
|
||||
} catch (e) {
|
||||
// 기타 오류
|
||||
showSnackBar('알 수 없는 오류가 발생했습니다.');
|
||||
}
|
||||
```
|
||||
|
||||
## 설정
|
||||
|
||||
### API 키 설정
|
||||
|
||||
네이버 로컬 검색 API를 사용하려면 API 키가 필요합니다:
|
||||
|
||||
1. [네이버 개발자 센터](https://developers.naver.com)에서 애플리케이션 등록
|
||||
2. Client ID와 Client Secret 발급
|
||||
3. `lib/core/constants/api_keys.dart` 파일에 키 입력:
|
||||
|
||||
```dart
|
||||
class ApiKeys {
|
||||
static const String naverClientId = 'YOUR_CLIENT_ID';
|
||||
static const String naverClientSecret = 'YOUR_CLIENT_SECRET';
|
||||
}
|
||||
```
|
||||
|
||||
### 네트워크 설정 커스터마이징
|
||||
|
||||
`lib/core/network/network_config.dart`에서 타임아웃, 재시도 횟수 등을 조정할 수 있습니다:
|
||||
|
||||
```dart
|
||||
class NetworkConfig {
|
||||
static const int connectTimeout = 15000; // 15초
|
||||
static const int maxRetries = 3; // 최대 3회 재시도
|
||||
static const Duration cacheMaxAge = Duration(minutes: 15); // 15분 캐싱
|
||||
}
|
||||
```
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── errors/
|
||||
│ │ ├── app_exceptions.dart # 앱 전체 예외 클래스들
|
||||
│ │ ├── data_exceptions.dart # 데이터 레이어 예외
|
||||
│ │ └── network_exceptions.dart # 네트워크 예외
|
||||
│ └── network/
|
||||
│ ├── network_client.dart # Dio 기반 HTTP 클라이언트
|
||||
│ ├── network_config.dart # 네트워크 설정
|
||||
│ └── interceptors/
|
||||
│ ├── retry_interceptor.dart # 재시도 로직
|
||||
│ └── logging_interceptor.dart # 로깅
|
||||
├── data/
|
||||
│ ├── api/
|
||||
│ │ └── naver_api_client.dart # 네이버 API 클라이언트
|
||||
│ └── datasources/
|
||||
│ └── remote/
|
||||
│ ├── naver_map_parser.dart # HTML 파싱
|
||||
│ └── naver_search_service.dart # 통합 검색 서비스
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **API 키 보안**: API 키는 절대 Git에 커밋하지 마세요. `.gitignore`에 추가하세요.
|
||||
2. **요청 제한**: 네이버 API는 일일 요청 제한이 있습니다. 과도한 요청을 피하세요.
|
||||
3. **캐싱**: 동일한 요청은 15분간 캐싱됩니다. 실시간 정보가 필요한 경우 `useCache: false` 옵션을 사용하세요.
|
||||
4. **웹 환경**: 웹에서는 CORS 제한으로 인해 프록시 서버를 통해 요청합니다.
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### CORS 에러 (웹)
|
||||
웹 환경에서 CORS 에러가 발생하면 프록시 서버가 일시적으로 사용 불가능한 상태일 수 있습니다. 잠시 후 다시 시도하거나 직접 입력 기능을 사용하세요.
|
||||
|
||||
### 타임아웃 에러
|
||||
네트워크가 느린 환경에서는 `NetworkConfig`의 타임아웃 값을 늘려보세요.
|
||||
|
||||
### API 키 에러
|
||||
API 키가 올바르게 설정되었는지 확인하고, 네이버 개발자 센터에서 API 사용 권한이 활성화되어 있는지 확인하세요.
|
||||
79
lib/core/network/interceptors/logging_interceptor.dart
Normal file
79
lib/core/network/interceptors/logging_interceptor.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
///
|
||||
/// 네트워크 요청과 응답을 로그로 기록합니다.
|
||||
/// 디버그 모드에서만 활성화됩니다.
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final uri = options.uri;
|
||||
final method = options.method;
|
||||
final headers = options.headers;
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('>>> REQUEST [$method] $uri');
|
||||
print('>>> Headers: $headers');
|
||||
|
||||
if (options.data != null) {
|
||||
print('>>> Body: ${options.data}');
|
||||
}
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
print('>>> Query Parameters: ${options.queryParameters}');
|
||||
}
|
||||
}
|
||||
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final statusCode = response.statusCode;
|
||||
final uri = response.requestOptions.uri;
|
||||
|
||||
print('<<< RESPONSE [$statusCode] $uri');
|
||||
|
||||
if (response.headers.map.isNotEmpty) {
|
||||
print('<<< Headers: ${response.headers.map}');
|
||||
}
|
||||
|
||||
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
|
||||
final responseData = response.data.toString();
|
||||
if (responseData.length > 500) {
|
||||
print('<<< Body: ${responseData.substring(0, 500)}...(truncated)');
|
||||
} else {
|
||||
print('<<< Body: $responseData');
|
||||
}
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final uri = err.requestOptions.uri;
|
||||
final message = err.message;
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('!!! ERROR $uri');
|
||||
print('!!! Message: $message');
|
||||
|
||||
if (err.response != null) {
|
||||
print('!!! Status Code: ${err.response!.statusCode}');
|
||||
print('!!! Response: ${err.response!.data}');
|
||||
}
|
||||
|
||||
print('!!! Error Type: ${err.type}');
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
97
lib/core/network/interceptors/retry_interceptor.dart
Normal file
97
lib/core/network/interceptors/retry_interceptor.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../network_config.dart';
|
||||
import '../../errors/network_exceptions.dart';
|
||||
|
||||
/// 재시도 인터셉터
|
||||
///
|
||||
/// 네트워크 오류 발생 시 자동으로 재시도합니다.
|
||||
/// 지수 백오프(exponential backoff) 알고리즘을 사용합니다.
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final Dio dio;
|
||||
|
||||
RetryInterceptor({required this.dio});
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// 재시도 카운트 확인
|
||||
final retryCount = err.requestOptions.extra['retryCount'] ?? 0;
|
||||
|
||||
// 재시도 가능한 오류인지 확인
|
||||
if (_shouldRetry(err) && retryCount < NetworkConfig.maxRetries) {
|
||||
try {
|
||||
// 지수 백오프 계산
|
||||
final delay = _calculateBackoffDelay(retryCount);
|
||||
|
||||
print('RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기');
|
||||
|
||||
// 대기
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
|
||||
// 재시도 카운트 증가
|
||||
err.requestOptions.extra['retryCount'] = retryCount + 1;
|
||||
|
||||
// 재시도 실행
|
||||
final response = await dio.fetch(err.requestOptions);
|
||||
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
// 재시도도 실패한 경우
|
||||
if (retryCount + 1 >= NetworkConfig.maxRetries) {
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: MaxRetriesExceededException(originalError: e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
/// 재시도 가능한 오류인지 판단
|
||||
bool _shouldRetry(DioException err) {
|
||||
// 네이버 관련 요청은 재시도하지 않음
|
||||
final url = err.requestOptions.uri.toString();
|
||||
if (url.contains('naver.com') || url.contains('naver.me')) {
|
||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 네트워크 연결 오류
|
||||
if (err.type == DioExceptionType.connectionTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout ||
|
||||
err.type == DioExceptionType.receiveTimeout ||
|
||||
err.type == DioExceptionType.connectionError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 서버 오류 (5xx)
|
||||
final statusCode = err.response?.statusCode;
|
||||
if (statusCode != null && statusCode >= 500 && statusCode < 600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 429 Too Many Requests는 재시도하지 않음
|
||||
// 재시도하면 더 많은 요청이 발생하여 문제가 악화됨
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 지수 백오프 지연 시간 계산
|
||||
int _calculateBackoffDelay(int retryCount) {
|
||||
final baseDelay = NetworkConfig.retryDelayMillis;
|
||||
final multiplier = NetworkConfig.retryDelayMultiplier;
|
||||
|
||||
// 지수 백오프: delay = baseDelay * (multiplier ^ retryCount)
|
||||
final exponentialDelay = baseDelay * pow(multiplier, retryCount);
|
||||
|
||||
// 지터(jitter) 추가로 동시 재시도 방지
|
||||
final jitter = Random().nextInt(1000);
|
||||
|
||||
return exponentialDelay.toInt() + jitter;
|
||||
}
|
||||
}
|
||||
260
lib/core/network/network_client.dart
Normal file
260
lib/core/network/network_client.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'network_config.dart';
|
||||
import '../errors/network_exceptions.dart';
|
||||
import 'interceptors/retry_interceptor.dart';
|
||||
import 'interceptors/logging_interceptor.dart';
|
||||
|
||||
/// 네트워크 클라이언트
|
||||
///
|
||||
/// Dio를 기반으로 한 중앙화된 HTTP 클라이언트입니다.
|
||||
/// 재시도, 캐싱, 로깅 등의 기능을 제공합니다.
|
||||
class NetworkClient {
|
||||
late final Dio _dio;
|
||||
CacheStore? _cacheStore;
|
||||
|
||||
NetworkClient() {
|
||||
_dio = Dio(_createBaseOptions());
|
||||
_setupInterceptors();
|
||||
}
|
||||
|
||||
/// 기본 옵션 생성
|
||||
BaseOptions _createBaseOptions() {
|
||||
return BaseOptions(
|
||||
connectTimeout: Duration(milliseconds: NetworkConfig.connectTimeout),
|
||||
receiveTimeout: Duration(milliseconds: NetworkConfig.receiveTimeout),
|
||||
sendTimeout: Duration(milliseconds: NetworkConfig.sendTimeout),
|
||||
headers: {
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
'Accept': 'application/json, text/html, */*',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
);
|
||||
}
|
||||
|
||||
/// 인터셉터 설정
|
||||
Future<void> _setupInterceptors() async {
|
||||
// 로깅 인터셉터 (디버그 모드에서만)
|
||||
if (kDebugMode) {
|
||||
_dio.interceptors.add(LoggingInterceptor());
|
||||
}
|
||||
|
||||
// 재시도 인터셉터
|
||||
_dio.interceptors.add(RetryInterceptor(dio: _dio));
|
||||
|
||||
// 캐시 인터셉터 설정
|
||||
await _setupCacheInterceptor();
|
||||
|
||||
// 에러 변환 인터셉터
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
handler.next(_transformError(error));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 캐시 인터셉터 설정
|
||||
Future<void> _setupCacheInterceptor() async {
|
||||
try {
|
||||
if (!kIsWeb) {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheDir = Directory('${dir.path}/lunchpick_cache');
|
||||
|
||||
if (!await cacheDir.exists()) {
|
||||
await cacheDir.create(recursive: true);
|
||||
}
|
||||
|
||||
_cacheStore = HiveCacheStore(cacheDir.path);
|
||||
} else {
|
||||
// 웹 환경에서는 메모리 캐시 사용
|
||||
_cacheStore = MemCacheStore();
|
||||
}
|
||||
|
||||
final cacheOptions = CacheOptions(
|
||||
store: _cacheStore,
|
||||
policy: CachePolicy.forceCache,
|
||||
maxStale: NetworkConfig.cacheMaxAge,
|
||||
priority: CachePriority.normal,
|
||||
keyBuilder: CacheOptions.defaultCacheKeyBuilder,
|
||||
allowPostMethod: false,
|
||||
);
|
||||
|
||||
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
|
||||
} catch (e) {
|
||||
debugPrint('NetworkClient: 캐시 설정 실패 - $e');
|
||||
// 캐시 실패해도 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
/// 에러 변환
|
||||
DioException _transformError(DioException error) {
|
||||
NetworkException networkException;
|
||||
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
networkException = ConnectionTimeoutException(originalError: error);
|
||||
break;
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
networkException = NoInternetException(originalError: error);
|
||||
break;
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode ?? 0;
|
||||
final message = _getErrorMessage(error.response);
|
||||
|
||||
if (statusCode >= 500) {
|
||||
networkException = ServerException(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: error,
|
||||
);
|
||||
} else if (statusCode >= 400) {
|
||||
networkException = ClientException(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: error,
|
||||
);
|
||||
} else {
|
||||
networkException = ClientException(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
networkException = NoInternetException(
|
||||
message: error.message ?? '알 수 없는 네트워크 오류가 발생했습니다',
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
type: error.type,
|
||||
error: networkException,
|
||||
);
|
||||
}
|
||||
|
||||
/// 에러 메시지 추출
|
||||
String _getErrorMessage(Response? response) {
|
||||
if (response == null) {
|
||||
return '서버 응답을 받을 수 없습니다';
|
||||
}
|
||||
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
|
||||
// 상태 코드별 기본 메시지
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return '잘못된 요청입니다';
|
||||
case 401:
|
||||
return '인증이 필요합니다';
|
||||
case 403:
|
||||
return '접근 권한이 없습니다';
|
||||
case 404:
|
||||
return '요청한 리소스를 찾을 수 없습니다';
|
||||
case 429:
|
||||
return '너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요';
|
||||
case 500:
|
||||
return '서버 내부 오류가 발생했습니다';
|
||||
case 502:
|
||||
return '게이트웨이 오류가 발생했습니다';
|
||||
case 503:
|
||||
return '서비스를 일시적으로 사용할 수 없습니다';
|
||||
default:
|
||||
return '서버 오류가 발생했습니다 (HTTP $statusCode)';
|
||||
}
|
||||
}
|
||||
|
||||
/// GET 요청
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
bool useCache = true,
|
||||
}) {
|
||||
final requestOptions = options ?? Options();
|
||||
|
||||
// 캐시 사용 설정
|
||||
if (!useCache) {
|
||||
requestOptions.extra = {
|
||||
...?requestOptions.extra,
|
||||
'disableCache': true,
|
||||
};
|
||||
}
|
||||
|
||||
return _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: requestOptions,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// POST 요청
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) {
|
||||
return _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// HEAD 요청 (리다이렉션 확인용)
|
||||
Future<Response<T>> head<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.head<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 캐시 삭제
|
||||
Future<void> clearCache() async {
|
||||
await _cacheStore?.clean();
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_dio.close();
|
||||
_cacheStore?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 기본 네트워크 클라이언트 인스턴스
|
||||
final networkClient = NetworkClient();
|
||||
34
lib/core/network/network_config.dart
Normal file
34
lib/core/network/network_config.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
/// 네트워크 설정 상수
|
||||
///
|
||||
/// 모든 네트워크 관련 설정을 중앙 관리합니다.
|
||||
class NetworkConfig {
|
||||
// 타임아웃 설정 (밀리초)
|
||||
static const int connectTimeout = 15000; // 15초
|
||||
static const int receiveTimeout = 30000; // 30초
|
||||
static const int sendTimeout = 15000; // 15초
|
||||
|
||||
// 재시도 설정
|
||||
static const int maxRetries = 3;
|
||||
static const int retryDelayMillis = 1000; // 1초
|
||||
static const double retryDelayMultiplier = 2.0; // 지수 백오프
|
||||
|
||||
// 캐시 설정
|
||||
static const Duration cacheMaxAge = Duration(minutes: 15);
|
||||
static const int cacheMaxSize = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// 네이버 API 설정
|
||||
static const String naverApiBaseUrl = 'https://openapi.naver.com';
|
||||
static const String naverMapBaseUrl = 'https://map.naver.com';
|
||||
static const String naverShortUrlBase = 'https://naver.me';
|
||||
|
||||
// CORS 프록시 (웹 환경용)
|
||||
static const String corsProxyUrl = 'https://api.allorigins.win/get';
|
||||
|
||||
// User Agent
|
||||
static const String userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
/// CORS 프록시 URL 생성
|
||||
static String getCorsProxyUrl(String originalUrl) {
|
||||
return '$corsProxyUrl?url=${Uri.encodeComponent(originalUrl)}';
|
||||
}
|
||||
}
|
||||
284
lib/core/services/notification_service.dart
Normal file
284
lib/core/services/notification_service.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
|
||||
/// 알림 서비스 싱글톤 클래스
|
||||
class NotificationService {
|
||||
// 싱글톤 인스턴스
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
// 알림 채널 정보
|
||||
static const String _channelId = 'lunchpick_visit_reminder';
|
||||
static const String _channelName = '방문 확인 알림';
|
||||
static const String _channelDescription = '점심 식사 후 방문을 확인하는 알림입니다.';
|
||||
|
||||
// 알림 ID (방문 확인용)
|
||||
static const int _visitReminderNotificationId = 1;
|
||||
|
||||
/// 알림 서비스 초기화
|
||||
Future<bool> initialize() async {
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// iOS 초기화 설정
|
||||
final iosInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
onDidReceiveLocalNotification: (id, title, body, payload) async {
|
||||
// iOS 9 이하 버전 대응
|
||||
},
|
||||
);
|
||||
|
||||
// macOS 초기화 설정
|
||||
final macOSInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
// 플랫폼별 초기화 설정 통합
|
||||
final initSettings = InitializationSettings(
|
||||
android: androidInitSettings,
|
||||
iOS: iosInitSettings,
|
||||
macOS: macOSInitSettings,
|
||||
);
|
||||
|
||||
// 알림 플러그인 초기화
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
|
||||
);
|
||||
|
||||
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await _createNotificationChannel();
|
||||
}
|
||||
|
||||
return initialized ?? false;
|
||||
}
|
||||
|
||||
/// Android 알림 채널 생성
|
||||
Future<void> _createNotificationChannel() async {
|
||||
const androidChannel = AndroidNotificationChannel(
|
||||
_channelId,
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
}
|
||||
|
||||
/// 알림 권한 요청
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
} else if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final granted = await macosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 권한 상태 확인
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
return true;
|
||||
}
|
||||
|
||||
// 알림 탭 콜백
|
||||
static void Function(NotificationResponse)? onNotificationTap;
|
||||
|
||||
/// 방문 확인 알림 예약
|
||||
Future<void> scheduleVisitReminder({
|
||||
required String restaurantId,
|
||||
required String restaurantName,
|
||||
required DateTime recommendationTime,
|
||||
}) async {
|
||||
try {
|
||||
// 1.5~2시간 사이의 랜덤 시간 계산 (90~120분)
|
||||
final randomMinutes = 90 + Random().nextInt(31); // 90 + 0~30분
|
||||
final scheduledTime = tz.TZDateTime.now(tz.local).add(
|
||||
Duration(minutes: randomMinutes),
|
||||
);
|
||||
|
||||
// 알림 상세 설정
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: '방문 확인',
|
||||
icon: '@mipmap/ic_launcher',
|
||||
autoCancel: true,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
sound: 'default',
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: iosDetails,
|
||||
);
|
||||
|
||||
// 알림 예약
|
||||
await _notifications.zonedSchedule(
|
||||
_visitReminderNotificationId,
|
||||
'다녀왔음? 🍴',
|
||||
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
||||
scheduledTime,
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($randomMinutes분 후)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('알림 예약 실패: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 예약된 방문 확인 알림 취소
|
||||
Future<void> cancelVisitReminder() async {
|
||||
await _notifications.cancel(_visitReminderNotificationId);
|
||||
}
|
||||
|
||||
/// 모든 알림 취소
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
/// 예약된 알림 목록 조회
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
return await _notifications.pendingNotificationRequests();
|
||||
}
|
||||
|
||||
/// 알림 탭 이벤트 처리
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 백그라운드 알림 탭 이벤트 처리
|
||||
@pragma('vm:entry-point')
|
||||
static void _onBackgroundNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 즉시 알림 표시 (테스트용)
|
||||
Future<void> showImmediateNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
0,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/core/utils/category_mapper.dart
Normal file
142
lib/core/utils/category_mapper.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 동적 카테고리 매핑을 위한 유틸리티 클래스
|
||||
class CategoryMapper {
|
||||
static const Map<String, IconData> _iconMap = {
|
||||
// 주요 카테고리
|
||||
'한식': Icons.rice_bowl,
|
||||
'중식': Icons.ramen_dining,
|
||||
'중국요리': Icons.ramen_dining,
|
||||
'일식': Icons.set_meal,
|
||||
'일본요리': Icons.set_meal,
|
||||
'양식': Icons.restaurant,
|
||||
'아시안': Icons.soup_kitchen,
|
||||
'아시아음식': Icons.soup_kitchen,
|
||||
'패스트푸드': Icons.fastfood,
|
||||
'카페': Icons.local_cafe,
|
||||
'디저트': Icons.cake,
|
||||
'카페/디저트': Icons.local_cafe,
|
||||
'술집': Icons.local_bar,
|
||||
'주점': Icons.local_bar,
|
||||
'분식': Icons.fastfood,
|
||||
'치킨': Icons.egg,
|
||||
'피자': Icons.local_pizza,
|
||||
'베이커리': Icons.bakery_dining,
|
||||
'해물': Icons.set_meal,
|
||||
'해산물': Icons.set_meal,
|
||||
'고기': Icons.kebab_dining,
|
||||
'육류': Icons.kebab_dining,
|
||||
'채식': Icons.eco,
|
||||
'비건': Icons.eco,
|
||||
'브런치': Icons.brunch_dining,
|
||||
'뷔페': Icons.dining,
|
||||
// 기본값
|
||||
'기타': Icons.restaurant_menu,
|
||||
'음식점': Icons.restaurant_menu,
|
||||
};
|
||||
|
||||
static const Map<String, Color> _colorMap = {
|
||||
// 주요 카테고리
|
||||
'한식': Color(0xFFE53935),
|
||||
'중식': Color(0xFFFF6F00),
|
||||
'중국요리': Color(0xFFFF6F00),
|
||||
'일식': Color(0xFF43A047),
|
||||
'일본요리': Color(0xFF43A047),
|
||||
'양식': Color(0xFF1E88E5),
|
||||
'아시안': Color(0xFF8E24AA),
|
||||
'아시아음식': Color(0xFF8E24AA),
|
||||
'패스트푸드': Color(0xFFFDD835),
|
||||
'카페': Color(0xFF6D4C41),
|
||||
'디저트': Color(0xFFEC407A),
|
||||
'카페/디저트': Color(0xFF6D4C41),
|
||||
'술집': Color(0xFF546E7A),
|
||||
'주점': Color(0xFF546E7A),
|
||||
'분식': Color(0xFFFF7043),
|
||||
'치킨': Color(0xFFFFB300),
|
||||
'피자': Color(0xFFE91E63),
|
||||
'베이커리': Color(0xFF8D6E63),
|
||||
'해물': Color(0xFF00ACC1),
|
||||
'해산물': Color(0xFF00ACC1),
|
||||
'고기': Color(0xFFD32F2F),
|
||||
'육류': Color(0xFFD32F2F),
|
||||
'채식': Color(0xFF689F38),
|
||||
'비건': Color(0xFF388E3C),
|
||||
'브런치': Color(0xFFFFA726),
|
||||
'뷔페': Color(0xFF7B1FA2),
|
||||
// 기본값
|
||||
'기타': Color(0xFF757575),
|
||||
'음식점': Color(0xFF757575),
|
||||
};
|
||||
|
||||
/// 카테고리에 해당하는 아이콘 반환
|
||||
static IconData getIcon(String category) {
|
||||
// 완전 일치 검색
|
||||
if (_iconMap.containsKey(category)) {
|
||||
return _iconMap[category]!;
|
||||
}
|
||||
|
||||
// 부분 일치 검색 (키워드 포함)
|
||||
for (final entry in _iconMap.entries) {
|
||||
if (category.contains(entry.key) || entry.key.contains(category)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 아이콘
|
||||
return Icons.restaurant_menu;
|
||||
}
|
||||
|
||||
/// 카테고리에 해당하는 색상 반환
|
||||
static Color getColor(String category) {
|
||||
// 완전 일치 검색
|
||||
if (_colorMap.containsKey(category)) {
|
||||
return _colorMap[category]!;
|
||||
}
|
||||
|
||||
// 부분 일치 검색 (키워드 포함)
|
||||
for (final entry in _colorMap.entries) {
|
||||
if (category.contains(entry.key) || entry.key.contains(category)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 문자열 기반 색상 생성 (일관된 색상)
|
||||
final hash = category.hashCode;
|
||||
final hue = (hash % 360).toDouble();
|
||||
return HSVColor.fromAHSV(1.0, hue, 0.6, 0.8).toColor();
|
||||
}
|
||||
|
||||
/// 카테고리 표시명 정규화
|
||||
static String getDisplayName(String category) {
|
||||
// 긴 카테고리명 축약
|
||||
if (category.length > 10) {
|
||||
// ">"로 구분된 경우 마지막 부분만 사용
|
||||
if (category.contains('>')) {
|
||||
final parts = category.split('>');
|
||||
return parts.last.trim();
|
||||
}
|
||||
// 공백으로 구분된 경우 첫 단어만 사용
|
||||
if (category.contains(' ')) {
|
||||
return category.split(' ').first;
|
||||
}
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
/// 네이버 카테고리 파싱 및 정규화
|
||||
static String normalizeNaverCategory(String category, String? subCategory) {
|
||||
// 카테고리가 "음식점"인 경우 subCategory 사용
|
||||
if (category == '음식점' && subCategory != null && subCategory.isNotEmpty) {
|
||||
return subCategory;
|
||||
}
|
||||
|
||||
// ">"로 구분된 카테고리의 경우 가장 구체적인 부분 사용
|
||||
if (category.contains('>')) {
|
||||
final parts = category.split('>').map((s) => s.trim()).toList();
|
||||
// 마지막 부분이 가장 구체적
|
||||
return parts.last;
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
110
lib/core/utils/distance_calculator.dart
Normal file
110
lib/core/utils/distance_calculator.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
class DistanceCalculator {
|
||||
static const double earthRadiusKm = 6371.0;
|
||||
|
||||
static double calculateDistance({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
}) {
|
||||
final double dLat = _toRadians(lat2 - lat1);
|
||||
final double dLon = _toRadians(lon2 - lon1);
|
||||
|
||||
final double a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_toRadians(lat1)) *
|
||||
math.cos(_toRadians(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
math.sin(dLon / 2);
|
||||
|
||||
final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
|
||||
return earthRadiusKm * c;
|
||||
}
|
||||
|
||||
static double _toRadians(double degree) {
|
||||
return degree * (math.pi / 180);
|
||||
}
|
||||
|
||||
static String formatDistance(double distanceInKm) {
|
||||
if (distanceInKm < 1) {
|
||||
return '${(distanceInKm * 1000).round()}m';
|
||||
} else if (distanceInKm < 10) {
|
||||
return '${distanceInKm.toStringAsFixed(1)}km';
|
||||
} else {
|
||||
return '${distanceInKm.round()}km';
|
||||
}
|
||||
}
|
||||
|
||||
static bool isWithinDistance({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
required double maxDistanceKm,
|
||||
}) {
|
||||
final distance = calculateDistance(
|
||||
lat1: lat1,
|
||||
lon1: lon1,
|
||||
lat2: lat2,
|
||||
lon2: lon2,
|
||||
);
|
||||
return distance <= maxDistanceKm;
|
||||
}
|
||||
|
||||
static double? calculateDistanceFromCurrentLocation({
|
||||
required double targetLat,
|
||||
required double targetLon,
|
||||
double? currentLat,
|
||||
double? currentLon,
|
||||
}) {
|
||||
if (currentLat == null || currentLon == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateDistance(
|
||||
lat1: currentLat,
|
||||
lon1: currentLon,
|
||||
lat2: targetLat,
|
||||
lon2: targetLon,
|
||||
);
|
||||
}
|
||||
|
||||
static List<T> sortByDistance<T>({
|
||||
required List<T> items,
|
||||
required double Function(T) getLat,
|
||||
required double Function(T) getLon,
|
||||
required double currentLat,
|
||||
required double currentLon,
|
||||
}) {
|
||||
final List<T> sortedItems = List<T>.from(items);
|
||||
|
||||
sortedItems.sort((a, b) {
|
||||
final distanceA = calculateDistance(
|
||||
lat1: currentLat,
|
||||
lon1: currentLon,
|
||||
lat2: getLat(a),
|
||||
lon2: getLon(a),
|
||||
);
|
||||
|
||||
final distanceB = calculateDistance(
|
||||
lat1: currentLat,
|
||||
lon1: currentLon,
|
||||
lat2: getLat(b),
|
||||
lon2: getLon(b),
|
||||
);
|
||||
|
||||
return distanceA.compareTo(distanceB);
|
||||
});
|
||||
|
||||
return sortedItems;
|
||||
}
|
||||
|
||||
static Map<String, double> getDefaultLocationForKorea() {
|
||||
return {
|
||||
'latitude': 37.5665,
|
||||
'longitude': 126.9780,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
lib/core/utils/validators.dart
Normal file
92
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
class Validators {
|
||||
static String? validateRestaurantName(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '맛집 이름을 입력해주세요';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return '맛집 이름은 2자 이상이어야 합니다';
|
||||
}
|
||||
if (value.trim().length > 50) {
|
||||
return '맛집 이름은 50자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateMemo(String? value) {
|
||||
if (value != null && value.length > 200) {
|
||||
return '메모는 200자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateLatitude(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final lat = double.tryParse(value);
|
||||
if (lat == null) {
|
||||
return '올바른 위도 값을 입력해주세요';
|
||||
}
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return '위도는 -90도에서 90도 사이여야 합니다';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateLongitude(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final lng = double.tryParse(value);
|
||||
if (lng == null) {
|
||||
return '올바른 경도 값을 입력해주세요';
|
||||
}
|
||||
|
||||
if (lng < -180 || lng > 180) {
|
||||
return '경도는 -180도에서 180도 사이여야 합니다';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateAddress(String? value) {
|
||||
if (value != null && value.length > 100) {
|
||||
return '주소는 100자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateCategory(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '카테고리를 선택해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateRating(double? value) {
|
||||
if (value != null && (value < 0 || value > 5)) {
|
||||
return '평점은 0에서 5 사이여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isValidEmail(String? email) {
|
||||
if (email == null || email.isEmpty) return false;
|
||||
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
return emailRegex.hasMatch(email);
|
||||
}
|
||||
|
||||
static bool isValidPhoneNumber(String? phone) {
|
||||
if (phone == null || phone.isEmpty) return false;
|
||||
|
||||
final phoneRegex = RegExp(r'^[0-9-+() ]+$');
|
||||
return phoneRegex.hasMatch(phone) && phone.length >= 10;
|
||||
}
|
||||
}
|
||||
154
lib/core/widgets/empty_state_widget.dart
Normal file
154
lib/core/widgets/empty_state_widget.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/app_colors.dart';
|
||||
import '../constants/app_typography.dart';
|
||||
|
||||
/// 빈 상태 위젯
|
||||
///
|
||||
/// 데이터가 없을 때 표시하는 공통 위젯
|
||||
class EmptyStateWidget extends StatelessWidget {
|
||||
/// 제목
|
||||
final String title;
|
||||
|
||||
/// 설명 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
/// 아이콘 (선택사항)
|
||||
final IconData? icon;
|
||||
|
||||
/// 아이콘 크기
|
||||
final double iconSize;
|
||||
|
||||
/// 액션 버튼 텍스트 (선택사항)
|
||||
final String? actionText;
|
||||
|
||||
/// 액션 버튼 콜백 (선택사항)
|
||||
final VoidCallback? onAction;
|
||||
|
||||
/// 커스텀 위젯 (아이콘 대신 사용할 수 있음)
|
||||
final Widget? customWidget;
|
||||
|
||||
const EmptyStateWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.icon,
|
||||
this.iconSize = 80.0,
|
||||
this.actionText,
|
||||
this.onAction,
|
||||
this.customWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 아이콘 또는 커스텀 위젯
|
||||
if (customWidget != null)
|
||||
customWidget!
|
||||
else if (icon != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary
|
||||
).withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 제목
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.heading2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// 설명 메시지 (있을 경우)
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// 액션 버튼 (있을 경우)
|
||||
if (actionText != null && onAction != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: onAction,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
actionText!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 리스트 빈 상태 위젯
|
||||
///
|
||||
/// 리스트나 그리드가 비어있을 때 사용하는 특화된 위젯
|
||||
class ListEmptyStateWidget extends StatelessWidget {
|
||||
/// 아이템 유형 (예: "식당", "기록" 등)
|
||||
final String itemType;
|
||||
|
||||
/// 추가 액션 콜백 (선택사항)
|
||||
final VoidCallback? onAdd;
|
||||
|
||||
const ListEmptyStateWidget({
|
||||
super.key,
|
||||
required this.itemType,
|
||||
this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return EmptyStateWidget(
|
||||
icon: Icons.inbox_outlined,
|
||||
title: '$itemType이(가) 없습니다',
|
||||
message: '새로운 $itemType을(를) 추가해보세요',
|
||||
actionText: onAdd != null ? '$itemType 추가' : null,
|
||||
onAction: onAdd,
|
||||
);
|
||||
}
|
||||
}
|
||||
116
lib/core/widgets/error_widget.dart
Normal file
116
lib/core/widgets/error_widget.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/app_colors.dart';
|
||||
import '../constants/app_typography.dart';
|
||||
|
||||
/// 커스텀 에러 위젯
|
||||
///
|
||||
/// Flutter의 기본 ErrorWidget과 이름 충돌을 피하기 위해 CustomErrorWidget으로 명명
|
||||
class CustomErrorWidget extends StatelessWidget {
|
||||
/// 에러 메시지
|
||||
final String message;
|
||||
|
||||
/// 에러 아이콘 (선택사항)
|
||||
final IconData? icon;
|
||||
|
||||
/// 재시도 버튼 콜백 (선택사항)
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// 상세 에러 메시지 (선택사항)
|
||||
final String? details;
|
||||
|
||||
const CustomErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon,
|
||||
this.onRetry,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 에러 아이콘
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: 64,
|
||||
color: isDark ? AppColors.darkError : AppColors.lightError,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 에러 메시지
|
||||
Text(
|
||||
message,
|
||||
style: AppTypography.heading2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// 상세 메시지 (있을 경우)
|
||||
if (details != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
details!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// 재시도 버튼 (있을 경우)
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('다시 시도'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 간단한 에러 스낵바를 표시하는 유틸리티 함수
|
||||
void showErrorSnackBar({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
backgroundColor: isDark ? AppColors.darkError : AppColors.lightError,
|
||||
duration: duration,
|
||||
action: action,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(8),
|
||||
),
|
||||
);
|
||||
}
|
||||
88
lib/core/widgets/loading_indicator.dart
Normal file
88
lib/core/widgets/loading_indicator.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/app_colors.dart';
|
||||
|
||||
/// 로딩 인디케이터 위젯
|
||||
///
|
||||
/// 앱 전체에서 일관된 로딩 표시를 위한 공통 위젯
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
/// 인디케이터 크기
|
||||
final double size;
|
||||
|
||||
/// 스트로크 너비
|
||||
final double strokeWidth;
|
||||
|
||||
const LoadingIndicator({
|
||||
super.key,
|
||||
this.message,
|
||||
this.size = 40.0,
|
||||
this.strokeWidth = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: strokeWidth,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 전체 화면 로딩 인디케이터
|
||||
///
|
||||
/// 화면 전체를 덮는 로딩 표시를 위한 위젯
|
||||
class FullScreenLoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
/// 배경 투명도
|
||||
final double backgroundOpacity;
|
||||
|
||||
const FullScreenLoadingIndicator({
|
||||
super.key,
|
||||
this.message,
|
||||
this.backgroundOpacity = 0.5,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
color: (isDark ? Colors.black : Colors.white)
|
||||
.withValues(alpha: backgroundOpacity),
|
||||
child: LoadingIndicator(message: message),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/data/api/converters/naver_data_converter.dart
Normal file
126
lib/data/api/converters/naver_data_converter.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import '../naver/naver_local_search_api.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
|
||||
/// 네이버 데이터 변환기
|
||||
///
|
||||
/// 네이버 API 응답을 도메인 엔티티로 변환합니다.
|
||||
class NaverDataConverter {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// NaverLocalSearchResult를 Restaurant 엔티티로 변환
|
||||
static Restaurant fromLocalSearchResult(
|
||||
NaverLocalSearchResult result, {
|
||||
String? id,
|
||||
}) {
|
||||
// 좌표 변환 (네이버 지도 좌표계 -> WGS84)
|
||||
final convertedCoords = _convertNaverMapCoordinates(
|
||||
result.mapx,
|
||||
result.mapy,
|
||||
);
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final categoryParts = result.category.split('>').map((s) => s.trim()).toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: result.title,
|
||||
category: normalizedCategory,
|
||||
subCategory: subCategory,
|
||||
description: result.description.isNotEmpty ? result.description : null,
|
||||
phoneNumber: result.telephone.isNotEmpty ? result.telephone : null,
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
: result.address,
|
||||
jibunAddress: result.address,
|
||||
latitude: convertedCoords['latitude'] ?? 37.5665,
|
||||
longitude: convertedCoords['longitude'] ?? 126.9780,
|
||||
naverUrl: result.link.isNotEmpty ? result.link : null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
source: DataSource.NAVER,
|
||||
);
|
||||
}
|
||||
|
||||
/// GraphQL 응답을 Restaurant 엔티티로 변환
|
||||
static Restaurant fromGraphQLResponse(
|
||||
Map<String, dynamic> placeData, {
|
||||
String? id,
|
||||
String? naverUrl,
|
||||
}) {
|
||||
// 영업시간 파싱
|
||||
String? businessHours;
|
||||
if (placeData['businessHours'] != null) {
|
||||
final hours = placeData['businessHours'] as List;
|
||||
businessHours = hours
|
||||
.where((h) => h['businessHours'] != null)
|
||||
.map((h) => h['businessHours'])
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 좌표 추출
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
if (placeData['location'] != null) {
|
||||
latitude = placeData['location']['latitude']?.toDouble();
|
||||
longitude = placeData['location']['longitude']?.toDouble();
|
||||
}
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final rawCategory = placeData['category'] ?? '음식점';
|
||||
final categoryParts = rawCategory.split('>').map((s) => s.trim()).toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: placeData['name'] ?? '이름 없음',
|
||||
category: normalizedCategory,
|
||||
subCategory: subCategory,
|
||||
description: placeData['description'],
|
||||
phoneNumber: placeData['phone'],
|
||||
roadAddress: placeData['address']?['roadAddress'] ?? '',
|
||||
jibunAddress: placeData['address']?['jibunAddress'] ?? '',
|
||||
latitude: latitude ?? 37.5665,
|
||||
longitude: longitude ?? 126.9780,
|
||||
businessHours: businessHours,
|
||||
naverUrl: naverUrl,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
source: DataSource.NAVER,
|
||||
);
|
||||
}
|
||||
|
||||
/// 네이버 지도 좌표를 WGS84로 변환
|
||||
static Map<String, double?> _convertNaverMapCoordinates(
|
||||
double? mapx,
|
||||
double? mapy,
|
||||
) {
|
||||
if (mapx == null || mapy == null) {
|
||||
return {'latitude': null, 'longitude': null};
|
||||
}
|
||||
|
||||
// 네이버 지도 좌표계는 KATEC을 사용
|
||||
// 간단한 변환 공식 (정확도는 떨어지지만 실용적)
|
||||
// 실제로는 더 정교한 변환이 필요할 수 있음
|
||||
final longitude = mapx / 10000000.0;
|
||||
final latitude = mapy / 10000000.0;
|
||||
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
167
lib/data/api/naver/naver_graphql_api.dart
Normal file
167
lib/data/api/naver/naver_graphql_api.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 GraphQL API 클라이언트
|
||||
///
|
||||
/// 네이버 지도의 GraphQL API를 호출하여 상세 정보를 가져옵니다.
|
||||
class NaverGraphQLApi {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
static const String _graphqlEndpoint = 'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
NaverGraphQLApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// GraphQL 쿼리 실행
|
||||
Future<Map<String, dynamic>> fetchGraphQL({
|
||||
required String operationName,
|
||||
required String query,
|
||||
Map<String, dynamic>? variables,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _networkClient.post<Map<String, dynamic>>(
|
||||
_graphqlEndpoint,
|
||||
data: {
|
||||
'operationName': operationName,
|
||||
'query': query,
|
||||
'variables': variables ?? {},
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
'Origin': 'https://map.naver.com',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 응답이 비어있습니다',
|
||||
);
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchGraphQL error: $e');
|
||||
throw ServerException(
|
||||
message: 'GraphQL 요청 중 오류가 발생했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 장소 상세 정보 가져오기 (한국어 텍스트)
|
||||
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
|
||||
const query = '''
|
||||
query getKoreanTexts(\$id: String!) {
|
||||
place(input: { id: \$id }) {
|
||||
id
|
||||
name
|
||||
category
|
||||
businessHours {
|
||||
description
|
||||
isDayOff
|
||||
openTime
|
||||
closeTime
|
||||
dayOfWeek
|
||||
businessHours
|
||||
}
|
||||
phone
|
||||
address {
|
||||
roadAddress
|
||||
jibunAddress
|
||||
}
|
||||
description
|
||||
menuInfo {
|
||||
menus {
|
||||
name
|
||||
price
|
||||
description
|
||||
images {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
keywords
|
||||
priceCategory
|
||||
imageCount
|
||||
visitorReviewCount
|
||||
visitorReviewScore
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
try {
|
||||
final response = await fetchGraphQL(
|
||||
operationName: 'getKoreanTexts',
|
||||
query: query,
|
||||
variables: {'id': placeId},
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchKoreanTextsFromPcmap error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 장소 기본 정보 가져오기
|
||||
Future<Map<String, dynamic>> fetchPlaceBasicInfo(String placeId) async {
|
||||
const query = '''
|
||||
query getPlaceBasicInfo(\$id: String!) {
|
||||
place(input: { id: \$id }) {
|
||||
id
|
||||
name
|
||||
category
|
||||
phone
|
||||
address {
|
||||
roadAddress
|
||||
jibunAddress
|
||||
}
|
||||
location {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
homepageUrl
|
||||
bookingUrl
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
try {
|
||||
final response = await fetchGraphQL(
|
||||
operationName: 'getPlaceBasicInfo',
|
||||
query: query,
|
||||
variables: {'id': placeId},
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchPlaceBasicInfo error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
52
lib/data/api/naver/naver_graphql_queries.dart
Normal file
52
lib/data/api/naver/naver_graphql_queries.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
/// \ub124\uc774\ubc84 \uc9c0\ub3c4 GraphQL \ucffc\ub9ac \ubaa8\uc74c
|
||||
///
|
||||
/// \ub124\uc774\ubc84 \uc9c0\ub3c4 API\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 GraphQL \ucffc\ub9ac\ub4e4\uc744 \uad00\ub9ac\ud569\ub2c8\ub2e4.
|
||||
class NaverGraphQLQueries {
|
||||
NaverGraphQLQueries._();
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - places \uc0ac\uc6a9
|
||||
static const String placeDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
places(id: \$id) {
|
||||
id
|
||||
name
|
||||
category
|
||||
address
|
||||
roadAddress
|
||||
phone
|
||||
virtualPhone
|
||||
businessHours {
|
||||
description
|
||||
}
|
||||
description
|
||||
location {
|
||||
lat
|
||||
lng
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - nxPlaces \uc0ac\uc6a9 (\ud3f4\ubc31)
|
||||
static const String nxPlaceDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
nxPlaces(id: \$id) {
|
||||
id
|
||||
name
|
||||
category
|
||||
address
|
||||
roadAddress
|
||||
phone
|
||||
virtualPhone
|
||||
businessHours {
|
||||
description
|
||||
}
|
||||
description
|
||||
location {
|
||||
lat
|
||||
lng
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
197
lib/data/api/naver/naver_local_search_api.dart
Normal file
197
lib/data/api/naver/naver_local_search_api.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/constants/api_keys.dart';
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 로컬 검색 API 결과 모델
|
||||
class NaverLocalSearchResult {
|
||||
final String title;
|
||||
final String link;
|
||||
final String category;
|
||||
final String description;
|
||||
final String telephone;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final double? mapx;
|
||||
final double? mapy;
|
||||
|
||||
NaverLocalSearchResult({
|
||||
required this.title,
|
||||
required this.link,
|
||||
required this.category,
|
||||
required this.description,
|
||||
required this.telephone,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
this.mapx,
|
||||
this.mapy,
|
||||
});
|
||||
|
||||
factory NaverLocalSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
// HTML 태그 제거 헬퍼 함수
|
||||
String removeHtmlTags(String text) {
|
||||
return text
|
||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(' ', ' ');
|
||||
}
|
||||
|
||||
return NaverLocalSearchResult(
|
||||
title: removeHtmlTags(json['title'] ?? ''),
|
||||
link: json['link'] ?? '',
|
||||
category: json['category'] ?? '',
|
||||
description: removeHtmlTags(json['description'] ?? ''),
|
||||
telephone: json['telephone'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
roadAddress: json['roadAddress'] ?? '',
|
||||
mapx: json['mapx'] != null ? double.tryParse(json['mapx'].toString()) : null,
|
||||
mapy: json['mapy'] != null ? double.tryParse(json['mapy'].toString()) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// link 필드에서 Place ID 추출
|
||||
///
|
||||
/// link가 비어있거나 Place ID가 없으면 null 반환
|
||||
String? extractPlaceId() {
|
||||
if (link.isEmpty) return null;
|
||||
|
||||
// 네이버 지도 URL 패턴에서 Place ID 추출
|
||||
// 예: https://map.naver.com/p/entry/place/1638379069
|
||||
final placeIdMatch = RegExp(r'/place/(\d+)').firstMatch(link);
|
||||
if (placeIdMatch != null) {
|
||||
return placeIdMatch.group(1);
|
||||
}
|
||||
|
||||
// 다른 패턴 시도: restaurant/1638379069
|
||||
final restaurantIdMatch = RegExp(r'/restaurant/(\d+)').firstMatch(link);
|
||||
if (restaurantIdMatch != null) {
|
||||
return restaurantIdMatch.group(1);
|
||||
}
|
||||
|
||||
// ID만 있는 경우 (10자리 숫자)
|
||||
final idOnlyMatch = RegExp(r'(\d{10})').firstMatch(link);
|
||||
if (idOnlyMatch != null) {
|
||||
return idOnlyMatch.group(1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 API 클라이언트
|
||||
///
|
||||
/// 네이버 검색 API를 통해 장소 정보를 검색합니다.
|
||||
class NaverLocalSearchApi {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverLocalSearchApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 로컬 검색 API 호출
|
||||
///
|
||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random', // random, comment
|
||||
}) async {
|
||||
// API 키 확인
|
||||
if (!ApiKeys.areKeysConfigured()) {
|
||||
throw ApiKeyException();
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
ApiKeys.naverLocalSearchEndpoint,
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'display': display,
|
||||
'start': start,
|
||||
'sort': sort,
|
||||
if (latitude != null && longitude != null) ...{
|
||||
'coordinate': '$longitude,$latitude', // 경도,위도 순서
|
||||
},
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-Naver-Client-Id': ApiKeys.naverClientId,
|
||||
'X-Naver-Client-Secret': ApiKeys.naverClientSecret,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data == null || data['items'] == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final items = data['items'] as List;
|
||||
return items
|
||||
.map((item) => NaverLocalSearchResult.fromJson(item))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
||||
debugPrint('Error type: ${e.type}');
|
||||
debugPrint('Error response: ${e.response?.data}');
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
throw ServerException(
|
||||
message: '네이버 검색 중 오류가 발생했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 특정 식당 상세 정보 검색
|
||||
Future<NaverLocalSearchResult?> searchRestaurantDetails({
|
||||
required String name,
|
||||
required String address,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
try {
|
||||
// 주소와 이름을 조합한 검색어
|
||||
final query = '$name $address';
|
||||
final results = await searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
display: 5,
|
||||
sort: 'comment', // 정확도순
|
||||
);
|
||||
|
||||
if (results.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 가장 정확한 결과 찾기
|
||||
for (final result in results) {
|
||||
if (result.title.contains(name) || name.contains(result.title)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 반환
|
||||
return results.first;
|
||||
} catch (e) {
|
||||
debugPrint('searchRestaurantDetails error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
101
lib/data/api/naver/naver_proxy_client.dart
Normal file
101
lib/data/api/naver/naver_proxy_client.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 프록시 클라이언트
|
||||
///
|
||||
/// 웹 환경에서 CORS 문제를 해결하기 위한 프록시 클라이언트입니다.
|
||||
class NaverProxyClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverProxyClient({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 웹 환경에서 프록시를 통해 HTML 가져오기
|
||||
Future<String> fetchViaProxy(String url) async {
|
||||
if (!kIsWeb) {
|
||||
throw UnsupportedError('프록시는 웹 환경에서만 사용 가능합니다');
|
||||
}
|
||||
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
||||
debugPrint('Using proxy URL: $proxyUrl');
|
||||
|
||||
final response = await _networkClient.get<String>(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw ParseException(
|
||||
message: '프록시 응답이 비어있습니다',
|
||||
);
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('Proxy fetch error: ${e.message}');
|
||||
debugPrint('Status code: ${e.response?.statusCode}');
|
||||
debugPrint('Response: ${e.response?.data}');
|
||||
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw ServerException(
|
||||
message: 'CORS 프록시 접근이 거부되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
statusCode: 403,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시를 통한 페이지 로드에 실패했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시 상태 확인
|
||||
Future<bool> checkProxyStatus() async {
|
||||
if (!kIsWeb) {
|
||||
return true; // 웹이 아니면 프록시 불필요
|
||||
}
|
||||
|
||||
try {
|
||||
final testUrl = 'https://map.naver.com';
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(testUrl);
|
||||
|
||||
final response = await _networkClient.head(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
validateStatus: (status) => status! < 500,
|
||||
),
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Proxy status check failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시 URL 생성
|
||||
String getProxyUrl(String originalUrl) {
|
||||
if (!kIsWeb) {
|
||||
return originalUrl;
|
||||
}
|
||||
return NetworkConfig.getCorsProxyUrl(originalUrl);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
151
lib/data/api/naver/naver_url_resolver.dart
Normal file
151
lib/data/api/naver/naver_url_resolver.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
|
||||
/// 네이버 URL 리졸버
|
||||
///
|
||||
/// 네이버 단축 URL을 실제 URL로 변환하고 최종 리다이렉트 URL을 추적합니다.
|
||||
class NaverUrlResolver {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverUrlResolver({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 단축 URL을 실제 URL로 변환
|
||||
Future<String> resolveShortUrl(String shortUrl) async {
|
||||
try {
|
||||
// 웹 환경에서는 CORS 프록시 사용
|
||||
if (kIsWeb) {
|
||||
return await _resolveShortUrlViaProxy(shortUrl);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 직접 HEAD 요청
|
||||
final response = await _networkClient.head(
|
||||
shortUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status! < 400,
|
||||
),
|
||||
);
|
||||
|
||||
// Location 헤더에서 리다이렉트 URL 추출
|
||||
final location = response.headers.value('location');
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
|
||||
// 리다이렉트가 없으면 원본 URL 반환
|
||||
return shortUrl;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('resolveShortUrl error: $e');
|
||||
|
||||
// 리다이렉트 응답인 경우 Location 헤더 확인
|
||||
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
|
||||
final location = e.response?.headers.value('location');
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
// 오류 발생 시 원본 URL 반환
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시를 통한 단축 URL 해결 (웹 환경)
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(shortUrl);
|
||||
|
||||
final response = await _networkClient.get(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => true,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
|
||||
// 응답에서 URL 정보 추출
|
||||
final responseData = response.data.toString();
|
||||
|
||||
// meta refresh 태그에서 URL 추출
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv="refresh"[^>]+content="0;url=([^"]+)"[^>]*>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final metaMatch = metaRefreshRegex.firstMatch(responseData);
|
||||
if (metaMatch != null) {
|
||||
return metaMatch.group(1) ?? shortUrl;
|
||||
}
|
||||
|
||||
// og:url 메타 태그에서 URL 추출
|
||||
final ogUrlRegex = RegExp(
|
||||
'<meta[^>]+property="og:url"[^>]+content="([^"]+)"[^>]*>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final ogMatch = ogUrlRegex.firstMatch(responseData);
|
||||
if (ogMatch != null) {
|
||||
return ogMatch.group(1) ?? shortUrl;
|
||||
}
|
||||
|
||||
// Location 헤더 확인
|
||||
final location = response.headers.value('location');
|
||||
if (location != null) {
|
||||
return location;
|
||||
}
|
||||
|
||||
return shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('_resolveShortUrlViaProxy error: $e');
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
///
|
||||
/// 여러 단계의 리다이렉트를 거쳐 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
String currentUrl = url;
|
||||
int redirectCount = 0;
|
||||
const maxRedirects = 5;
|
||||
|
||||
while (redirectCount < maxRedirects) {
|
||||
final response = await _networkClient.head(
|
||||
currentUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status! < 400,
|
||||
),
|
||||
);
|
||||
|
||||
final location = response.headers.value('location');
|
||||
if (location == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 절대 URL로 변환
|
||||
if (location.startsWith('/')) {
|
||||
final uri = Uri.parse(currentUrl);
|
||||
currentUrl = '${uri.scheme}://${uri.host}$location';
|
||||
} else {
|
||||
currentUrl = location;
|
||||
}
|
||||
|
||||
redirectCount++;
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
} catch (e) {
|
||||
debugPrint('getFinalRedirectUrl error: $e');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
217
lib/data/api/naver_api_client.dart
Normal file
217
lib/data/api/naver_api_client.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/network/network_client.dart';
|
||||
import '../../core/errors/network_exceptions.dart';
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import 'naver/naver_local_search_api.dart';
|
||||
import 'naver/naver_url_resolver.dart';
|
||||
import 'naver/naver_graphql_api.dart';
|
||||
import 'naver/naver_proxy_client.dart';
|
||||
import 'converters/naver_data_converter.dart';
|
||||
import '../datasources/remote/naver_html_extractor.dart';
|
||||
|
||||
/// 네이버 API 통합 클라이언트
|
||||
///
|
||||
/// 네이버 오픈 API와 지도 서비스를 위한 통합 클라이언트입니다.
|
||||
/// 내부적으로 각 기능별로 분리된 API 클라이언트를 사용합니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
// 분리된 API 클라이언트들
|
||||
late final NaverLocalSearchApi _localSearchApi;
|
||||
late final NaverUrlResolver _urlResolver;
|
||||
late final NaverGraphQLApi _graphqlApi;
|
||||
late final NaverProxyClient _proxyClient;
|
||||
|
||||
NaverApiClient({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient() {
|
||||
// 각 API 클라이언트 초기화
|
||||
_localSearchApi = NaverLocalSearchApi(networkClient: _networkClient);
|
||||
_urlResolver = NaverUrlResolver(networkClient: _networkClient);
|
||||
_graphqlApi = NaverGraphQLApi(networkClient: _networkClient);
|
||||
_proxyClient = NaverProxyClient(networkClient: _networkClient);
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 API 호출
|
||||
///
|
||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random',
|
||||
}) async {
|
||||
return _localSearchApi.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
display: display,
|
||||
start: start,
|
||||
sort: sort,
|
||||
);
|
||||
}
|
||||
|
||||
/// 단축 URL을 실제 URL로 변환
|
||||
Future<String> resolveShortUrl(String shortUrl) async {
|
||||
return _urlResolver.resolveShortUrl(shortUrl);
|
||||
}
|
||||
|
||||
/// 네이버 지도 페이지 HTML 가져오기
|
||||
Future<String> fetchMapPageHtml(String url) async {
|
||||
try {
|
||||
// 웹 환경에서는 프록시 사용
|
||||
if (kIsWeb) {
|
||||
return await _proxyClient.fetchViaProxy(url);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw ParseException(
|
||||
message: 'HTML 응답이 비어있습니다',
|
||||
);
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchMapPageHtml error: $e');
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: '페이지를 불러올 수 없습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GraphQL API 호출
|
||||
Future<Map<String, dynamic>> fetchGraphQL({
|
||||
required String operationName,
|
||||
required String query,
|
||||
Map<String, dynamic>? variables,
|
||||
}) async {
|
||||
return _graphqlApi.fetchGraphQL(
|
||||
operationName: operationName,
|
||||
query: query,
|
||||
variables: variables,
|
||||
);
|
||||
}
|
||||
|
||||
/// pcmap URL에서 한글 텍스트 리스트 가져오기
|
||||
///
|
||||
/// restaurant/{ID}/home 형식의 URL에서 모든 한글 텍스트를 추출합니다.
|
||||
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
|
||||
// restaurant 타입 URL 사용
|
||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||
|
||||
try {
|
||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
debugPrint('요청 URL: $pcmapUrl');
|
||||
debugPrint('Place ID: $placeId');
|
||||
|
||||
String html;
|
||||
if (kIsWeb) {
|
||||
// 웹 환경에서는 프록시 사용
|
||||
html = await _proxyClient.fetchViaProxy(pcmapUrl);
|
||||
} else {
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
pcmapUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 || response.data == null) {
|
||||
debugPrint(
|
||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||
);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'HTTP ${response.statusCode}',
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
|
||||
html = response.data!;
|
||||
}
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
||||
debugPrint('Apollo State 상호명: $apolloName');
|
||||
debugPrint('=====================================');
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
'placeId': placeId,
|
||||
'url': pcmapUrl,
|
||||
'koreanTexts': koreanTexts,
|
||||
'jsonLdName': jsonLdName,
|
||||
'apolloStateName': apolloName,
|
||||
'extractedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
||||
return {
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
return _urlResolver.getFinalRedirectUrl(url);
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_localSearchApi.dispose();
|
||||
_urlResolver.dispose();
|
||||
_graphqlApi.dispose();
|
||||
_proxyClient.dispose();
|
||||
_networkClient.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// NaverLocalSearchResult를 Restaurant으로 변환하는 확장 메서드
|
||||
extension NaverLocalSearchResultExtension on NaverLocalSearchResult {
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
return NaverDataConverter.fromLocalSearchResult(this, id: id);
|
||||
}
|
||||
}
|
||||
553
lib/data/api/naver_api_client.dart.backup
Normal file
553
lib/data/api/naver_api_client.dart.backup
Normal file
@@ -0,0 +1,553 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/constants/api_keys.dart';
|
||||
import '../../core/network/network_client.dart';
|
||||
import '../../core/network/network_config.dart';
|
||||
import '../../core/errors/network_exceptions.dart';
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import '../datasources/remote/naver_html_extractor.dart';
|
||||
|
||||
/// 네이버 API 클라이언트
|
||||
///
|
||||
/// 네이버 오픈 API와 지도 서비스를 위한 통합 클라이언트입니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
NaverApiClient({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
|
||||
/// 네이버 로컬 검색 API 호출
|
||||
///
|
||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int display = 20,
|
||||
int start = 1,
|
||||
String sort = 'random', // random, comment
|
||||
}) async {
|
||||
// API 키 확인
|
||||
if (!ApiKeys.areKeysConfigured()) {
|
||||
throw ApiKeyException();
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
ApiKeys.naverLocalSearchEndpoint,
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'display': display,
|
||||
'start': start,
|
||||
'sort': sort,
|
||||
if (latitude != null && longitude != null) ...{
|
||||
'coordinate': '$longitude,$latitude', // 경도,위도 순서
|
||||
},
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-Naver-Client-Id': ApiKeys.naverClientId,
|
||||
'X-Naver-Client-Secret': ApiKeys.naverClientSecret,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final items = response.data!['items'] as List<dynamic>?;
|
||||
if (items == null || items.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.map(
|
||||
(item) =>
|
||||
NaverLocalSearchResult.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw ParseException(message: '검색 결과를 파싱할 수 없습니다');
|
||||
} on DioException catch (e) {
|
||||
// 에러는 NetworkClient에서 이미 변환됨
|
||||
throw e.error ??
|
||||
ServerException(message: '네이버 API 호출 실패', statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 단축 URL 리다이렉션 처리
|
||||
///
|
||||
/// naver.me 단축 URL을 실제 지도 URL로 변환합니다.
|
||||
Future<String> resolveShortUrl(String shortUrl) async {
|
||||
if (!shortUrl.contains('naver.me')) {
|
||||
debugPrint('NaverApiClient: 단축 URL이 아님, 원본 반환 - $shortUrl');
|
||||
return shortUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('NaverApiClient: 단축 URL 리디렉션 처리 시작 - $shortUrl');
|
||||
|
||||
// 웹 환경에서는 CORS 프록시 사용
|
||||
if (kIsWeb) {
|
||||
return await _resolveShortUrlViaProxy(shortUrl);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 여러 단계의 리다이렉션 처리
|
||||
String currentUrl = shortUrl;
|
||||
int redirectCount = 0;
|
||||
const maxRedirects = 10;
|
||||
|
||||
while (redirectCount < maxRedirects) {
|
||||
debugPrint(
|
||||
'NaverApiClient: 리다이렉션 시도 #${redirectCount + 1} - $currentUrl',
|
||||
);
|
||||
|
||||
final response = await _networkClient.get(
|
||||
currentUrl,
|
||||
options: Options(
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => true, // 모든 상태 코드 허용
|
||||
headers: {'User-Agent': NetworkConfig.userAgent},
|
||||
),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
debugPrint('NaverApiClient: 응답 상태 코드 - ${response.statusCode}');
|
||||
|
||||
// 리다이렉션 체크 (301, 302, 307, 308)
|
||||
if ([301, 302, 307, 308].contains(response.statusCode)) {
|
||||
final location = response.headers['location']?.firstOrNull;
|
||||
if (location != null) {
|
||||
debugPrint('NaverApiClient: Location 헤더 발견 - $location');
|
||||
|
||||
// 상대 경로인 경우 절대 경로로 변환
|
||||
if (!location.startsWith('http')) {
|
||||
final Uri baseUri = Uri.parse(currentUrl);
|
||||
currentUrl = baseUri.resolve(location).toString();
|
||||
} else {
|
||||
currentUrl = location;
|
||||
}
|
||||
|
||||
// 목표 URL에 도달했는지 확인
|
||||
if (currentUrl.contains('pcmap.place.naver.com') ||
|
||||
currentUrl.contains('map.naver.com/p/')) {
|
||||
debugPrint('NaverApiClient: 최종 URL 도착 - $currentUrl');
|
||||
return currentUrl;
|
||||
}
|
||||
|
||||
redirectCount++;
|
||||
} else {
|
||||
debugPrint('NaverApiClient: Location 헤더 없음');
|
||||
break;
|
||||
}
|
||||
} else if (response.statusCode == 200) {
|
||||
// 200 OK인 경우 meta refresh 태그 확인
|
||||
debugPrint('NaverApiClient: 200 OK - meta refresh 태그 확인');
|
||||
|
||||
final String? html = response.data as String?;
|
||||
if (html != null &&
|
||||
html.contains('meta') &&
|
||||
html.contains('refresh')) {
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv=["\']refresh["\'][^>]+content=["\']\\d+;\\s*url=([^"\'>]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final match = metaRefreshRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final redirectUrl = match.group(1)!;
|
||||
debugPrint('NaverApiClient: Meta refresh URL 발견 - $redirectUrl');
|
||||
|
||||
// 상대 경로 처리
|
||||
if (!redirectUrl.startsWith('http')) {
|
||||
final Uri baseUri = Uri.parse(currentUrl);
|
||||
currentUrl = baseUri.resolve(redirectUrl).toString();
|
||||
} else {
|
||||
currentUrl = redirectUrl;
|
||||
}
|
||||
|
||||
redirectCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// meta refresh가 없으면 현재 URL이 최종 URL
|
||||
debugPrint('NaverApiClient: 200 OK - 최종 URL - $currentUrl');
|
||||
return currentUrl;
|
||||
} else {
|
||||
debugPrint('NaverApiClient: 리다이렉션 아님 - 상태 코드 ${response.statusCode}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 시도 후 현재 URL 반환
|
||||
debugPrint('NaverApiClient: 최종 URL - $currentUrl');
|
||||
return currentUrl;
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: 단축 URL 리다이렉션 실패 - $e');
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시를 통한 단축 URL 리다이렉션 (웹 환경)
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl =
|
||||
'${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(shortUrl)}';
|
||||
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
proxyUrl,
|
||||
options: Options(headers: {'Accept': 'application/json'}),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data!;
|
||||
|
||||
// status.url 확인
|
||||
if (data['status'] != null &&
|
||||
data['status'] is Map &&
|
||||
data['status']['url'] != null) {
|
||||
final finalUrl = data['status']['url'] as String;
|
||||
debugPrint('NaverApiClient: 프록시 최종 URL - $finalUrl');
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
// contents에서 meta refresh 태그 찾기
|
||||
final contents = data['contents'] as String?;
|
||||
if (contents != null && contents.isNotEmpty) {
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta\\s+http-equiv=["\']refresh["\']'
|
||||
'\\s+content=["\']0;\\s*url=([^"\']+)["\']',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final match = metaRefreshRegex.firstMatch(contents);
|
||||
if (match != null) {
|
||||
final redirectUrl = match.group(1)!;
|
||||
debugPrint('NaverApiClient: Meta refresh URL - $redirectUrl');
|
||||
return redirectUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: 프록시 리다이렉션 실패 - $e');
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 지도 HTML 가져오기
|
||||
///
|
||||
/// 웹 환경에서는 CORS 프록시를 사용합니다.
|
||||
Future<String> fetchMapPageHtml(String url) async {
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
return await _fetchViaProxy(url);
|
||||
}
|
||||
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
'Referer': 'https://map.naver.com',
|
||||
},
|
||||
),
|
||||
useCache: false, // 네이버 지도는 동적 콘텐츠이므로 캐시 사용 안함
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: 'HTML을 가져올 수 없습니다',
|
||||
statusCode: response.statusCode ?? 500,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw e.error ??
|
||||
ServerException(message: 'HTML 가져오기 실패', statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프록시를 통한 HTML 가져오기 (웹 환경)
|
||||
Future<String> _fetchViaProxy(String url) async {
|
||||
final proxyUrl =
|
||||
'${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(url)}';
|
||||
|
||||
final response = await _networkClient.get<Map<String, dynamic>>(
|
||||
proxyUrl,
|
||||
options: Options(headers: {'Accept': 'application/json'}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data!;
|
||||
|
||||
// 상태 코드 확인
|
||||
if (data['status'] != null && data['status'] is Map) {
|
||||
final statusMap = data['status'] as Map<String, dynamic>;
|
||||
final httpCode = statusMap['http_code'];
|
||||
if (httpCode != null && httpCode != 200) {
|
||||
throw ServerException(
|
||||
message: '네이버 서버 응답 오류',
|
||||
statusCode: httpCode as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// contents 반환
|
||||
final contents = data['contents'];
|
||||
if (contents == null || contents.toString().isEmpty) {
|
||||
throw ParseException(message: '빈 응답을 받았습니다');
|
||||
}
|
||||
|
||||
return contents.toString();
|
||||
}
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시 요청 실패',
|
||||
statusCode: response.statusCode ?? 500,
|
||||
);
|
||||
}
|
||||
|
||||
/// GraphQL 쿼리 실행
|
||||
///
|
||||
/// 네이버 지도 API의 GraphQL 엔드포인트에 요청을 보냅니다.
|
||||
Future<Map<String, dynamic>> fetchGraphQL({
|
||||
required String operationName,
|
||||
required Map<String, dynamic> variables,
|
||||
required String query,
|
||||
}) async {
|
||||
const String graphqlUrl = 'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
try {
|
||||
final response = await _networkClient.post<Map<String, dynamic>>(
|
||||
graphqlUrl,
|
||||
data: {
|
||||
'operationName': operationName,
|
||||
'variables': variables,
|
||||
'query': query,
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
throw ParseException(message: 'GraphQL 응답을 파싱할 수 없습니다');
|
||||
} on DioException catch (e) {
|
||||
throw e.error ??
|
||||
ServerException(message: 'GraphQL 요청 실패', statusCode: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// pcmap URL에서 한글 텍스트 리스트 가져오기
|
||||
///
|
||||
/// restaurant/{ID}/home 형식의 URL에서 모든 한글 텍스트를 추출합니다.
|
||||
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
|
||||
// restaurant 타입 URL 사용
|
||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||
|
||||
try {
|
||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
debugPrint('요청 URL: $pcmapUrl');
|
||||
debugPrint('Place ID: $placeId');
|
||||
|
||||
String html;
|
||||
if (kIsWeb) {
|
||||
// 웹 환경에서는 프록시 사용
|
||||
html = await _fetchViaProxy(pcmapUrl);
|
||||
} else {
|
||||
// 모바일 환경에서는 직접 요청
|
||||
final response = await _networkClient.get<String>(
|
||||
pcmapUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://map.naver.com/',
|
||||
},
|
||||
),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 || response.data == null) {
|
||||
debugPrint(
|
||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||
);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'HTTP ${response.statusCode}',
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
|
||||
html = response.data!;
|
||||
}
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
||||
debugPrint('Apollo State 상호명: $apolloName');
|
||||
debugPrint('=====================================');
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
'placeId': placeId,
|
||||
'url': pcmapUrl,
|
||||
'koreanTexts': koreanTexts,
|
||||
'jsonLdName': jsonLdName,
|
||||
'apolloStateName': apolloName,
|
||||
'extractedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
||||
return {
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
'koreanTexts': <String>[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 최종 리디렉션 URL 획득
|
||||
///
|
||||
/// 주어진 URL이 리디렉션되는 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 중 - $url');
|
||||
|
||||
// 429 에러 방지를 위한 지연
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final response = await _networkClient.get(
|
||||
url,
|
||||
options: Options(
|
||||
followRedirects: true,
|
||||
maxRedirects: 5,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
final finalUrl = response.realUri.toString();
|
||||
debugPrint('NaverApiClient: 최종 리디렉션 URL - $finalUrl');
|
||||
|
||||
return finalUrl;
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 실패 - $e');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_networkClient.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 로컬 검색 결과
|
||||
class NaverLocalSearchResult {
|
||||
final String title;
|
||||
final String link;
|
||||
final String category;
|
||||
final String description;
|
||||
final String telephone;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final int mapx; // 경도 (x좌표)
|
||||
final int mapy; // 위도 (y좌표)
|
||||
|
||||
NaverLocalSearchResult({
|
||||
required this.title,
|
||||
required this.link,
|
||||
required this.category,
|
||||
required this.description,
|
||||
required this.telephone,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
required this.mapx,
|
||||
required this.mapy,
|
||||
});
|
||||
|
||||
factory NaverLocalSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
return NaverLocalSearchResult(
|
||||
title: _removeHtmlTags(json['title'] ?? ''),
|
||||
link: json['link'] ?? '',
|
||||
category: json['category'] ?? '',
|
||||
description: _removeHtmlTags(json['description'] ?? ''),
|
||||
telephone: json['telephone'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
roadAddress: json['roadAddress'] ?? '',
|
||||
mapx: int.tryParse(json['mapx']?.toString() ?? '0') ?? 0,
|
||||
mapy: int.tryParse(json['mapy']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// HTML 태그 제거
|
||||
static String _removeHtmlTags(String text) {
|
||||
return text.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
}
|
||||
|
||||
/// 위도 (십진도)
|
||||
double get latitude => mapy / 10000000.0;
|
||||
|
||||
/// 경도 (십진도)
|
||||
double get longitude => mapx / 10000000.0;
|
||||
|
||||
/// Restaurant 엔티티로 변환
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
// 카테고리 파싱
|
||||
final categories = category.split('>').map((c) => c.trim()).toList();
|
||||
final mainCategory = categories.isNotEmpty ? categories.first : '기타';
|
||||
final subCategory = categories.length > 1 ? categories.last : mainCategory;
|
||||
|
||||
return Restaurant(
|
||||
id: id,
|
||||
name: title,
|
||||
category: mainCategory,
|
||||
subCategory: subCategory,
|
||||
description: description.isNotEmpty ? description : null,
|
||||
phoneNumber: telephone.isNotEmpty ? telephone : null,
|
||||
roadAddress: roadAddress.isNotEmpty ? roadAddress : address,
|
||||
jibunAddress: address,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastVisitDate: null,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: null,
|
||||
naverUrl: link.isNotEmpty ? link : null,
|
||||
businessHours: null,
|
||||
lastVisited: null,
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/data/datasources/remote/naver_html_extractor.dart
Normal file
253
lib/data/datasources/remote/naver_html_extractor.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
|
||||
class NaverHtmlExtractor {
|
||||
// 제외할 UI 텍스트 패턴 (확장)
|
||||
static const List<String> _excludePatterns = [
|
||||
'로그인', '메뉴', '검색', '지도', '리뷰', '사진', '네이버', '영업시간',
|
||||
'전화번호', '주소', '찾아오시는길', '예약', '홈', '이용약관', '개인정보',
|
||||
'고객센터', '신고', '공유', '즐겨찾기', '길찾기', '거리뷰', '저장',
|
||||
'더보기', '접기', '펼치기', '닫기', '취소', '확인', '선택', '전체', '삭제',
|
||||
'플레이스', '지도보기', '상세보기', '평점', '별점', '추천', '인기', '최신',
|
||||
'오늘', '내일', '영업중', '영업종료', '휴무', '정기휴무', '임시휴무',
|
||||
'배달', '포장', '매장', '주차', '단체석', '예약가능', '대기', '웨이팅',
|
||||
'영수증', '현금', '카드', '계산서', '할인', '쿠폰', '적립', '포인트',
|
||||
'회원', '비회원', '로그아웃', '마이페이지', '알림', '설정', '도움말',
|
||||
'문의', '제보', '수정', '삭제', '등록', '작성', '댓글', '답글', '좋아요',
|
||||
'싫어요', '스크랩', '북마크', '태그', '해시태그', '팔로우', '팔로잉',
|
||||
'팔로워', '차단', '신고하기', '게시물', '프로필', '활동', '통계', '분석',
|
||||
'다운로드', '업로드', '첨부', '파일', '이미지', '동영상', '음성', '링크',
|
||||
'복사', '붙여넣기', '되돌리기', '다시실행', '새로고침', '뒤로', '앞으로',
|
||||
'시작', '종료', '일시정지', '재생', '정지', '음량', '화면', '전체화면',
|
||||
'최소화', '최대화', '창닫기', '새창', '새탭', '인쇄', '저장하기', '열기',
|
||||
'가져오기', '내보내기', '동기화', '백업', '복원', '초기화', '재설정',
|
||||
'업데이트', '버전', '정보', '소개', '안내', '공지', '이벤트', '혜택',
|
||||
'쿠키', '개인정보처리방침', '서비스이용약관', '위치정보이용약관',
|
||||
'청소년보호정책', '저작권', '라이선스', '제휴', '광고', '비즈니스',
|
||||
'개발자', 'API', '오픈소스', '기여', '후원', '기부', '결제', '환불',
|
||||
'교환', '반품', '배송', '택배', '운송장', '추적', '도착', '출발',
|
||||
'네이버 지도', '카카오맵', '구글맵', 'T맵', '지도 앱', '내비게이션',
|
||||
'경로', '소요시간', '거리', '도보', '자전거', '대중교통', '자동차',
|
||||
'지하철', '버스', '택시', '기차', '비행기', '선박', '도보', '환승',
|
||||
'출구', '입구', '승강장', '매표소', '화장실', '편의시설', '주차장',
|
||||
'엘리베이터', '에스컬레이터', '계단', '경사로', '점자블록', '휠체어',
|
||||
'유모차', '애완동물', '흡연', '금연', '와이파이', '콘센트', '충전',
|
||||
'PC', '프린터', '팩스', '복사기', '회의실', '세미나실', '강당', '공연장',
|
||||
'전시장', '박물관', '미술관', '도서관', '체육관', '수영장', '운동장',
|
||||
'놀이터', '공원', '산책로', '자전거도로', '등산로', '캠핑장', '낚시터'
|
||||
];
|
||||
|
||||
/// HTML에서 유효한 한글 텍스트 추출 (UI 텍스트 제외)
|
||||
static List<String> extractAllValidKoreanTexts(String html) {
|
||||
// script, style 태그 내용 제거
|
||||
var cleanHtml = html.replaceAll(
|
||||
RegExp(r'<script[^>]*>[\s\S]*?</script>', multiLine: true),
|
||||
'',
|
||||
);
|
||||
cleanHtml = cleanHtml.replaceAll(
|
||||
RegExp(r'<style[^>]*>[\s\S]*?</style>', multiLine: true),
|
||||
'',
|
||||
);
|
||||
|
||||
// 특정 태그의 내용만 추출 (제목, 본문 등 중요 텍스트가 있을 가능성이 높은 태그)
|
||||
final contentTags = [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'span', 'div', 'li', 'td', 'th',
|
||||
'strong', 'em', 'b', 'i', 'a'
|
||||
];
|
||||
|
||||
final tagPattern = contentTags.map((tag) =>
|
||||
'<$tag[^>]*>([^<]+)</$tag>'
|
||||
).join('|');
|
||||
|
||||
final tagRegex = RegExp(tagPattern, multiLine: true, caseSensitive: false);
|
||||
final tagMatches = tagRegex.allMatches(cleanHtml);
|
||||
|
||||
// 추출된 텍스트 수집
|
||||
final extractedTexts = <String>[];
|
||||
|
||||
for (final match in tagMatches) {
|
||||
final text = match.group(1)?.trim() ?? '';
|
||||
if (text.isNotEmpty && text.contains(RegExp(r'[가-힣]'))) {
|
||||
extractedTexts.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 태그 제거 후 남은 텍스트도 추가
|
||||
final textOnly = cleanHtml.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
final cleanedText = textOnly.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
// 한글 텍스트 추출
|
||||
final koreanPattern = RegExp(r'[가-힣]+(?:\s[가-힣]+)*');
|
||||
final koreanMatches = koreanPattern.allMatches(cleanedText);
|
||||
|
||||
for (final match in koreanMatches) {
|
||||
final text = match.group(0)?.trim() ?? '';
|
||||
if (text.length >= 2) {
|
||||
extractedTexts.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거 및 필터링
|
||||
final uniqueTexts = <String>{};
|
||||
|
||||
for (final text in extractedTexts) {
|
||||
// UI 패턴 제외
|
||||
bool isExcluded = false;
|
||||
for (final pattern in _excludePatterns) {
|
||||
if (text == pattern || text.startsWith(pattern) || text.endsWith(pattern)) {
|
||||
isExcluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExcluded && text.length >= 2 && text.length <= 50) {
|
||||
uniqueTexts.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
/// JSON-LD 데이터에서 장소명 추출
|
||||
static String? extractPlaceNameFromJsonLd(String html) {
|
||||
try {
|
||||
// JSON-LD 스크립트 태그 찾기
|
||||
final jsonLdRegex = RegExp(
|
||||
'<script[^>]*type="application/ld\\+json"[^>]*>([\\s\\S]*?)</script>',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
final matches = jsonLdRegex.allMatches(html);
|
||||
for (final match in matches) {
|
||||
final jsonString = match.group(1);
|
||||
if (jsonString == null) continue;
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> json = jsonDecode(jsonString);
|
||||
|
||||
// Restaurant 타입 확인
|
||||
if (json['@type'] == 'Restaurant' ||
|
||||
json['@type'] == 'LocalBusiness') {
|
||||
final name = json['name'] as String?;
|
||||
if (name != null && name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// @graph 배열 확인
|
||||
if (json['@graph'] is List) {
|
||||
final graph = json['@graph'] as List;
|
||||
for (final item in graph) {
|
||||
if (item is Map<String, dynamic> &&
|
||||
(item['@type'] == 'Restaurant' ||
|
||||
item['@type'] == 'LocalBusiness')) {
|
||||
final name = item['name'] as String?;
|
||||
if (name != null && name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패, 다음 매치로 이동
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Apollo State에서 장소명 추출
|
||||
static String? extractPlaceNameFromApolloState(String html) {
|
||||
try {
|
||||
// window.__APOLLO_STATE__ 패턴 찾기
|
||||
final apolloRegex = RegExp(
|
||||
'window\\.__APOLLO_STATE__\\s*=\\s*\\{([\\s\\S]*?)\\};',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
final match = apolloRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final apolloJson = match.group(1);
|
||||
if (apolloJson != null) {
|
||||
try {
|
||||
final Map<String, dynamic> apolloState = jsonDecode(
|
||||
'{$apolloJson}',
|
||||
);
|
||||
|
||||
// Place 객체들 찾기
|
||||
for (final entry in apolloState.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
// 'name' 필드가 있는 Place 객체 찾기
|
||||
if (value['__typename'] == 'Place' ||
|
||||
value['__typename'] == 'Restaurant') {
|
||||
final name = value['name'] as String?;
|
||||
if (name != null &&
|
||||
name.isNotEmpty &&
|
||||
!name.contains('네이버')) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패
|
||||
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// HTML에서 Place URL 추출 (og:url 메타 태그)
|
||||
static String? extractPlaceLink(String html) {
|
||||
try {
|
||||
// og:url 메타 태그에서 추출
|
||||
final ogUrlRegex = RegExp(
|
||||
r'<meta[^>]+property="og:url"[^>]+content="([^"]+)"',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final match = ogUrlRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final url = match.group(1);
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
// canonical 링크 태그에서 추출
|
||||
final canonicalRegex = RegExp(
|
||||
r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final canonicalMatch = canonicalRegex.firstMatch(html);
|
||||
if (canonicalMatch != null) {
|
||||
final url = canonicalMatch.group(1);
|
||||
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
305
lib/data/datasources/remote/naver_html_parser.dart
Normal file
305
lib/data/datasources/remote/naver_html_parser.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
/// 네이버 지도 페이지의 HTML에서 식당 정보를 추출합니다.
|
||||
class NaverHtmlParser {
|
||||
// CSS 셀렉터 상수
|
||||
static const List<String> _nameSelectors = [
|
||||
'span.GHAhO',
|
||||
'h1.Qpe7b',
|
||||
'span.Fc1rA',
|
||||
'[class*="place_name"]',
|
||||
'meta[property="og:title"]',
|
||||
];
|
||||
|
||||
static const List<String> _categorySelectors = [
|
||||
'span.DJJvD',
|
||||
'span.lnJFt',
|
||||
'[class*="category"]',
|
||||
];
|
||||
|
||||
static const List<String> _descriptionSelectors = [
|
||||
'span.IH7VW',
|
||||
'div.vV_z_',
|
||||
'meta[name="description"]',
|
||||
];
|
||||
|
||||
static const List<String> _phoneSelectors = [
|
||||
'span.xlx7Q',
|
||||
'a[href^="tel:"]',
|
||||
'[class*="phone"]',
|
||||
];
|
||||
|
||||
static const List<String> _addressSelectors = [
|
||||
'span.IH7VW',
|
||||
'span.jWDO_',
|
||||
'[class*="address"]',
|
||||
];
|
||||
|
||||
static const List<String> _businessHoursSelectors = [
|
||||
'time.aT6WB',
|
||||
'div.O8qbU',
|
||||
'[class*="business"]',
|
||||
'[class*="hours"]',
|
||||
];
|
||||
|
||||
/// HTML 문서에서 식당 정보 추출
|
||||
Map<String, dynamic> parseRestaurantInfo(Document document) {
|
||||
return {
|
||||
'name': extractName(document),
|
||||
'category': extractCategory(document),
|
||||
'subCategory': extractSubCategory(document),
|
||||
'description': extractDescription(document),
|
||||
'phone': extractPhoneNumber(document),
|
||||
'roadAddress': extractRoadAddress(document),
|
||||
'address': extractJibunAddress(document),
|
||||
'latitude': extractLatitude(document),
|
||||
'longitude': extractLongitude(document),
|
||||
'businessHours': extractBusinessHours(document),
|
||||
};
|
||||
}
|
||||
|
||||
/// 식당 이름 추출
|
||||
String? extractName(Document document) {
|
||||
try {
|
||||
for (final selector in _nameSelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
if (element.localName == 'meta') {
|
||||
return element.attributes['content'];
|
||||
}
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 카테고리 추출
|
||||
String? extractCategory(Document document) {
|
||||
try {
|
||||
for (final selector in _categorySelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
// 첫 번째 카테고리만 추출 (예: "한식 > 국밥" -> "한식")
|
||||
return text.split('>').first.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 서브 카테고리 추출
|
||||
String? extractSubCategory(Document document) {
|
||||
try {
|
||||
final element = document.querySelector('span.DJJvD, span.lnJFt');
|
||||
if (element != null) {
|
||||
final text = element.text.trim();
|
||||
if (text.contains('>')) {
|
||||
// 두 번째 카테고리 반환 (예: "한식 > 국밥" -> "국밥")
|
||||
return text.split('>').last.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 설명 추출
|
||||
String? extractDescription(Document document) {
|
||||
try {
|
||||
for (final selector in _descriptionSelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
if (element.localName == 'meta') {
|
||||
return element.attributes['content'];
|
||||
}
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 전화번호 추출
|
||||
String? extractPhoneNumber(Document document) {
|
||||
try {
|
||||
for (final selector in _phoneSelectors) {
|
||||
final element = document.querySelector(selector);
|
||||
if (element != null) {
|
||||
if (element.localName == 'a' && element.attributes['href'] != null) {
|
||||
return element.attributes['href']!.replaceFirst('tel:', '');
|
||||
}
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty && RegExp(r'[\d\-\+\(\)]+').hasMatch(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 도로명 주소 추출
|
||||
String? extractRoadAddress(Document document) {
|
||||
try {
|
||||
for (final selector in _addressSelectors) {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 도로명 주소 패턴 확인
|
||||
if (text.contains('로') || text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 지번 주소 추출
|
||||
String? extractJibunAddress(Document document) {
|
||||
try {
|
||||
for (final selector in _addressSelectors) {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 지번 주소 패턴 확인 (숫자-숫자 형식 포함)
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
!text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 위도 추출
|
||||
double? extractLatitude(Document document) {
|
||||
try {
|
||||
// 메타 태그에서 좌표 정보 찾기
|
||||
final metaElement = document.querySelector('meta[property="og:url"]');
|
||||
if (metaElement != null) {
|
||||
final content = metaElement.attributes['content'];
|
||||
if (content != null) {
|
||||
// URL에서 좌표 파라미터 추출 (예: ?y=37.5666805)
|
||||
final RegExp latRegex = RegExp(r'[?&]y=(\d+\.\d+)');
|
||||
final match = latRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
final content = script.text;
|
||||
if (content.contains('latitude') || content.contains('lat')) {
|
||||
final RegExp latRegex = RegExp(r'(?:latitude|lat)["\s:]+(\d+\.\d+)');
|
||||
final match = latRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 경도 추출
|
||||
double? extractLongitude(Document document) {
|
||||
try {
|
||||
// 메타 태그에서 좌표 정보 찾기
|
||||
final metaElement = document.querySelector('meta[property="og:url"]');
|
||||
if (metaElement != null) {
|
||||
final content = metaElement.attributes['content'];
|
||||
if (content != null) {
|
||||
// URL에서 좌표 파라미터 추출 (예: ?x=126.9784147)
|
||||
final RegExp lonRegex = RegExp(r'[?&]x=(\d+\.\d+)');
|
||||
final match = lonRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
final content = script.text;
|
||||
if (content.contains('longitude') || content.contains('lng')) {
|
||||
final RegExp lonRegex = RegExp(r'(?:longitude|lng)["\s:]+(\d+\.\d+)');
|
||||
final match = lonRegex.firstMatch(content);
|
||||
if (match != null) {
|
||||
return double.tryParse(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 영업시간 추출
|
||||
String? extractBusinessHours(Document document) {
|
||||
try {
|
||||
for (final selector in _businessHoursSelectors) {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
669
lib/data/datasources/remote/naver_map_parser.dart
Normal file
669
lib/data/datasources/remote/naver_map_parser.dart
Normal file
@@ -0,0 +1,669 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../api/naver_api_client.dart';
|
||||
import '../../api/naver/naver_local_search_api.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
import 'naver_html_parser.dart';
|
||||
import '../../api/naver/naver_graphql_queries.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
|
||||
/// 네이버 지도 URL 파서
|
||||
/// 네이버 지도 URL에서 식당 정보를 추출합니다.
|
||||
/// NaverApiClient를 사용하여 네트워크 통신을 처리합니다.
|
||||
class NaverMapParser {
|
||||
// URL 관련 상수
|
||||
static const String _naverMapBaseUrl = 'https://map.naver.com';
|
||||
|
||||
// 정규식 패턴
|
||||
static final RegExp _placeIdRegex = RegExp(r'/p/(?:restaurant|entry/place)/(\d+)');
|
||||
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
||||
|
||||
// 기본 좌표 (서울 시청)
|
||||
static const double _defaultLatitude = 37.5666805;
|
||||
static const double _defaultLongitude = 126.9784147;
|
||||
|
||||
// API 요청 관련 상수
|
||||
static const int _shortDelayMillis = 500;
|
||||
static const int _longDelayMillis = 1000;
|
||||
static const int _searchDisplayCount = 10;
|
||||
static const double _coordinateConversionFactor = 10000000.0;
|
||||
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverHtmlParser _htmlParser = NaverHtmlParser();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
NaverMapParser({NaverApiClient? apiClient})
|
||||
: _apiClient = apiClient ?? NaverApiClient();
|
||||
|
||||
/// 네이버 지도 URL에서 식당 정보를 파싱합니다.
|
||||
///
|
||||
/// 지원하는 URL 형식:
|
||||
/// - https://map.naver.com/p/restaurant/1234567890
|
||||
/// - https://naver.me/abcdefgh
|
||||
///
|
||||
/// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때
|
||||
/// 가장 가까운 위치의 식당을 선택합니다.
|
||||
Future<Restaurant> parseRestaurantFromUrl(
|
||||
String url, {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Starting to parse URL: $url');
|
||||
}
|
||||
|
||||
// URL 유효성 검증
|
||||
if (!_isValidNaverUrl(url)) {
|
||||
throw NaverMapParseException('유효하지 않은 네이버 지도 URL입니다: $url');
|
||||
}
|
||||
|
||||
// 짧은 URL인 경우 리다이렉트 처리
|
||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
|
||||
}
|
||||
|
||||
// Place ID 추출 (10자리 숫자)
|
||||
final String? placeId = _extractPlaceId(finalUrl);
|
||||
if (placeId == null) {
|
||||
// 짧은 URL에서 직접 ID 추출 시도
|
||||
final shortUrlId = _extractShortUrlId(url);
|
||||
if (shortUrlId != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Using short URL ID as place ID: $shortUrlId');
|
||||
}
|
||||
return _createFallbackRestaurant(shortUrlId, url);
|
||||
}
|
||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||
}
|
||||
|
||||
// 단축 URL인 경우 특별 처리
|
||||
final isShortUrl = url.contains('naver.me');
|
||||
|
||||
if (isShortUrl) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
try {
|
||||
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
||||
final restaurant = await _parseWithLocalSearch(placeId, finalUrl, userLatitude, userLongitude);
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
}
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e');
|
||||
}
|
||||
// 실패 시 기본 파싱으로 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
|
||||
final restaurantData = await _fetchRestaurantFromGraphQL(
|
||||
placeId,
|
||||
userLatitude: userLatitude,
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
rethrow;
|
||||
}
|
||||
if (e is RateLimitException) {
|
||||
rethrow;
|
||||
}
|
||||
if (e is NetworkException) {
|
||||
throw NaverMapParseException('네트워크 오류: ${e.message}');
|
||||
}
|
||||
throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// URL이 유효한 네이버 지도 URL인지 확인
|
||||
bool _isValidNaverUrl(String url) {
|
||||
try {
|
||||
final Uri uri = Uri.parse(url);
|
||||
return uri.host.contains('naver.com') || uri.host.contains('naver.me');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// _resolveFinalUrl 메서드는 이제 NaverApiClient.resolveShortUrl로 대체됨
|
||||
|
||||
/// URL에서 Place ID 추출
|
||||
String? _extractPlaceId(String url) {
|
||||
final match = _placeIdRegex.firstMatch(url);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
/// 짧은 URL에서 ID 추출
|
||||
String? _extractShortUrlId(String url) {
|
||||
try {
|
||||
final match = _shortUrlRegex.firstMatch(url);
|
||||
return match?.group(1);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// GraphQL API로 식당 정보 가져오기
|
||||
Future<Map<String, dynamic>> _fetchRestaurantFromGraphQL(
|
||||
String placeId, {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
// 심플한 접근: URL로 직접 검색
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 시작');
|
||||
}
|
||||
|
||||
// 네이버 지도 URL 구성
|
||||
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
|
||||
// Step 1: URL 자체로 검색 (가장 신뢰할 수 있는 방법)
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: placeUrl,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
// place ID가 포함된 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
if (result.link.contains(placeId)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}');
|
||||
}
|
||||
return _convertSearchResultToData(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}');
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Place ID로 검색
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _longDelayMillis)); // 더 긴 지연
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: placeId,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}');
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
||||
}
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
retryAfter: e.response?.headers['retry-after']?.firstOrNull,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
||||
}
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
retryAfter: e.response?.headers['retry-after']?.firstOrNull,
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||
// 첫 번째 시도: places 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Trying places query...');
|
||||
}
|
||||
final response = await _apiClient.fetchGraphQL(
|
||||
operationName: 'getPlaceDetail',
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.placeDetailQuery,
|
||||
);
|
||||
|
||||
// places 응답 처리 (배열일 수도 있음)
|
||||
final placesData = response['data']?['places'];
|
||||
if (placesData != null) {
|
||||
if (placesData is List && placesData.isNotEmpty) {
|
||||
return _extractPlaceData(placesData.first as Map<String, dynamic>);
|
||||
} else if (placesData is Map) {
|
||||
return _extractPlaceData(placesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: places query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 두 번째 시도: nxPlaces 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Trying nxPlaces query...');
|
||||
}
|
||||
final response = await _apiClient.fetchGraphQL(
|
||||
operationName: 'getPlaceDetail',
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||
);
|
||||
|
||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||
final nxPlacesData = response['data']?['nxPlaces'];
|
||||
if (nxPlacesData != null) {
|
||||
if (nxPlacesData is List && nxPlacesData.isNotEmpty) {
|
||||
return _extractPlaceData(nxPlacesData.first as Map<String, dynamic>);
|
||||
} else if (nxPlacesData is Map) {
|
||||
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: All GraphQL queries failed, falling back to HTML parsing');
|
||||
}
|
||||
return await _fallbackToHtmlParsing(placeId);
|
||||
}
|
||||
|
||||
/// 검색 결과를 데이터 맵으로 변환
|
||||
Map<String, dynamic> _convertSearchResultToData(NaverLocalSearchResult item) {
|
||||
// 카테고리 파싱
|
||||
final categoryParts = item.category.split('>').map((s) => s.trim()).toList();
|
||||
final category = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : category;
|
||||
|
||||
return {
|
||||
'name': item.title,
|
||||
'category': category,
|
||||
'subCategory': subCategory,
|
||||
'address': item.address,
|
||||
'roadAddress': item.roadAddress,
|
||||
'phone': item.telephone,
|
||||
'description': item.description.isNotEmpty ? item.description : null,
|
||||
'latitude': item.mapy != null ? item.mapy! / _coordinateConversionFactor : _defaultLatitude,
|
||||
'longitude': item.mapx != null ? item.mapx! / _coordinateConversionFactor : _defaultLongitude,
|
||||
'businessHours': null, // Search API에서는 영업시간 정보 제공 안 함
|
||||
};
|
||||
}
|
||||
|
||||
/// GraphQL 응답에서 데이터 추출
|
||||
Map<String, dynamic> _extractPlaceData(Map<String, dynamic> placeData) {
|
||||
// 카테고리 파싱
|
||||
final String? fullCategory = placeData['category'];
|
||||
String? category;
|
||||
String? subCategory;
|
||||
|
||||
if (fullCategory != null) {
|
||||
final categoryParts = fullCategory.split('>').map((s) => s.trim()).toList();
|
||||
category = categoryParts.isNotEmpty ? categoryParts.first : null;
|
||||
subCategory = categoryParts.length > 1 ? categoryParts.last : null;
|
||||
}
|
||||
|
||||
return {
|
||||
'name': placeData['name'],
|
||||
'category': category,
|
||||
'subCategory': subCategory,
|
||||
'address': placeData['address'],
|
||||
'roadAddress': placeData['roadAddress'],
|
||||
'phone': placeData['phone'] ?? placeData['virtualPhone'],
|
||||
'description': placeData['description'],
|
||||
'latitude': placeData['location']?['lat'],
|
||||
'longitude': placeData['location']?['lng'],
|
||||
'businessHours': placeData['businessHours']?.isNotEmpty == true
|
||||
? placeData['businessHours'][0]['description']
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/// HTML 파싱으로 fallback
|
||||
Future<Map<String, dynamic>> _fallbackToHtmlParsing(String placeId) async {
|
||||
try {
|
||||
final finalUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
final String html = await _apiClient.fetchMapPageHtml(finalUrl);
|
||||
final document = html_parser.parse(html);
|
||||
|
||||
return _htmlParser.parseRestaurantInfo(document);
|
||||
} catch (e) {
|
||||
// 429 에러인 경우 RateLimitException으로 변환
|
||||
if (e.toString().contains('429')) {
|
||||
throw RateLimitException(
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restaurant 객체 생성
|
||||
Restaurant _createRestaurant(
|
||||
Map<String, dynamic> data,
|
||||
String placeId,
|
||||
String url,
|
||||
) {
|
||||
// 데이터 추출 및 기본값 처리
|
||||
final String name = data['name'] ?? '네이버 지도 장소 #$placeId';
|
||||
final String rawCategory = data['category'] ?? '음식점';
|
||||
final String? rawSubCategory = data['subCategory'];
|
||||
final String? description = data['description'];
|
||||
final String? phoneNumber = data['phone'];
|
||||
final String roadAddress = data['roadAddress'] ?? '';
|
||||
final String jibunAddress = data['address'] ?? '';
|
||||
final double? latitude = data['latitude'];
|
||||
final double? longitude = data['longitude'];
|
||||
final String? businessHours = data['businessHours'];
|
||||
|
||||
// 카테고리 정규화
|
||||
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(rawCategory, rawSubCategory);
|
||||
final String finalSubCategory = rawSubCategory ?? rawCategory;
|
||||
|
||||
// 좌표가 없는 경우 기본값 설정
|
||||
final double finalLatitude = latitude ?? _defaultLatitude;
|
||||
final double finalLongitude = longitude ?? _defaultLongitude;
|
||||
|
||||
// 주소가 비어있는 경우 처리
|
||||
final String finalRoadAddress = roadAddress.isNotEmpty ? roadAddress : '주소 정보를 가져올 수 없습니다';
|
||||
final String finalJibunAddress = jibunAddress.isNotEmpty ? jibunAddress : '주소 정보를 가져올 수 없습니다';
|
||||
|
||||
return Restaurant(
|
||||
id: _uuid.v4(),
|
||||
name: name,
|
||||
category: normalizedCategory,
|
||||
subCategory: finalSubCategory,
|
||||
description: description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: finalRoadAddress,
|
||||
jibunAddress: finalJibunAddress,
|
||||
latitude: finalLatitude,
|
||||
longitude: finalLongitude,
|
||||
lastVisitDate: null,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: placeId,
|
||||
naverUrl: url,
|
||||
businessHours: businessHours,
|
||||
lastVisited: null,
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 기본 정보로 Restaurant 생성 (Fallback)
|
||||
Restaurant _createFallbackRestaurant(String placeId, String url) {
|
||||
return Restaurant(
|
||||
id: _uuid.v4(),
|
||||
name: '네이버 지도 장소 #$placeId',
|
||||
category: '음식점',
|
||||
subCategory: '음식점',
|
||||
description: '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
phoneNumber: null,
|
||||
roadAddress: '주소 정보를 가져올 수 없습니다',
|
||||
jibunAddress: '주소 정보를 가져올 수 없습니다',
|
||||
latitude: _defaultLatitude,
|
||||
longitude: _defaultLongitude,
|
||||
lastVisitDate: null,
|
||||
source: DataSource.NAVER,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: placeId,
|
||||
naverUrl: url,
|
||||
businessHours: null,
|
||||
lastVisited: null,
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 단축 URL을 위한 향상된 파싱 메서드
|
||||
/// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득
|
||||
Future<Restaurant> _parseWithLocalSearch(
|
||||
String placeId,
|
||||
String finalUrl,
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
// 1. 한글 텍스트 추출
|
||||
final koreanData = await _apiClient.fetchKoreanTextsFromPcmap(placeId);
|
||||
|
||||
if (koreanData['success'] != true || koreanData['koreanTexts'] == null) {
|
||||
throw NaverMapParseException('한글 텍스트 추출 실패');
|
||||
}
|
||||
|
||||
final koreanTexts = koreanData['koreanTexts'] as List<dynamic>;
|
||||
|
||||
// 상호명 우선순위 결정
|
||||
String searchQuery = '';
|
||||
if (koreanData['jsonLdName'] != null) {
|
||||
searchQuery = koreanData['jsonLdName'] as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
||||
}
|
||||
} else if (koreanData['apolloStateName'] != null) {
|
||||
searchQuery = koreanData['apolloStateName'] as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
||||
}
|
||||
} else if (koreanTexts.isNotEmpty) {
|
||||
searchQuery = koreanTexts.first as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
||||
}
|
||||
} else {
|
||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 2. 로컬 검색 API 호출
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: searchQuery,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: 20, // 더 많은 결과 검색
|
||||
);
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
throw NaverMapParseException('검색 결과가 없습니다: $searchQuery');
|
||||
}
|
||||
|
||||
// 디버깅: 검색 결과 Place ID 분석
|
||||
if (kDebugMode) {
|
||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
for (int i = 0; i < searchResults.length; i++) {
|
||||
final result = searchResults[i];
|
||||
final extractedId = result.extractPlaceId();
|
||||
debugPrint('[$i] ${result.title}');
|
||||
debugPrint(' 링크: ${result.link}');
|
||||
debugPrint(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
||||
}
|
||||
debugPrint('=====================================');
|
||||
}
|
||||
|
||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
|
||||
// 1차: Place ID가 정확히 일치하는 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
final extractedId = result.extractPlaceId();
|
||||
if (extractedId == placeId) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2차: 상호명이 유사한 결과 찾기
|
||||
if (bestMatch == null) {
|
||||
// JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용
|
||||
String? exactName = koreanData['jsonLdName'] as String? ??
|
||||
koreanData['apolloStateName'] as String?;
|
||||
|
||||
if (exactName != null) {
|
||||
for (final result in searchResults) {
|
||||
// 상호명 완전 일치 또는 포함 관계 확인
|
||||
if (result.title == exactName ||
|
||||
result.title.contains(exactName) ||
|
||||
exactName.contains(result.title)) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
|
||||
if (bestMatch == null && userLatitude != null && userLongitude != null) {
|
||||
bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude);
|
||||
if (bestMatch != null && kDebugMode) {
|
||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
// 최종: 첫 번째 결과 사용
|
||||
if (bestMatch == null) {
|
||||
bestMatch = searchResults.first;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Restaurant 객체 생성
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
// 추가 정보 보완
|
||||
return restaurant.copyWith(
|
||||
naverPlaceId: placeId,
|
||||
naverUrl: finalUrl,
|
||||
source: DataSource.NAVER,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 가장 가까운 결과 찾기 (거리 기반)
|
||||
NaverLocalSearchResult? _findNearestResult(
|
||||
List<NaverLocalSearchResult> results,
|
||||
double userLat,
|
||||
double userLng,
|
||||
) {
|
||||
NaverLocalSearchResult? nearest;
|
||||
double minDistance = double.infinity;
|
||||
|
||||
for (final result in results) {
|
||||
if (result.mapy != null && result.mapx != null) {
|
||||
// 네이버 좌표를 일반 좌표로 변환
|
||||
final lat = result.mapy! / _coordinateConversionFactor;
|
||||
final lng = result.mapx! / _coordinateConversionFactor;
|
||||
|
||||
// 거리 계산
|
||||
final distance = _calculateDistance(userLat, userLng, lat, lng);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kDebugMode && nearest != null) {
|
||||
debugPrint('가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)');
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/// 두 지점 간의 거리 계산 (Haversine 공식 사용)
|
||||
///
|
||||
/// 반환값: 킬로미터 단위의 거리
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
const double earthRadius = 6371.0; // 지구 반지름 (km)
|
||||
|
||||
// 라디안으로 변환
|
||||
final double lat1Rad = lat1 * (3.141592653589793 / 180.0);
|
||||
final double lon1Rad = lon1 * (3.141592653589793 / 180.0);
|
||||
final double lat2Rad = lat2 * (3.141592653589793 / 180.0);
|
||||
final double lon2Rad = lon2 * (3.141592653589793 / 180.0);
|
||||
|
||||
// 위도와 경도의 차이
|
||||
final double dLat = lat2Rad - lat1Rad;
|
||||
final double dLon = lon2Rad - lon1Rad;
|
||||
|
||||
// Haversine 공식
|
||||
final double a = (sin(dLat / 2) * sin(dLat / 2)) +
|
||||
(cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2) * sin(dLon / 2));
|
||||
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_apiClient.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 지도 파싱 예외
|
||||
class NaverMapParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
NaverMapParseException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'NaverMapParseException: $message';
|
||||
}
|
||||
251
lib/data/datasources/remote/naver_search_service.dart
Normal file
251
lib/data/datasources/remote/naver_search_service.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../../api/naver_api_client.dart';
|
||||
import '../../api/naver/naver_local_search_api.dart';
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
|
||||
/// 네이버 검색 서비스
|
||||
///
|
||||
/// 네이버 지도 URL 파싱과 로컬 검색 API를 통합한 서비스입니다.
|
||||
class NaverSearchService {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
// 성능 최적화를 위한 정규식 캐싱
|
||||
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
|
||||
|
||||
NaverSearchService({
|
||||
NaverApiClient? apiClient,
|
||||
NaverMapParser? mapParser,
|
||||
}) : _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
/// URL에서 식당 정보 가져오기
|
||||
///
|
||||
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
|
||||
///
|
||||
/// [url] 네이버 지도 URL 또는 단축 URL
|
||||
///
|
||||
/// Throws:
|
||||
/// - [NaverMapParseException] URL 파싱 실패 시
|
||||
/// - [NetworkException] 네트워크 오류 발생 시
|
||||
Future<Restaurant> getRestaurantFromUrl(String url) async {
|
||||
try {
|
||||
return await _mapParser.parseRestaurantFromUrl(url);
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException || e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 정보를 가져올 수 없습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 키워드로 주변 식당 검색
|
||||
///
|
||||
/// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<Restaurant>> searchNearbyRestaurants({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int maxResults = 20,
|
||||
String sort = 'random', // random, comment
|
||||
}) async {
|
||||
try {
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
display: maxResults,
|
||||
sort: sort,
|
||||
);
|
||||
|
||||
return searchResults
|
||||
.map((result) => result.toRestaurant(id: _uuid.v4()))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
if (e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 검색에 실패했습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 식당 이름으로 상세 정보 검색
|
||||
///
|
||||
/// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다.
|
||||
Future<Restaurant?> searchRestaurantDetails({
|
||||
required String name,
|
||||
String? address,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
try {
|
||||
// 검색어 구성
|
||||
String query = name;
|
||||
if (address != null && address.isNotEmpty) {
|
||||
// 주소에서 시/구 정보 추출
|
||||
final addressParts = address.split(' ');
|
||||
if (addressParts.length >= 2) {
|
||||
query = '${addressParts[0]} ${addressParts[1]} $name';
|
||||
}
|
||||
}
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
display: 5,
|
||||
sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선
|
||||
);
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 가장 유사한 결과 찾기
|
||||
final bestMatch = _findBestMatch(name, searchResults);
|
||||
|
||||
if (bestMatch != null) {
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
// 네이버 지도 URL이 있으면 상세 정보 파싱 시도
|
||||
if (restaurant.naverUrl != null) {
|
||||
try {
|
||||
final detailedRestaurant = await _mapParser.parseRestaurantFromUrl(
|
||||
restaurant.naverUrl!,
|
||||
);
|
||||
|
||||
// 기존 정보와 병합
|
||||
return Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: detailedRestaurant.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(),
|
||||
naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
} catch (e) {
|
||||
// 상세 파싱 실패해도 기본 정보 반환
|
||||
if (kDebugMode) {
|
||||
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 상세 정보 검색에 실패했습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 가장 유사한 검색 결과 찾기
|
||||
NaverLocalSearchResult? _findBestMatch(
|
||||
String targetName,
|
||||
List<NaverLocalSearchResult> results,
|
||||
) {
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
// 정확히 일치하는 결과 우선
|
||||
final exactMatch = results.firstWhere(
|
||||
(result) => result.title.toLowerCase() == targetName.toLowerCase(),
|
||||
orElse: () => results.first,
|
||||
);
|
||||
|
||||
if (exactMatch.title.toLowerCase() == targetName.toLowerCase()) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// 유사도 계산 (간단한 버전)
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
double bestScore = 0.0;
|
||||
|
||||
for (final result in results) {
|
||||
final score = _calculateSimilarity(targetName, result.title);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = result;
|
||||
}
|
||||
}
|
||||
|
||||
// 유사도가 너무 낮으면 null 반환
|
||||
if (bestScore < 0.5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bestMatch ?? results.first;
|
||||
}
|
||||
|
||||
/// 문자열 유사도 계산 (Jaccard 유사도)
|
||||
double _calculateSimilarity(String str1, String str2) {
|
||||
final s1 = str1.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
|
||||
final s2 = str2.toLowerCase().replaceAll(_nonAlphanumericRegex, '');
|
||||
|
||||
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||
|
||||
// 포함 관계 확인
|
||||
if (s1.contains(s2) || s2.contains(s1)) {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
// 문자 집합으로 변환
|
||||
final set1 = s1.split('').toSet();
|
||||
final set2 = s2.split('').toSet();
|
||||
|
||||
// Jaccard 유사도 계산
|
||||
final intersection = set1.intersection(set2).length;
|
||||
final union = set1.union(set2).length;
|
||||
|
||||
return union > 0 ? intersection / union : 0.0;
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_apiClient.dispose();
|
||||
_mapParser.dispose();
|
||||
}
|
||||
|
||||
// 테스트를 위한 내부 메서드 접근
|
||||
@visibleForTesting
|
||||
NaverLocalSearchResult? findBestMatchForTesting(
|
||||
String targetName,
|
||||
List<NaverLocalSearchResult> results,
|
||||
) {
|
||||
return _findBestMatch(targetName, results);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
double calculateSimilarityForTesting(String str1, String str2) {
|
||||
return _calculateSimilarity(str1, str2);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
lib/domain/entities/recommendation_record.dart
Normal file
29
lib/domain/entities/recommendation_record.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'recommendation_record.g.dart';
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
class RecommendationRecord extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String restaurantId;
|
||||
|
||||
@HiveField(2)
|
||||
final DateTime recommendationDate;
|
||||
|
||||
@HiveField(3)
|
||||
final bool visited;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime createdAt;
|
||||
|
||||
RecommendationRecord({
|
||||
required this.id,
|
||||
required this.restaurantId,
|
||||
required this.recommendationDate,
|
||||
required this.visited,
|
||||
required this.createdAt,
|
||||
});
|
||||
}
|
||||
138
lib/domain/entities/restaurant.dart
Normal file
138
lib/domain/entities/restaurant.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'restaurant.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class Restaurant extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
@HiveField(2)
|
||||
final String category;
|
||||
|
||||
@HiveField(3)
|
||||
final String subCategory;
|
||||
|
||||
@HiveField(4)
|
||||
final String? description;
|
||||
|
||||
@HiveField(5)
|
||||
final String? phoneNumber;
|
||||
|
||||
@HiveField(6)
|
||||
final String roadAddress;
|
||||
|
||||
@HiveField(7)
|
||||
final String jibunAddress;
|
||||
|
||||
@HiveField(8)
|
||||
final double latitude;
|
||||
|
||||
@HiveField(9)
|
||||
final double longitude;
|
||||
|
||||
@HiveField(10)
|
||||
final DateTime? lastVisitDate;
|
||||
|
||||
@HiveField(11)
|
||||
final DataSource source;
|
||||
|
||||
@HiveField(12)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(13)
|
||||
final DateTime updatedAt;
|
||||
|
||||
@HiveField(14)
|
||||
final String? naverPlaceId;
|
||||
|
||||
@HiveField(15)
|
||||
final String? naverUrl;
|
||||
|
||||
@HiveField(16)
|
||||
final String? businessHours;
|
||||
|
||||
@HiveField(17)
|
||||
final DateTime? lastVisited;
|
||||
|
||||
@HiveField(18)
|
||||
final int visitCount;
|
||||
|
||||
Restaurant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.category,
|
||||
required this.subCategory,
|
||||
this.description,
|
||||
this.phoneNumber,
|
||||
required this.roadAddress,
|
||||
required this.jibunAddress,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.lastVisitDate,
|
||||
required this.source,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.naverPlaceId,
|
||||
this.naverUrl,
|
||||
this.businessHours,
|
||||
this.lastVisited,
|
||||
this.visitCount = 0,
|
||||
});
|
||||
|
||||
Restaurant copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? category,
|
||||
String? subCategory,
|
||||
String? description,
|
||||
String? phoneNumber,
|
||||
String? roadAddress,
|
||||
String? jibunAddress,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
DateTime? lastVisitDate,
|
||||
DataSource? source,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? naverPlaceId,
|
||||
String? naverUrl,
|
||||
String? businessHours,
|
||||
DateTime? lastVisited,
|
||||
int? visitCount,
|
||||
}) {
|
||||
return Restaurant(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
category: category ?? this.category,
|
||||
subCategory: subCategory ?? this.subCategory,
|
||||
description: description ?? this.description,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
roadAddress: roadAddress ?? this.roadAddress,
|
||||
jibunAddress: jibunAddress ?? this.jibunAddress,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastVisitDate: lastVisitDate ?? this.lastVisitDate,
|
||||
source: source ?? this.source,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
naverPlaceId: naverPlaceId ?? this.naverPlaceId,
|
||||
naverUrl: naverUrl ?? this.naverUrl,
|
||||
businessHours: businessHours ?? this.businessHours,
|
||||
lastVisited: lastVisited ?? this.lastVisited,
|
||||
visitCount: visitCount ?? this.visitCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@HiveType(typeId: 1)
|
||||
enum DataSource {
|
||||
@HiveField(0)
|
||||
NAVER,
|
||||
|
||||
@HiveField(1)
|
||||
USER_INPUT
|
||||
}
|
||||
11
lib/domain/entities/share_device.dart
Normal file
11
lib/domain/entities/share_device.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
class ShareDevice {
|
||||
final String code;
|
||||
final String deviceId;
|
||||
final DateTime discoveredAt;
|
||||
|
||||
ShareDevice({
|
||||
required this.code,
|
||||
required this.deviceId,
|
||||
required this.discoveredAt,
|
||||
});
|
||||
}
|
||||
45
lib/domain/entities/user_settings.dart
Normal file
45
lib/domain/entities/user_settings.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'user_settings.g.dart';
|
||||
|
||||
@HiveType(typeId: 4)
|
||||
class UserSettings {
|
||||
@HiveField(0)
|
||||
final int revisitPreventionDays;
|
||||
|
||||
@HiveField(1)
|
||||
final bool notificationEnabled;
|
||||
|
||||
@HiveField(2)
|
||||
final String notificationTime;
|
||||
|
||||
@HiveField(3)
|
||||
final Map<String, double> categoryWeights;
|
||||
|
||||
@HiveField(4)
|
||||
final int notificationDelayMinutes;
|
||||
|
||||
UserSettings({
|
||||
this.revisitPreventionDays = 7,
|
||||
this.notificationEnabled = true,
|
||||
this.notificationTime = "14:00",
|
||||
Map<String, double>? categoryWeights,
|
||||
this.notificationDelayMinutes = 90,
|
||||
}) : categoryWeights = categoryWeights ?? {};
|
||||
|
||||
UserSettings copyWith({
|
||||
int? revisitPreventionDays,
|
||||
bool? notificationEnabled,
|
||||
String? notificationTime,
|
||||
Map<String, double>? categoryWeights,
|
||||
int? notificationDelayMinutes,
|
||||
}) {
|
||||
return UserSettings(
|
||||
revisitPreventionDays: revisitPreventionDays ?? this.revisitPreventionDays,
|
||||
notificationEnabled: notificationEnabled ?? this.notificationEnabled,
|
||||
notificationTime: notificationTime ?? this.notificationTime,
|
||||
categoryWeights: categoryWeights ?? this.categoryWeights,
|
||||
notificationDelayMinutes: notificationDelayMinutes ?? this.notificationDelayMinutes,
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/domain/entities/visit_record.dart
Normal file
29
lib/domain/entities/visit_record.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'visit_record.g.dart';
|
||||
|
||||
@HiveType(typeId: 2)
|
||||
class VisitRecord extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String restaurantId;
|
||||
|
||||
@HiveField(2)
|
||||
final DateTime visitDate;
|
||||
|
||||
@HiveField(3)
|
||||
final bool isConfirmed;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime createdAt;
|
||||
|
||||
VisitRecord({
|
||||
required this.id,
|
||||
required this.restaurantId,
|
||||
required this.visitDate,
|
||||
required this.isConfirmed,
|
||||
required this.createdAt,
|
||||
});
|
||||
}
|
||||
21
lib/domain/entities/weather_info.dart
Normal file
21
lib/domain/entities/weather_info.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
class WeatherInfo {
|
||||
final WeatherData current;
|
||||
final WeatherData nextHour;
|
||||
|
||||
WeatherInfo({
|
||||
required this.current,
|
||||
required this.nextHour,
|
||||
});
|
||||
}
|
||||
|
||||
class WeatherData {
|
||||
final int temperature;
|
||||
final bool isRainy;
|
||||
final String description;
|
||||
|
||||
WeatherData({
|
||||
required this.temperature,
|
||||
required this.isRainy,
|
||||
required this.description,
|
||||
});
|
||||
}
|
||||
39
lib/domain/repositories/recommendation_repository.dart
Normal file
39
lib/domain/repositories/recommendation_repository.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
|
||||
abstract class RecommendationRepository {
|
||||
/// 모든 추천 기록을 가져옵니다
|
||||
Future<List<RecommendationRecord>> getAllRecommendationRecords();
|
||||
|
||||
/// 특정 맛집의 추천 기록을 가져옵니다
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId);
|
||||
|
||||
/// 날짜별 추천 기록을 가져옵니다
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(DateTime date);
|
||||
|
||||
/// 날짜 범위로 추천 기록을 가져옵니다
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// 새로운 추천 기록을 추가합니다
|
||||
Future<void> addRecommendationRecord(RecommendationRecord record);
|
||||
|
||||
/// 추천 기록을 업데이트합니다
|
||||
Future<void> updateRecommendationRecord(RecommendationRecord record);
|
||||
|
||||
/// 추천 기록을 삭제합니다
|
||||
Future<void> deleteRecommendationRecord(String id);
|
||||
|
||||
/// 추천 후 방문 여부를 업데이트합니다
|
||||
Future<void> markAsVisited(String recommendationId);
|
||||
|
||||
/// 오늘의 추천 횟수를 가져옵니다
|
||||
Future<int> getTodayRecommendationCount();
|
||||
|
||||
/// 추천 기록을 스트림으로 감시합니다
|
||||
Stream<List<RecommendationRecord>> watchRecommendationRecords();
|
||||
|
||||
/// 월별 추천 통계를 가져옵니다
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month);
|
||||
}
|
||||
49
lib/domain/repositories/restaurant_repository.dart
Normal file
49
lib/domain/repositories/restaurant_repository.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
abstract class RestaurantRepository {
|
||||
/// 모든 맛집 목록을 가져옵니다
|
||||
Future<List<Restaurant>> getAllRestaurants();
|
||||
|
||||
/// 특정 맛집을 ID로 가져옵니다
|
||||
Future<Restaurant?> getRestaurantById(String id);
|
||||
|
||||
/// 새로운 맛집을 추가합니다
|
||||
Future<void> addRestaurant(Restaurant restaurant);
|
||||
|
||||
/// 맛집 정보를 업데이트합니다
|
||||
Future<void> updateRestaurant(Restaurant restaurant);
|
||||
|
||||
/// 맛집을 삭제합니다
|
||||
Future<void> deleteRestaurant(String id);
|
||||
|
||||
/// 카테고리별로 맛집을 가져옵니다
|
||||
Future<List<Restaurant>> getRestaurantsByCategory(String category);
|
||||
|
||||
/// 모든 카테고리 목록을 가져옵니다
|
||||
Future<List<String>> getAllCategories();
|
||||
|
||||
/// 맛집 목록을 스트림으로 감시합니다
|
||||
Stream<List<Restaurant>> watchRestaurants();
|
||||
|
||||
/// 맛집 방문일을 업데이트합니다
|
||||
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate);
|
||||
|
||||
/// 거리 내의 맛집을 가져옵니다
|
||||
Future<List<Restaurant>> getRestaurantsWithinDistance({
|
||||
required double userLatitude,
|
||||
required double userLongitude,
|
||||
required double maxDistanceInMeters,
|
||||
});
|
||||
|
||||
/// 최근 n일 이내에 방문하지 않은 맛집을 가져옵니다
|
||||
Future<List<Restaurant>> getRestaurantsNotVisitedInDays(int days);
|
||||
|
||||
/// 검색어로 맛집을 검색합니다
|
||||
Future<List<Restaurant>> searchRestaurants(String query);
|
||||
|
||||
/// 네이버 지도 URL로부터 맛집을 추가합니다
|
||||
Future<Restaurant> addRestaurantFromUrl(String url);
|
||||
|
||||
/// 네이버 Place ID로 맛집을 찾습니다
|
||||
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId);
|
||||
}
|
||||
60
lib/domain/repositories/settings_repository.dart
Normal file
60
lib/domain/repositories/settings_repository.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:lunchpick/domain/entities/user_settings.dart';
|
||||
|
||||
abstract class SettingsRepository {
|
||||
/// 사용자 설정 전체를 가져옵니다
|
||||
Future<UserSettings> getUserSettings();
|
||||
|
||||
/// 사용자 설정을 업데이트합니다
|
||||
Future<void> updateUserSettings(UserSettings settings);
|
||||
|
||||
/// 재방문 금지 일수를 가져옵니다
|
||||
Future<int> getDaysToExclude();
|
||||
|
||||
/// 재방문 금지 일수를 설정합니다
|
||||
Future<void> setDaysToExclude(int days);
|
||||
|
||||
/// 우천시 최대 거리를 가져옵니다
|
||||
Future<int> getMaxDistanceRainy();
|
||||
|
||||
/// 우천시 최대 거리를 설정합니다
|
||||
Future<void> setMaxDistanceRainy(int meters);
|
||||
|
||||
/// 평상시 최대 거리를 가져옵니다
|
||||
Future<int> getMaxDistanceNormal();
|
||||
|
||||
/// 평상시 최대 거리를 설정합니다
|
||||
Future<void> setMaxDistanceNormal(int meters);
|
||||
|
||||
/// 알림 시간 설정을 가져옵니다 (분 단위)
|
||||
Future<int> getNotificationDelayMinutes();
|
||||
|
||||
/// 알림 시간을 설정합니다 (분 단위)
|
||||
Future<void> setNotificationDelayMinutes(int minutes);
|
||||
|
||||
/// 알림 활성화 여부를 가져옵니다
|
||||
Future<bool> isNotificationEnabled();
|
||||
|
||||
/// 알림 활성화 여부를 설정합니다
|
||||
Future<void> setNotificationEnabled(bool enabled);
|
||||
|
||||
/// 다크모드 설정을 가져옵니다
|
||||
Future<bool> isDarkModeEnabled();
|
||||
|
||||
/// 다크모드를 설정합니다
|
||||
Future<void> setDarkModeEnabled(bool enabled);
|
||||
|
||||
/// 첫 실행 여부를 확인합니다
|
||||
Future<bool> isFirstRun();
|
||||
|
||||
/// 첫 실행 상태를 업데이트합니다
|
||||
Future<void> setFirstRun(bool isFirst);
|
||||
|
||||
/// 모든 설정을 초기화합니다
|
||||
Future<void> resetSettings();
|
||||
|
||||
/// 설정 변경사항을 스트림으로 감시합니다
|
||||
Stream<Map<String, dynamic>> watchSettings();
|
||||
|
||||
/// UserSettings 변경사항을 스트림으로 감시합니다
|
||||
Stream<UserSettings> watchUserSettings();
|
||||
}
|
||||
42
lib/domain/repositories/visit_repository.dart
Normal file
42
lib/domain/repositories/visit_repository.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
abstract class VisitRepository {
|
||||
/// 모든 방문 기록을 가져옵니다
|
||||
Future<List<VisitRecord>> getAllVisitRecords();
|
||||
|
||||
/// 특정 맛집의 방문 기록을 가져옵니다
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(String restaurantId);
|
||||
|
||||
/// 특정 날짜의 방문 기록을 가져옵니다
|
||||
Future<List<VisitRecord>> getVisitRecordsByDate(DateTime date);
|
||||
|
||||
/// 날짜 범위로 방문 기록을 가져옵니다
|
||||
Future<List<VisitRecord>> getVisitRecordsByDateRange({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// 새로운 방문 기록을 추가합니다
|
||||
Future<void> addVisitRecord(VisitRecord visitRecord);
|
||||
|
||||
/// 방문 기록을 업데이트합니다
|
||||
Future<void> updateVisitRecord(VisitRecord visitRecord);
|
||||
|
||||
/// 방문 기록을 삭제합니다
|
||||
Future<void> deleteVisitRecord(String id);
|
||||
|
||||
/// 방문 확인 상태를 업데이트합니다
|
||||
Future<void> confirmVisit(String visitRecordId);
|
||||
|
||||
/// 방문 기록을 스트림으로 감시합니다
|
||||
Stream<List<VisitRecord>> watchVisitRecords();
|
||||
|
||||
/// 특정 맛집의 마지막 방문일을 가져옵니다
|
||||
Future<DateTime?> getLastVisitDate(String restaurantId);
|
||||
|
||||
/// 월별 방문 통계를 가져옵니다
|
||||
Future<Map<String, int>> getMonthlyVisitStats(int year, int month);
|
||||
|
||||
/// 카테고리별 방문 통계를 가져옵니다
|
||||
Future<Map<String, int>> getCategoryVisitStats();
|
||||
}
|
||||
21
lib/domain/repositories/weather_repository.dart
Normal file
21
lib/domain/repositories/weather_repository.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
|
||||
abstract class WeatherRepository {
|
||||
/// 현재 위치의 날씨 정보를 가져옵니다
|
||||
Future<WeatherInfo> getCurrentWeather({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
});
|
||||
|
||||
/// 캐시된 날씨 정보를 가져옵니다
|
||||
Future<WeatherInfo?> getCachedWeather();
|
||||
|
||||
/// 날씨 정보를 캐시에 저장합니다
|
||||
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo);
|
||||
|
||||
/// 날씨 캐시를 삭제합니다
|
||||
Future<void> clearWeatherCache();
|
||||
|
||||
/// 날씨 정보 업데이트가 필요한지 확인합니다
|
||||
Future<bool> isWeatherUpdateNeeded();
|
||||
}
|
||||
257
lib/domain/usecases/recommendation_engine.dart
Normal file
257
lib/domain/usecases/recommendation_engine.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
import 'dart:math';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/user_settings.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
|
||||
/// 추천 엔진 설정
|
||||
class RecommendationConfig {
|
||||
final double userLatitude;
|
||||
final double userLongitude;
|
||||
final double maxDistance;
|
||||
final List<String> selectedCategories;
|
||||
final UserSettings userSettings;
|
||||
final WeatherInfo? weather;
|
||||
final DateTime currentTime;
|
||||
|
||||
RecommendationConfig({
|
||||
required this.userLatitude,
|
||||
required this.userLongitude,
|
||||
required this.maxDistance,
|
||||
required this.selectedCategories,
|
||||
required this.userSettings,
|
||||
this.weather,
|
||||
DateTime? currentTime,
|
||||
}) : currentTime = currentTime ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// 추천 엔진 UseCase
|
||||
class RecommendationEngine {
|
||||
final Random _random = Random();
|
||||
|
||||
/// 추천 생성
|
||||
Future<Restaurant?> generateRecommendation({
|
||||
required List<Restaurant> allRestaurants,
|
||||
required List<VisitRecord> recentVisits,
|
||||
required RecommendationConfig config,
|
||||
}) async {
|
||||
// 1단계: 거리 필터링
|
||||
final restaurantsInRange = _filterByDistance(allRestaurants, config);
|
||||
if (restaurantsInRange.isEmpty) return null;
|
||||
|
||||
// 2단계: 재방문 방지 필터링
|
||||
final eligibleRestaurants = _filterByRevisitPrevention(
|
||||
restaurantsInRange,
|
||||
recentVisits,
|
||||
config.userSettings.revisitPreventionDays,
|
||||
);
|
||||
if (eligibleRestaurants.isEmpty) return null;
|
||||
|
||||
// 3단계: 카테고리 필터링
|
||||
final filteredByCategory = _filterByCategory(eligibleRestaurants, config.selectedCategories);
|
||||
if (filteredByCategory.isEmpty) return null;
|
||||
|
||||
// 4단계: 가중치 계산 및 선택
|
||||
return _selectWithWeights(filteredByCategory, config);
|
||||
}
|
||||
|
||||
/// 거리 기반 필터링
|
||||
List<Restaurant> _filterByDistance(List<Restaurant> restaurants, RecommendationConfig config) {
|
||||
// 날씨에 따른 최대 거리 조정
|
||||
double effectiveMaxDistance = config.maxDistance;
|
||||
if (config.weather != null && config.weather!.current.isRainy) {
|
||||
// 비가 올 때는 거리를 70%로 줄임
|
||||
effectiveMaxDistance *= 0.7;
|
||||
}
|
||||
|
||||
return restaurants.where((restaurant) {
|
||||
final distance = DistanceCalculator.calculateDistance(
|
||||
lat1: config.userLatitude,
|
||||
lon1: config.userLongitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
return distance <= effectiveMaxDistance;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 재방문 방지 필터링
|
||||
List<Restaurant> _filterByRevisitPrevention(
|
||||
List<Restaurant> restaurants,
|
||||
List<VisitRecord> recentVisits,
|
||||
int preventionDays,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final cutoffDate = now.subtract(Duration(days: preventionDays));
|
||||
|
||||
// 최근 n일 내 방문한 식당 ID 수집
|
||||
final recentlyVisitedIds = recentVisits
|
||||
.where((visit) => visit.visitDate.isAfter(cutoffDate))
|
||||
.map((visit) => visit.restaurantId)
|
||||
.toSet();
|
||||
|
||||
// 최근 방문하지 않은 식당만 필터링
|
||||
return restaurants.where((restaurant) {
|
||||
return !recentlyVisitedIds.contains(restaurant.id);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 카테고리 필터링
|
||||
List<Restaurant> _filterByCategory(List<Restaurant> restaurants, List<String> selectedCategories) {
|
||||
if (selectedCategories.isEmpty) {
|
||||
return restaurants;
|
||||
}
|
||||
return restaurants.where((restaurant) {
|
||||
return selectedCategories.contains(restaurant.category);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 가중치 기반 선택
|
||||
Restaurant? _selectWithWeights(List<Restaurant> restaurants, RecommendationConfig config) {
|
||||
if (restaurants.isEmpty) return null;
|
||||
|
||||
// 각 식당에 대한 가중치 계산
|
||||
final weightedRestaurants = restaurants.map((restaurant) {
|
||||
double weight = 1.0;
|
||||
|
||||
// 카테고리 가중치 적용
|
||||
final categoryWeight = config.userSettings.categoryWeights[restaurant.category];
|
||||
if (categoryWeight != null) {
|
||||
weight *= categoryWeight;
|
||||
}
|
||||
|
||||
// 거리 가중치 적용 (가까울수록 높은 가중치)
|
||||
final distance = DistanceCalculator.calculateDistance(
|
||||
lat1: config.userLatitude,
|
||||
lon1: config.userLongitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceWeight = 1.0 - (distance / config.maxDistance);
|
||||
weight *= (0.5 + distanceWeight * 0.5); // 50% ~ 100% 범위
|
||||
|
||||
// 시간대별 가중치 적용
|
||||
weight *= _getTimeBasedWeight(restaurant, config.currentTime);
|
||||
|
||||
// 날씨 기반 가중치 적용
|
||||
if (config.weather != null) {
|
||||
weight *= _getWeatherBasedWeight(restaurant, config.weather!);
|
||||
}
|
||||
|
||||
return _WeightedRestaurant(restaurant, weight);
|
||||
}).toList();
|
||||
|
||||
// 가중치 기반 랜덤 선택
|
||||
return _weightedRandomSelection(weightedRestaurants);
|
||||
}
|
||||
|
||||
/// 시간대별 가중치 계산
|
||||
double _getTimeBasedWeight(Restaurant restaurant, DateTime currentTime) {
|
||||
final hour = currentTime.hour;
|
||||
|
||||
// 아침 시간대 (7-10시)
|
||||
if (hour >= 7 && hour < 10) {
|
||||
if (restaurant.category == 'cafe' || restaurant.category == 'korean') {
|
||||
return 1.2;
|
||||
}
|
||||
if (restaurant.category == 'bar') {
|
||||
return 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// 점심 시간대 (11-14시)
|
||||
else if (hour >= 11 && hour < 14) {
|
||||
if (restaurant.category == 'korean' ||
|
||||
restaurant.category == 'chinese' ||
|
||||
restaurant.category == 'japanese') {
|
||||
return 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
// 저녁 시간대 (17-21시)
|
||||
else if (hour >= 17 && hour < 21) {
|
||||
if (restaurant.category == 'bar' ||
|
||||
restaurant.category == 'western') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 늦은 저녁 (21시 이후)
|
||||
else if (hour >= 21) {
|
||||
if (restaurant.category == 'bar' ||
|
||||
restaurant.category == 'fastfood') {
|
||||
return 1.3;
|
||||
}
|
||||
if (restaurant.category == 'cafe') {
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// 날씨 기반 가중치 계산
|
||||
double _getWeatherBasedWeight(Restaurant restaurant, WeatherInfo weather) {
|
||||
if (weather.current.isRainy) {
|
||||
// 비가 올 때는 가까운 식당 선호
|
||||
// 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호
|
||||
if (restaurant.category == 'cafe' ||
|
||||
restaurant.category == 'fastfood') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 더운 날씨 (25도 이상)
|
||||
if (weather.current.temperature >= 25) {
|
||||
if (restaurant.category == 'cafe' ||
|
||||
restaurant.category == 'japanese') {
|
||||
return 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
// 추운 날씨 (10도 이하)
|
||||
if (weather.current.temperature <= 10) {
|
||||
if (restaurant.category == 'korean' ||
|
||||
restaurant.category == 'chinese') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// 가중치 기반 랜덤 선택
|
||||
Restaurant? _weightedRandomSelection(List<_WeightedRestaurant> weightedRestaurants) {
|
||||
if (weightedRestaurants.isEmpty) return null;
|
||||
|
||||
// 전체 가중치 합계 계산
|
||||
final totalWeight = weightedRestaurants.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + item.weight,
|
||||
);
|
||||
|
||||
// 랜덤 값 생성
|
||||
final randomValue = _random.nextDouble() * totalWeight;
|
||||
|
||||
// 누적 가중치로 선택
|
||||
double cumulativeWeight = 0;
|
||||
for (final weightedRestaurant in weightedRestaurants) {
|
||||
cumulativeWeight += weightedRestaurant.weight;
|
||||
if (randomValue <= cumulativeWeight) {
|
||||
return weightedRestaurant.restaurant;
|
||||
}
|
||||
}
|
||||
|
||||
// 예외 처리 (여기에 도달하면 안됨)
|
||||
return weightedRestaurants.last.restaurant;
|
||||
}
|
||||
}
|
||||
|
||||
/// 가중치가 적용된 식당 모델
|
||||
class _WeightedRestaurant {
|
||||
final Restaurant restaurant;
|
||||
final double weight;
|
||||
|
||||
_WeightedRestaurant(this.restaurant, this.weight);
|
||||
}
|
||||
176
lib/main.dart
Normal file
176
lib/main.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
|
||||
import 'core/constants/app_colors.dart';
|
||||
import 'core/constants/app_constants.dart';
|
||||
import 'core/services/notification_service.dart';
|
||||
import 'domain/entities/restaurant.dart';
|
||||
import 'domain/entities/visit_record.dart';
|
||||
import 'domain/entities/recommendation_record.dart';
|
||||
import 'domain/entities/user_settings.dart';
|
||||
import 'presentation/pages/splash/splash_screen.dart';
|
||||
import 'presentation/pages/main/main_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize timezone
|
||||
tz.initializeTimeZones();
|
||||
|
||||
// Initialize Hive
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Register Hive Adapters
|
||||
Hive.registerAdapter(RestaurantAdapter());
|
||||
Hive.registerAdapter(DataSourceAdapter());
|
||||
Hive.registerAdapter(VisitRecordAdapter());
|
||||
Hive.registerAdapter(RecommendationRecordAdapter());
|
||||
Hive.registerAdapter(UserSettingsAdapter());
|
||||
|
||||
// Open Hive Boxes
|
||||
await Hive.openBox<Restaurant>(AppConstants.restaurantBox);
|
||||
await Hive.openBox<VisitRecord>(AppConstants.visitRecordBox);
|
||||
await Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox);
|
||||
await Hive.openBox(AppConstants.settingsBox);
|
||||
await Hive.openBox<UserSettings>('user_settings');
|
||||
|
||||
// Initialize Notification Service (only for non-web platforms)
|
||||
if (!kIsWeb) {
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
await notificationService.requestPermission();
|
||||
}
|
||||
|
||||
|
||||
// Get saved theme mode
|
||||
final savedThemeMode = await AdaptiveTheme.getThemeMode();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: LunchPickApp(savedThemeMode: savedThemeMode),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class LunchPickApp extends StatelessWidget {
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
|
||||
const LunchPickApp({super.key, this.savedThemeMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.lightPrimary,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
primaryColor: AppColors.lightPrimary,
|
||||
scaffoldBackgroundColor: AppColors.lightBackground,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
dark: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.darkPrimary,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
primaryColor: AppColors.darkPrimary,
|
||||
scaffoldBackgroundColor: AppColors.darkBackground,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.darkPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.darkPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.darkSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.light,
|
||||
builder: (theme, darkTheme) => MaterialApp.router(
|
||||
title: AppConstants.appName,
|
||||
theme: theme,
|
||||
darkTheme: darkTheme,
|
||||
routerConfig: _router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GoRouter configuration
|
||||
final _router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) {
|
||||
final tabParam = state.uri.queryParameters['tab'];
|
||||
int initialTab = 2; // 기본값: 뽑기 탭
|
||||
if (tabParam != null) {
|
||||
switch (tabParam) {
|
||||
case 'share':
|
||||
initialTab = 0;
|
||||
break;
|
||||
case 'list':
|
||||
initialTab = 1;
|
||||
break;
|
||||
case 'random':
|
||||
initialTab = 2;
|
||||
break;
|
||||
case 'calendar':
|
||||
initialTab = 3;
|
||||
break;
|
||||
case 'settings':
|
||||
initialTab = 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return MainScreen(initialTab: initialTab);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
305
lib/presentation/pages/calendar/calendar_screen.dart
Normal file
305
lib/presentation/pages/calendar/calendar_screen.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../domain/entities/visit_record.dart';
|
||||
import '../../providers/visit_provider.dart';
|
||||
import 'widgets/visit_record_card.dart';
|
||||
import 'widgets/visit_statistics.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerStatefulWidget {
|
||||
const CalendarScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CalendarScreen> createState() => _CalendarScreenState();
|
||||
}
|
||||
|
||||
class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTickerProviderStateMixin {
|
||||
late DateTime _selectedDay;
|
||||
late DateTime _focusedDay;
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
late TabController _tabController;
|
||||
Map<DateTime, List<VisitRecord>> _visitRecordEvents = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDay = DateTime.now();
|
||||
_focusedDay = DateTime.now();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<VisitRecord> _getEventsForDay(DateTime day) {
|
||||
final normalizedDay = DateTime(day.year, day.month, day.day);
|
||||
return _visitRecordEvents[normalizedDay] ?? [];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('방문 기록'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
indicatorWeight: 3,
|
||||
tabs: const [
|
||||
Tab(text: '캘린더'),
|
||||
Tab(text: '통계'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// 캘린더 탭
|
||||
_buildCalendarTab(isDark),
|
||||
// 통계 탭
|
||||
VisitStatistics(selectedMonth: _focusedDay),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCalendarTab(bool isDark) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
||||
|
||||
// 방문 기록을 날짜별로 그룹화
|
||||
visitRecordsAsync.whenData((records) {
|
||||
_visitRecordEvents = {};
|
||||
for (final record in records) {
|
||||
final normalizedDate = DateTime(
|
||||
record.visitDate.year,
|
||||
record.visitDate.month,
|
||||
record.visitDate.day,
|
||||
);
|
||||
_visitRecordEvents[normalizedDate] = [
|
||||
...(_visitRecordEvents[normalizedDate] ?? []),
|
||||
record,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 캘린더
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TableCalendar(
|
||||
firstDay: DateTime.utc(2025, 1, 1),
|
||||
lastDay: DateTime.utc(2030, 12, 31),
|
||||
focusedDay: _focusedDay,
|
||||
calendarFormat: _calendarFormat,
|
||||
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
||||
onDaySelected: (selectedDay, focusedDay) {
|
||||
setState(() {
|
||||
_selectedDay = selectedDay;
|
||||
_focusedDay = focusedDay;
|
||||
});
|
||||
},
|
||||
onFormatChanged: (format) {
|
||||
setState(() {
|
||||
_calendarFormat = format;
|
||||
});
|
||||
},
|
||||
eventLoader: _getEventsForDay,
|
||||
calendarBuilders: CalendarBuilders(
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
|
||||
final visitRecords = events.cast<VisitRecord>();
|
||||
final confirmedCount = visitRecords.where((r) => r.isConfirmed).length;
|
||||
final unconfirmedCount = visitRecords.length - confirmedCount;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (confirmedCount > 0)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (unconfirmedCount > 0)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.orange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
outsideDaysVisible: false,
|
||||
selectedDecoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
markersMaxCount: 2,
|
||||
markerDecoration: const BoxDecoration(
|
||||
color: AppColors.lightSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
weekendTextStyle: const TextStyle(
|
||||
color: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
headerStyle: HeaderStyle(
|
||||
formatButtonVisible: true,
|
||||
titleCentered: true,
|
||||
formatButtonShowsNext: false,
|
||||
formatButtonDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
formatButtonTextStyle: const TextStyle(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 범례
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegend('추천받음', Colors.orange, isDark),
|
||||
const SizedBox(width: 24),
|
||||
_buildLegend('방문완료', Colors.green, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 선택된 날짜의 기록
|
||||
Expanded(
|
||||
child: _buildDayRecords(_selectedDay, isDark),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLegend(String label, Color color, bool isDark) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: AppTypography.body2(isDark)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDayRecords(DateTime day, bool isDark) {
|
||||
final events = _getEventsForDay(day);
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_available,
|
||||
size: 48,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'이날의 기록이 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${day.month}월 ${day.day}일 방문 기록',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${events.length}건',
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sortedEvents = events..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
return VisitRecordCard(
|
||||
visitRecord: sortedEvents[index],
|
||||
onTap: () {
|
||||
// TODO: 맛집 상세 페이지로 이동
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
|
||||
class VisitConfirmationDialog extends ConsumerWidget {
|
||||
final String restaurantId;
|
||||
final String restaurantName;
|
||||
final DateTime recommendationTime;
|
||||
|
||||
const VisitConfirmationDialog({
|
||||
super.key,
|
||||
required this.restaurantId,
|
||||
required this.restaurantName,
|
||||
required this.recommendationTime,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.restaurant,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'다녀왔음? 🍴',
|
||||
style: AppTypography.heading2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
restaurantName,
|
||||
style: AppTypography.heading2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'어땠어요? 방문 기록을 남겨주세요!',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? AppColors.darkBackground : AppColors.lightBackground),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'추천 시간: ${_formatTime(recommendationTime)}',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text(
|
||||
'안 갔어요',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
// 방문 기록 추가
|
||||
await ref.read(visitNotifierProvider.notifier).addVisitRecord(
|
||||
restaurantId: restaurantId,
|
||||
visitDate: DateTime.now(),
|
||||
isConfirmed: true,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
|
||||
// 성공 메시지 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('방문 기록이 저장되었습니다! 👍'),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('갔다왔어요!'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
static Future<bool?> show({
|
||||
required BuildContext context,
|
||||
required String restaurantId,
|
||||
required String restaurantName,
|
||||
required DateTime recommendationTime,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => VisitConfirmationDialog(
|
||||
restaurantId: restaurantId,
|
||||
restaurantName: restaurantName,
|
||||
recommendationTime: recommendationTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
205
lib/presentation/pages/calendar/widgets/visit_record_card.dart
Normal file
205
lib/presentation/pages/calendar/widgets/visit_record_card.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
|
||||
class VisitRecordCard extends ConsumerWidget {
|
||||
final VisitRecord visitRecord;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const VisitRecordCard({
|
||||
super.key,
|
||||
required this.visitRecord,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
Widget _buildVisitIcon(bool isConfirmed, bool isDark) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isConfirmed
|
||||
? AppColors.lightPrimary.withValues(alpha: 0.1)
|
||||
: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isConfirmed ? Icons.check_circle : Icons.schedule,
|
||||
color: isConfirmed ? AppColors.lightPrimary : Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final restaurantAsync = ref.watch(restaurantProvider(visitRecord.restaurantId));
|
||||
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
if (restaurant == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildVisitIcon(visitRecord.isConfirmed, isDark),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(visitRecord.visitDate),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!visitRecord.isConfirmed) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'방문 확인이 필요합니다',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
onSelected: (value) async {
|
||||
if (value == 'confirm' && !visitRecord.isConfirmed) {
|
||||
await ref.read(visitNotifierProvider.notifier).confirmVisit(visitRecord.id);
|
||||
} else if (value == 'delete') {
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('방문 기록 삭제'),
|
||||
content: const Text('이 방문 기록을 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(visitNotifierProvider.notifier).deleteVisitRecord(visitRecord.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (!visitRecord.isConfirmed)
|
||||
PopupMenuItem(
|
||||
value: 'confirm',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check, color: AppColors.lightPrimary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('방문 확인', style: AppTypography.body2(isDark)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline, color: AppColors.lightError, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('삭제', style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightError,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
331
lib/presentation/pages/calendar/widgets/visit_statistics.dart
Normal file
331
lib/presentation/pages/calendar/widgets/visit_statistics.dart
Normal file
@@ -0,0 +1,331 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
class VisitStatistics extends ConsumerWidget {
|
||||
final DateTime selectedMonth;
|
||||
|
||||
const VisitStatistics({
|
||||
super.key,
|
||||
required this.selectedMonth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// 월별 통계
|
||||
final monthlyStatsAsync = ref.watch(monthlyVisitStatsProvider((
|
||||
year: selectedMonth.year,
|
||||
month: selectedMonth.month,
|
||||
)));
|
||||
|
||||
// 자주 방문한 맛집
|
||||
final frequentRestaurantsAsync = ref.watch(frequentRestaurantsProvider);
|
||||
|
||||
// 주간 통계
|
||||
final weeklyStatsAsync = ref.watch(weeklyVisitStatsProvider);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 이번 달 통계
|
||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주간 통계 차트
|
||||
_buildWeeklyChart(weeklyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 자주 방문한 맛집 TOP 3
|
||||
_buildFrequentRestaurants(frequentRestaurantsAsync, ref, isDark),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthlyStats(AsyncValue<Map<String, int>> statsAsync, bool isDark) {
|
||||
return Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${selectedMonth.month}월 방문 통계',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
statsAsync.when(
|
||||
data: (stats) {
|
||||
final totalVisits = stats.values.fold(0, (sum, count) => sum + count);
|
||||
final categoryCounts = stats.entries
|
||||
.where((e) => !e.key.contains('/'))
|
||||
.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildStatItem(
|
||||
icon: Icons.restaurant,
|
||||
label: '총 방문 횟수',
|
||||
value: '$totalVisits회',
|
||||
color: AppColors.lightPrimary,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (categoryCounts.isNotEmpty) ...[
|
||||
_buildStatItem(
|
||||
icon: Icons.favorite,
|
||||
label: '가장 많이 간 카테고리',
|
||||
value: '${categoryCounts.first.key} (${categoryCounts.first.value}회)',
|
||||
color: AppColors.lightSecondary,
|
||||
isDark: isDark,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text(
|
||||
'통계를 불러올 수 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyChart(AsyncValue<Map<String, int>> statsAsync, bool isDark) {
|
||||
return Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'최근 7일 방문 현황',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
statsAsync.when(
|
||||
data: (stats) {
|
||||
final maxCount = stats.values.isEmpty ? 1 : stats.values.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: stats.entries.map((entry) {
|
||||
final height = maxCount == 0 ? 0.0 : (entry.value / maxCount) * 80;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
entry.value.toString(),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 30,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
entry.key,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text(
|
||||
'차트를 불러올 수 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFrequentRestaurants(
|
||||
AsyncValue<List<({String restaurantId, int visitCount})>> frequentAsync,
|
||||
WidgetRef ref,
|
||||
bool isDark,
|
||||
) {
|
||||
return Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'자주 방문한 맛집 TOP 3',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
frequentAsync.when(
|
||||
data: (frequentList) {
|
||||
if (frequentList.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'아직 방문 기록이 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: frequentList.take(3).map((item) {
|
||||
final restaurantAsync = ref.watch(restaurantProvider(item.restaurantId));
|
||||
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
if (restaurant == null) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${frequentList.indexOf(item) + 1}',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.visitCount}회',
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(height: 44),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
);
|
||||
}).toList() as List<Widget>,
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text(
|
||||
'데이터를 불러올 수 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required Color color,
|
||||
required bool isDark,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/presentation/pages/main/main_screen.dart
Normal file
88
lib/presentation/pages/main/main_screen.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/services/notification_service.dart';
|
||||
import '../../providers/notification_handler_provider.dart';
|
||||
import '../share/share_screen.dart';
|
||||
import '../restaurant_list/restaurant_list_screen.dart';
|
||||
import '../random_selection/random_selection_screen.dart';
|
||||
import '../calendar/calendar_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
|
||||
class MainScreen extends ConsumerStatefulWidget {
|
||||
final int initialTab;
|
||||
|
||||
const MainScreen({super.key, this.initialTab = 2});
|
||||
|
||||
@override
|
||||
ConsumerState<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends ConsumerState<MainScreen> {
|
||||
late int _selectedIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedIndex = widget.initialTab;
|
||||
|
||||
// 알림 핸들러 설정
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NotificationService.onNotificationTap = (NotificationResponse response) {
|
||||
if (mounted) {
|
||||
ref.read(notificationHandlerProvider.notifier).handleNotificationTap(
|
||||
context,
|
||||
response.payload,
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NotificationService.onNotificationTap = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
final List<({IconData icon, String label})> _navItems = [
|
||||
(icon: Icons.share, label: '공유'),
|
||||
(icon: Icons.restaurant, label: '맛집'),
|
||||
(icon: Icons.casino, label: '뽑기'),
|
||||
(icon: Icons.calendar_month, label: '기록'),
|
||||
(icon: Icons.settings, label: '설정'),
|
||||
];
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const ShareScreen(),
|
||||
const RestaurantListScreen(),
|
||||
const RandomSelectionScreen(),
|
||||
const CalendarScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: _screens,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() => _selectedIndex = index);
|
||||
},
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
destinations: _navItems.map((item) => NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: item.label,
|
||||
)).toList(),
|
||||
indicatorColor: AppColors.lightPrimary.withOpacity(0.2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../domain/entities/weather_info.dart';
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../providers/weather_provider.dart';
|
||||
import '../../providers/location_provider.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import 'widgets/recommendation_result_dialog.dart';
|
||||
|
||||
class RandomSelectionScreen extends ConsumerStatefulWidget {
|
||||
const RandomSelectionScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RandomSelectionScreen> createState() => _RandomSelectionScreenState();
|
||||
}
|
||||
|
||||
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
double _distanceValue = 500;
|
||||
final List<String> _selectedCategories = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('오늘 뭐 먹Z?'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 맛집 리스트 현황 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final restaurantsAsync = ref.watch(restaurantListProvider);
|
||||
return restaurantsAsync.when(
|
||||
data: (restaurants) => Text(
|
||||
'${restaurants.length}개',
|
||||
style: AppTypography.heading1(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
error: (_, __) => Text(
|
||||
'0개',
|
||||
style: AppTypography.heading1(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'등록된 맛집',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 날씨 정보 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final weatherAsync = ref.watch(weatherProvider);
|
||||
return weatherAsync.when(
|
||||
data: (weather) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildWeatherData('지금', weather.current, isDark),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 50,
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
),
|
||||
_buildWeatherData('1시간 후', weather.nextHour, isDark),
|
||||
],
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
error: (_, __) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildWeatherInfo('지금', Icons.wb_sunny, '맑음', 20, isDark),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 50,
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
),
|
||||
_buildWeatherInfo('1시간 후', Icons.wb_sunny, '맑음', 22, isDark),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 거리 설정 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'최대 거리',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: AppColors.lightPrimary,
|
||||
inactiveTrackColor: AppColors.lightPrimary.withValues(alpha: 0.3),
|
||||
thumbColor: AppColors.lightPrimary,
|
||||
trackHeight: 4,
|
||||
),
|
||||
child: Slider(
|
||||
value: _distanceValue,
|
||||
min: 100,
|
||||
max: 2000,
|
||||
divisions: 19,
|
||||
onChanged: (value) {
|
||||
setState(() => _distanceValue = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${_distanceValue.toInt()}m',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final locationAsync = ref.watch(currentLocationProvider);
|
||||
final restaurantsAsync = ref.watch(restaurantListProvider);
|
||||
|
||||
if (locationAsync.hasValue && restaurantsAsync.hasValue) {
|
||||
final location = locationAsync.value;
|
||||
final restaurants = restaurantsAsync.value;
|
||||
|
||||
if (location != null && restaurants != null) {
|
||||
final count = _getRestaurantCountInRange(
|
||||
restaurants,
|
||||
location,
|
||||
_distanceValue,
|
||||
);
|
||||
return Text(
|
||||
'$count개 맛집 포함',
|
||||
style: AppTypography.caption(isDark),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Text(
|
||||
'위치 정보를 가져오는 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 카테고리 선택 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'카테고리',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
|
||||
return categoriesAsync.when(
|
||||
data: (categories) => Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: categories.isEmpty
|
||||
? [const Text('카테고리 없음')]
|
||||
: categories.map((category) => _buildCategoryChip(category, isDark)).toList(),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 추천받기 버튼
|
||||
ElevatedButton(
|
||||
onPressed: _canRecommend() ? _startRecommendation : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 3,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.play_arrow, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'광고보고 추천받기',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeatherData(String label, WeatherData weatherData, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: AppTypography.caption(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Icon(
|
||||
weatherData.isRainy ? Icons.umbrella : Icons.wb_sunny,
|
||||
color: weatherData.isRainy ? Colors.blue : Colors.orange,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${weatherData.temperature}°C',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
weatherData.description,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeatherInfo(String label, IconData icon, String description, int temperature, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: AppTypography.caption(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.orange,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$temperature°C',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
description,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildCategoryChip(String category, bool isDark) {
|
||||
final isSelected = _selectedCategories.contains(category);
|
||||
|
||||
return FilterChip(
|
||||
label: Text(category),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedCategories.add(category);
|
||||
} else {
|
||||
_selectedCategories.remove(category);
|
||||
}
|
||||
});
|
||||
},
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground,
|
||||
selectedColor: AppColors.lightPrimary.withValues(alpha: 0.2),
|
||||
checkmarkColor: AppColors.lightPrimary,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkDivider : AppColors.lightDivider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _getRestaurantCountInRange(
|
||||
List<Restaurant> restaurants,
|
||||
Position location,
|
||||
double maxDistance,
|
||||
) {
|
||||
return restaurants.where((restaurant) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
restaurant.latitude,
|
||||
restaurant.longitude,
|
||||
);
|
||||
return distance <= maxDistance;
|
||||
}).length;
|
||||
}
|
||||
|
||||
bool _canRecommend() {
|
||||
final locationAsync = ref.read(currentLocationProvider);
|
||||
final restaurantsAsync = ref.read(restaurantListProvider);
|
||||
|
||||
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) return false;
|
||||
|
||||
final location = locationAsync.value;
|
||||
final restaurants = restaurantsAsync.value;
|
||||
|
||||
if (location == null || restaurants == null || restaurants.isEmpty) return false;
|
||||
|
||||
final count = _getRestaurantCountInRange(restaurants, location, _distanceValue);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
Future<void> _startRecommendation() async {
|
||||
final notifier = ref.read(recommendationNotifierProvider.notifier);
|
||||
|
||||
await notifier.getRandomRecommendation(
|
||||
maxDistance: _distanceValue,
|
||||
selectedCategories: _selectedCategories,
|
||||
);
|
||||
|
||||
final result = ref.read(recommendationNotifierProvider);
|
||||
|
||||
result.whenData((restaurant) {
|
||||
if (restaurant != null && mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => RecommendationResultDialog(
|
||||
restaurant: restaurant,
|
||||
onReroll: () {
|
||||
Navigator.pop(context);
|
||||
_startRecommendation();
|
||||
},
|
||||
onConfirmVisit: () {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('맛있게 드세요! 🍴'),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('조건에 맞는 맛집이 없습니다'),
|
||||
backgroundColor: AppColors.lightError,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/core/services/notification_service.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/settings_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
|
||||
class RecommendationResultDialog extends ConsumerWidget {
|
||||
final Restaurant restaurant;
|
||||
final VoidCallback onReroll;
|
||||
final VoidCallback onConfirmVisit;
|
||||
|
||||
const RecommendationResultDialog({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
required this.onReroll,
|
||||
required this.onConfirmVisit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 상단 이미지 영역
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(false).copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 맛집 정보
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 가게 이름
|
||||
Center(
|
||||
child: Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 카테고리
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${restaurant.category} > ${restaurant.subCategory}',
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주소
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 버튼들
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onReroll,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(color: AppColors.lightPrimary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final recommendationTime = DateTime.now();
|
||||
|
||||
// 알림 설정 확인
|
||||
final notificationEnabled = await ref.read(notificationEnabledProvider.future);
|
||||
|
||||
if (notificationEnabled) {
|
||||
// 알림 예약
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.scheduleVisitReminder(
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
}
|
||||
|
||||
// 방문 기록 자동 생성 (미확인 상태로)
|
||||
await ref.read(visitNotifierProvider.notifier).createVisitFromRecommendation(
|
||||
restaurantId: restaurant.id,
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
|
||||
// 기존 콜백 실행
|
||||
onConfirmVisit();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('여기로 갈게요!'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../widgets/category_selector.dart';
|
||||
import 'widgets/restaurant_card.dart';
|
||||
import 'widgets/add_restaurant_dialog.dart';
|
||||
|
||||
class RestaurantListScreen extends ConsumerStatefulWidget {
|
||||
const RestaurantListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RestaurantListScreen> createState() => _RestaurantListScreenState();
|
||||
}
|
||||
|
||||
class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _isSearching = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final restaurantsAsync = ref.watch(
|
||||
searchQuery.isNotEmpty || selectedCategory != null
|
||||
? filteredRestaurantsProvider
|
||||
: restaurantListProvider
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: _isSearching
|
||||
? TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '맛집 검색...',
|
||||
hintStyle: TextStyle(color: Colors.white70),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(searchQueryProvider.notifier).state = value;
|
||||
},
|
||||
)
|
||||
: const Text('내 맛집 리스트'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (_isSearching) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_searchController.clear();
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
] else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 카테고리 선택기
|
||||
Container(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: CategorySelector(
|
||||
selectedCategory: selectedCategory,
|
||||
onCategorySelected: (category) {
|
||||
ref.read(selectedCategoryProvider.notifier).state = category;
|
||||
},
|
||||
showAllOption: true,
|
||||
),
|
||||
),
|
||||
// 맛집 목록
|
||||
Expanded(
|
||||
child: restaurantsAsync.when(
|
||||
data: (restaurants) {
|
||||
if (restaurants.isEmpty) {
|
||||
return _buildEmptyState(isDark);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: restaurants.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RestaurantCard(restaurant: restaurants[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: isDark ? AppColors.darkError : AppColors.lightError,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'오류가 발생했습니다',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddOptions,
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(bool isDark) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 80,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'아직 등록된 맛집이 없어요',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'+ 버튼을 눌러 맛집을 추가해보세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddOptions() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const AddRestaurantDialog(initialTabIndex: 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
import '../../../view_models/add_restaurant_view_model.dart';
|
||||
import 'add_restaurant_form.dart';
|
||||
import 'add_restaurant_url_tab.dart';
|
||||
|
||||
/// 식당 추가 다이얼로그
|
||||
///
|
||||
/// UI 렌더링만 담당하며, 비즈니스 로직은 ViewModel에 위임합니다.
|
||||
class AddRestaurantDialog extends ConsumerStatefulWidget {
|
||||
final int initialTabIndex;
|
||||
|
||||
const AddRestaurantDialog({
|
||||
super.key,
|
||||
this.initialTabIndex = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AddRestaurantDialog> createState() => _AddRestaurantDialogState();
|
||||
}
|
||||
|
||||
class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Form 관련
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// TextEditingController들
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _categoryController;
|
||||
late final TextEditingController _subCategoryController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _roadAddressController;
|
||||
late final TextEditingController _jibunAddressController;
|
||||
late final TextEditingController _latitudeController;
|
||||
late final TextEditingController _longitudeController;
|
||||
late final TextEditingController _naverUrlController;
|
||||
|
||||
// UI 상태
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TabController 초기화
|
||||
_tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
initialIndex: widget.initialTabIndex,
|
||||
);
|
||||
|
||||
// TextEditingController 초기화
|
||||
_nameController = TextEditingController();
|
||||
_categoryController = TextEditingController();
|
||||
_subCategoryController = TextEditingController();
|
||||
_descriptionController = TextEditingController();
|
||||
_phoneController = TextEditingController();
|
||||
_roadAddressController = TextEditingController();
|
||||
_jibunAddressController = TextEditingController();
|
||||
_latitudeController = TextEditingController();
|
||||
_longitudeController = TextEditingController();
|
||||
_naverUrlController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// TabController 정리
|
||||
_tabController.dispose();
|
||||
|
||||
// TextEditingController 정리
|
||||
_nameController.dispose();
|
||||
_categoryController.dispose();
|
||||
_subCategoryController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_phoneController.dispose();
|
||||
_roadAddressController.dispose();
|
||||
_jibunAddressController.dispose();
|
||||
_latitudeController.dispose();
|
||||
_longitudeController.dispose();
|
||||
_naverUrlController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 폼 데이터가 변경될 때 ViewModel 업데이트
|
||||
void _onFormDataChanged(String _) {
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
final formData = RestaurantFormData.fromControllers(
|
||||
nameController: _nameController,
|
||||
categoryController: _categoryController,
|
||||
subCategoryController: _subCategoryController,
|
||||
descriptionController: _descriptionController,
|
||||
phoneController: _phoneController,
|
||||
roadAddressController: _roadAddressController,
|
||||
jibunAddressController: _jibunAddressController,
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
naverUrlController: _naverUrlController,
|
||||
);
|
||||
viewModel.updateFormData(formData);
|
||||
}
|
||||
|
||||
/// 네이버 URL로부터 정보 가져오기
|
||||
Future<void> _fetchFromNaverUrl() async {
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
await viewModel.fetchFromNaverUrl(_naverUrlController.text);
|
||||
|
||||
// 성공 시 폼에 데이터 채우기 및 자동 저장
|
||||
final state = ref.read(addRestaurantViewModelProvider);
|
||||
if (state.fetchedRestaurantData != null) {
|
||||
_updateFormControllers(state.formData);
|
||||
|
||||
// 자동으로 저장 실행
|
||||
final success = await viewModel.saveRestaurant();
|
||||
|
||||
if (success && mounted) {
|
||||
// 다이얼로그 닫기
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// 성공 메시지 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('맛집이 추가되었습니다'),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 폼 컨트롤러 업데이트
|
||||
void _updateFormControllers(RestaurantFormData formData) {
|
||||
_nameController.text = formData.name;
|
||||
_categoryController.text = formData.category;
|
||||
_subCategoryController.text = formData.subCategory;
|
||||
_descriptionController.text = formData.description;
|
||||
_phoneController.text = formData.phoneNumber;
|
||||
_roadAddressController.text = formData.roadAddress;
|
||||
_jibunAddressController.text = formData.jibunAddress;
|
||||
_latitudeController.text = formData.latitude;
|
||||
_longitudeController.text = formData.longitude;
|
||||
}
|
||||
|
||||
/// 식당 저장
|
||||
Future<void> _saveRestaurant() async {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
final success = await viewModel.saveRestaurant();
|
||||
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('맛집이 추가되었습니다'),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final state = ref.watch(addRestaurantViewModelProvider);
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 헤더
|
||||
_buildHeader(isDark),
|
||||
|
||||
// 탭바
|
||||
_buildTabBar(isDark),
|
||||
|
||||
// 탭 내용
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// URL 탭
|
||||
SingleChildScrollView(
|
||||
child: AddRestaurantUrlTab(
|
||||
urlController: _naverUrlController,
|
||||
isLoading: state.isLoading,
|
||||
errorMessage: state.errorMessage,
|
||||
onFetchPressed: _fetchFromNaverUrl,
|
||||
),
|
||||
),
|
||||
// 직접 입력 탭
|
||||
SingleChildScrollView(
|
||||
child: AddRestaurantForm(
|
||||
formKey: _formKey,
|
||||
nameController: _nameController,
|
||||
categoryController: _categoryController,
|
||||
subCategoryController: _subCategoryController,
|
||||
descriptionController: _descriptionController,
|
||||
phoneController: _phoneController,
|
||||
roadAddressController: _roadAddressController,
|
||||
jibunAddressController: _jibunAddressController,
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
onFieldChanged: _onFormDataChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 버튼
|
||||
_buildButtons(isDark, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 헤더 빌드
|
||||
Widget _buildHeader(bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'맛집 추가',
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 탭바 빌드
|
||||
Widget _buildTabBar(bool isDark) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
labelColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
unselectedLabelColor: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.link),
|
||||
text: 'URL로 가져오기',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.edit),
|
||||
text: '직접 입력',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 버튼 빌드
|
||||
Widget _buildButtons(bool isDark, AddRestaurantState state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () {
|
||||
// 현재 탭에 따라 다른 동작
|
||||
if (_tabController.index == 0) {
|
||||
// URL 탭
|
||||
_fetchFromNaverUrl();
|
||||
} else {
|
||||
// 직접 입력 탭
|
||||
_saveRestaurant();
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
_tabController.index == 0 ? '가져오기' : '저장',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/core/utils/validators.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
class AddRestaurantDialog extends ConsumerStatefulWidget {
|
||||
final int initialTabIndex;
|
||||
|
||||
const AddRestaurantDialog({
|
||||
super.key,
|
||||
this.initialTabIndex = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AddRestaurantDialog> createState() => _AddRestaurantDialogState();
|
||||
}
|
||||
|
||||
class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog> with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _categoryController = TextEditingController();
|
||||
final _subCategoryController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _roadAddressController = TextEditingController();
|
||||
final _jibunAddressController = TextEditingController();
|
||||
final _latitudeController = TextEditingController();
|
||||
final _longitudeController = TextEditingController();
|
||||
final _naverUrlController = TextEditingController();
|
||||
|
||||
// 기본 좌표 (서울시청)
|
||||
final double _defaultLatitude = 37.5665;
|
||||
final double _defaultLongitude = 126.9780;
|
||||
|
||||
// UI 상태 관리
|
||||
late TabController _tabController;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
Restaurant? _fetchedRestaurantData;
|
||||
final _linkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
initialIndex: widget.initialTabIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_categoryController.dispose();
|
||||
_subCategoryController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_phoneController.dispose();
|
||||
_roadAddressController.dispose();
|
||||
_jibunAddressController.dispose();
|
||||
_latitudeController.dispose();
|
||||
_longitudeController.dispose();
|
||||
_naverUrlController.dispose();
|
||||
_linkController.dispose();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 제목과 탭바
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'맛집 추가',
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicator: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
labelStyle: AppTypography.body1(false).copyWith(fontWeight: FontWeight.w600),
|
||||
tabs: const [
|
||||
Tab(text: '직접 입력'),
|
||||
Tab(text: '네이버 지도에서 가져오기'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 탭뷰 컨텐츠
|
||||
Flexible(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
// 직접 입력 탭
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
// 가게 이름
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '가게 이름 *',
|
||||
hintText: '예: 서울갈비',
|
||||
prefixIcon: const Icon(Icons.store),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '가게 이름을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 카테고리
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _categoryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '카테고리 *',
|
||||
hintText: '예: 한식',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '카테고리를 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _subCategoryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '세부 카테고리',
|
||||
hintText: '예: 갈비',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 설명
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: '설명',
|
||||
hintText: '맛집에 대한 간단한 설명',
|
||||
prefixIcon: const Icon(Icons.description),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 전화번호
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: '전화번호',
|
||||
hintText: '예: 02-1234-5678',
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 도로명 주소
|
||||
TextFormField(
|
||||
controller: _roadAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '도로명 주소 *',
|
||||
hintText: '예: 서울시 중구 세종대로 110',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '도로명 주소를 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 지번 주소
|
||||
TextFormField(
|
||||
controller: _jibunAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '지번 주소',
|
||||
hintText: '예: 서울시 중구 태평로1가 31',
|
||||
prefixIcon: const Icon(Icons.map),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 위도/경도 입력
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: '위도',
|
||||
hintText: '37.5665',
|
||||
prefixIcon: const Icon(Icons.explore),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
validator: Validators.validateLatitude,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: '경도',
|
||||
hintText: '126.9780',
|
||||
prefixIcon: const Icon(Icons.explore),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
validator: Validators.validateLongitude,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 버튼
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'취소',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _saveRestaurant,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 네이버 지도 탭
|
||||
_buildNaverMapTab(isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 네이버 지도 탭 빌드
|
||||
Widget _buildNaverMapTab(bool isDark) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 안내 메시지
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: AppColors.lightPrimary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
kIsWeb
|
||||
? '네이버 지도에서 맛집 페이지 URL을 복사하여\n붙여넣어 주세요.\n\n웹 환경에서는 프록시 서버를 통해 정보를 가져옵니다.\n네트워크 상황에 따라 시간이 걸릴 수 있습니다.'
|
||||
: '네이버 지도에서 맛집 페이지 URL을 복사하여\n붙여넣어 주세요.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? AppColors.darkText : AppColors.lightText,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// URL 입력 필드
|
||||
TextFormField(
|
||||
controller: _naverUrlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '네이버 지도 URL',
|
||||
hintText: 'https://map.naver.com/... 또는 https://naver.me/...',
|
||||
prefixIcon: Icon(
|
||||
Icons.link,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorText: _errorMessage,
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 가져온 정보 표시 (JSON 스타일)
|
||||
if (_fetchedRestaurantData != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 타이틀
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.code,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'가져온 정보',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// JSON 스타일 정보 표시
|
||||
_buildJsonField(
|
||||
'이름',
|
||||
_nameController,
|
||||
isDark,
|
||||
icon: Icons.store,
|
||||
),
|
||||
_buildJsonField(
|
||||
'카테고리',
|
||||
_categoryController,
|
||||
isDark,
|
||||
icon: Icons.category,
|
||||
),
|
||||
_buildJsonField(
|
||||
'세부 카테고리',
|
||||
_subCategoryController,
|
||||
isDark,
|
||||
icon: Icons.label_outline,
|
||||
),
|
||||
_buildJsonField(
|
||||
'주소',
|
||||
_roadAddressController,
|
||||
isDark,
|
||||
icon: Icons.location_on,
|
||||
),
|
||||
_buildJsonField(
|
||||
'전화',
|
||||
_phoneController,
|
||||
isDark,
|
||||
icon: Icons.phone,
|
||||
),
|
||||
_buildJsonField(
|
||||
'설명',
|
||||
_descriptionController,
|
||||
isDark,
|
||||
icon: Icons.description,
|
||||
maxLines: 2,
|
||||
),
|
||||
_buildJsonField(
|
||||
'좌표',
|
||||
TextEditingController(
|
||||
text: '${_latitudeController.text}, ${_longitudeController.text}'
|
||||
),
|
||||
isDark,
|
||||
icon: Icons.my_location,
|
||||
isCoordinate: true,
|
||||
),
|
||||
if (_linkController.text.isNotEmpty)
|
||||
_buildJsonField(
|
||||
'링크',
|
||||
_linkController,
|
||||
isDark,
|
||||
icon: Icons.link,
|
||||
isLink: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// 버튼
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'취소',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_fetchedRestaurantData == null)
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _fetchFromNaverUrl,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.download, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('가져오기'),
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_fetchedRestaurantData = null;
|
||||
_clearControllers();
|
||||
});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
side: BorderSide(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _saveRestaurant,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.save, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('저장'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// JSON 스타일 필드 빌드
|
||||
Widget _buildJsonField(
|
||||
String label,
|
||||
TextEditingController controller,
|
||||
bool isDark, {
|
||||
IconData? icon,
|
||||
int maxLines = 1,
|
||||
bool isCoordinate = false,
|
||||
bool isLink = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
'$label:',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (isCoordinate)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _latitudeController,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? AppColors.darkText : AppColors.lightText,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '위도',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
',',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? AppColors.darkText : AppColors.lightText,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _longitudeController,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? AppColors.darkText : AppColors.lightText,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '경도',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: isLink ? 'monospace' : null,
|
||||
color: isLink
|
||||
? AppColors.lightPrimary
|
||||
: isDark ? AppColors.darkText : AppColors.lightText,
|
||||
decoration: isLink ? TextDecoration.underline : null,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 컨트롤러 초기화
|
||||
void _clearControllers() {
|
||||
_nameController.clear();
|
||||
_categoryController.clear();
|
||||
_subCategoryController.clear();
|
||||
_descriptionController.clear();
|
||||
_phoneController.clear();
|
||||
_roadAddressController.clear();
|
||||
_jibunAddressController.clear();
|
||||
_latitudeController.clear();
|
||||
_longitudeController.clear();
|
||||
_linkController.clear();
|
||||
}
|
||||
|
||||
// 네이버 URL에서 정보 가져오기
|
||||
Future<void> _fetchFromNaverUrl() async {
|
||||
final url = _naverUrlController.text.trim();
|
||||
|
||||
if (url.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = 'URL을 입력해주세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final notifier = ref.read(restaurantNotifierProvider.notifier);
|
||||
final restaurant = await notifier.addRestaurantFromUrl(url);
|
||||
|
||||
// 성공 시 폼에 정보 채우고 _fetchedRestaurantData 설정
|
||||
setState(() {
|
||||
_nameController.text = restaurant.name;
|
||||
_categoryController.text = restaurant.category;
|
||||
_subCategoryController.text = restaurant.subCategory;
|
||||
_descriptionController.text = restaurant.description ?? '';
|
||||
_phoneController.text = restaurant.phoneNumber ?? '';
|
||||
_roadAddressController.text = restaurant.roadAddress;
|
||||
_jibunAddressController.text = restaurant.jibunAddress;
|
||||
_latitudeController.text = restaurant.latitude.toString();
|
||||
_longitudeController.text = restaurant.longitude.toString();
|
||||
|
||||
// 링크 정보가 있다면 설정
|
||||
_linkController.text = restaurant.naverUrl ?? '';
|
||||
|
||||
// Restaurant 객체 저장
|
||||
_fetchedRestaurantData = restaurant;
|
||||
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 성공 메시지 표시
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('맛집 정보를 가져왔습니다. 확인 후 저장해주세요.'),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveRestaurant() async {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(restaurantNotifierProvider.notifier);
|
||||
|
||||
try {
|
||||
// _fetchedRestaurantData가 있으면 해당 데이터 사용 (네이버에서 가져온 경우)
|
||||
final fetchedData = _fetchedRestaurantData;
|
||||
if (fetchedData != null) {
|
||||
// 사용자가 수정한 필드만 업데이트
|
||||
final updatedRestaurant = fetchedData.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
category: _categoryController.text.trim(),
|
||||
subCategory: _subCategoryController.text.trim().isEmpty
|
||||
? _categoryController.text.trim()
|
||||
: _subCategoryController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
phoneNumber: _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim(),
|
||||
roadAddress: _roadAddressController.text.trim(),
|
||||
jibunAddress: _jibunAddressController.text.trim().isEmpty
|
||||
? _roadAddressController.text.trim()
|
||||
: _jibunAddressController.text.trim(),
|
||||
latitude: double.tryParse(_latitudeController.text.trim()) ?? fetchedData.latitude,
|
||||
longitude: double.tryParse(_longitudeController.text.trim()) ?? fetchedData.longitude,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// 이미 완성된 Restaurant 객체를 직접 추가
|
||||
await notifier.addRestaurantDirect(updatedRestaurant);
|
||||
} else {
|
||||
// 직접 입력한 경우 (기존 로직)
|
||||
await notifier.addRestaurant(
|
||||
name: _nameController.text.trim(),
|
||||
category: _categoryController.text.trim(),
|
||||
subCategory: _subCategoryController.text.trim().isEmpty
|
||||
? _categoryController.text.trim()
|
||||
: _subCategoryController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
phoneNumber: _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim(),
|
||||
roadAddress: _roadAddressController.text.trim(),
|
||||
jibunAddress: _jibunAddressController.text.trim().isEmpty
|
||||
? _roadAddressController.text.trim()
|
||||
: _jibunAddressController.text.trim(),
|
||||
latitude: _latitudeController.text.trim().isEmpty
|
||||
? _defaultLatitude
|
||||
: double.tryParse(_latitudeController.text.trim()) ?? _defaultLatitude,
|
||||
longitude: _longitudeController.text.trim().isEmpty
|
||||
? _defaultLongitude
|
||||
: double.tryParse(_longitudeController.text.trim()) ?? _defaultLongitude,
|
||||
source: DataSource.USER_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('맛집이 추가되었습니다'),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('오류가 발생했습니다: ${e.toString()}'),
|
||||
backgroundColor: AppColors.lightError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../services/restaurant_form_validator.dart';
|
||||
|
||||
/// 식당 추가 폼 위젯
|
||||
class AddRestaurantForm extends StatelessWidget {
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController categoryController;
|
||||
final TextEditingController subCategoryController;
|
||||
final TextEditingController descriptionController;
|
||||
final TextEditingController phoneController;
|
||||
final TextEditingController roadAddressController;
|
||||
final TextEditingController jibunAddressController;
|
||||
final TextEditingController latitudeController;
|
||||
final TextEditingController longitudeController;
|
||||
final Function(String) onFieldChanged;
|
||||
|
||||
const AddRestaurantForm({
|
||||
super.key,
|
||||
required this.formKey,
|
||||
required this.nameController,
|
||||
required this.categoryController,
|
||||
required this.subCategoryController,
|
||||
required this.descriptionController,
|
||||
required this.phoneController,
|
||||
required this.roadAddressController,
|
||||
required this.jibunAddressController,
|
||||
required this.latitudeController,
|
||||
required this.longitudeController,
|
||||
required this.onFieldChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 가게 이름
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '가게 이름 *',
|
||||
hintText: '예: 맛있는 한식당',
|
||||
prefixIcon: const Icon(Icons.store),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '가게 이름을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 카테고리
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: categoryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '카테고리 *',
|
||||
hintText: '예: 한식',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) => RestaurantFormValidator.validateCategory(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: subCategoryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '세부 카테고리',
|
||||
hintText: '예: 갈비',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 설명
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: '설명',
|
||||
hintText: '맛집에 대한 간단한 설명',
|
||||
prefixIcon: const Icon(Icons.description),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 전화번호
|
||||
TextFormField(
|
||||
controller: phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: '전화번호',
|
||||
hintText: '예: 02-1234-5678',
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) => RestaurantFormValidator.validatePhoneNumber(value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 도로명 주소
|
||||
TextFormField(
|
||||
controller: roadAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '도로명 주소 *',
|
||||
hintText: '예: 서울시 중구 세종대로 110',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) => RestaurantFormValidator.validateAddress(value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 지번 주소
|
||||
TextFormField(
|
||||
controller: jibunAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '지번 주소',
|
||||
hintText: '예: 서울시 중구 태평로1가 31',
|
||||
prefixIcon: const Icon(Icons.map),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 위도/경도 입력
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: '위도',
|
||||
hintText: '37.5665',
|
||||
prefixIcon: const Icon(Icons.explore),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final latitude = double.tryParse(value);
|
||||
if (latitude == null || latitude < -90 || latitude > 90) {
|
||||
return '올바른 위도값을 입력해주세요';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: '경도',
|
||||
hintText: '126.9780',
|
||||
prefixIcon: const Icon(Icons.explore),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final longitude = double.tryParse(value);
|
||||
if (longitude == null || longitude < -180 || longitude > 180) {
|
||||
return '올바른 경도값을 입력해주세요';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
|
||||
/// 네이버 URL 입력 탭 위젯
|
||||
class AddRestaurantUrlTab extends StatelessWidget {
|
||||
final TextEditingController urlController;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final VoidCallback onFetchPressed;
|
||||
|
||||
const AddRestaurantUrlTab({
|
||||
super.key,
|
||||
required this.urlController,
|
||||
required this.isLoading,
|
||||
this.errorMessage,
|
||||
required this.onFetchPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 안내 텍스트
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.darkPrimary.withOpacity(0.1)
|
||||
: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'네이버 지도에서 맛집 정보 가져오기',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
||||
'2. 공유 버튼을 눌러 URL을 복사합니다\n'
|
||||
'3. 아래에 붙여넣고 가져오기를 누릅니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// URL 입력 필드
|
||||
TextField(
|
||||
controller: urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '네이버 지도 URL',
|
||||
hintText: kIsWeb
|
||||
? 'https://map.naver.com/...'
|
||||
: 'https://naver.me/...',
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
errorText: errorMessage,
|
||||
),
|
||||
onSubmitted: (_) => onFetchPressed(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 가져오기 버튼
|
||||
ElevatedButton.icon(
|
||||
onPressed: isLoading ? null : onFetchPressed,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download),
|
||||
label: Text(isLoading ? '가져오는 중...' : '맛집 정보 가져오기'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 웹 환경 경고
|
||||
if (kIsWeb) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded,
|
||||
color: Colors.orange, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'웹 환경에서는 CORS 정책으로 인해 일부 맛집 정보가 제한될 수 있습니다.',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
|
||||
class RestaurantCard extends ConsumerWidget {
|
||||
final Restaurant restaurant;
|
||||
|
||||
const RestaurantCard({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final lastVisitAsync = ref.watch(lastVisitDateProvider(restaurant.id));
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: InkWell(
|
||||
onTap: () => _showRestaurantDetail(context, isDark),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// 카테고리 아이콘
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
_getCategoryIcon(restaurant.category),
|
||||
color: AppColors.lightPrimary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 가게 정보
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading2(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory != restaurant.category) ...[
|
||||
Text(
|
||||
' • ',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
Text(
|
||||
restaurant.subCategory,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 더보기 버튼
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
onPressed: () => _showOptions(context, ref, isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 주소
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
restaurant.roadAddress,
|
||||
style: AppTypography.caption(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 마지막 방문일
|
||||
lastVisitAsync.when(
|
||||
data: (lastVisit) {
|
||||
if (lastVisit != null) {
|
||||
final daysSinceVisit = DateTime.now().difference(lastVisit).inDays;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
daysSinceVisit == 0
|
||||
? '오늘 방문'
|
||||
: '$daysSinceVisit일 전 방문',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case '한식':
|
||||
return Icons.rice_bowl;
|
||||
case '중식':
|
||||
return Icons.ramen_dining;
|
||||
case '일식':
|
||||
return Icons.set_meal;
|
||||
case '양식':
|
||||
return Icons.restaurant;
|
||||
case '카페':
|
||||
return Icons.coffee;
|
||||
case '분식':
|
||||
return Icons.fastfood;
|
||||
case '치킨':
|
||||
return Icons.egg;
|
||||
case '피자':
|
||||
return Icons.local_pizza;
|
||||
default:
|
||||
return Icons.restaurant_menu;
|
||||
}
|
||||
}
|
||||
|
||||
void _showRestaurantDetail(BuildContext context, bool isDark) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
title: Text(restaurant.name),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow('카테고리', '${restaurant.category} > ${restaurant.subCategory}', isDark),
|
||||
if (restaurant.description != null)
|
||||
_buildDetailRow('설명', restaurant.description!, isDark),
|
||||
if (restaurant.phoneNumber != null)
|
||||
_buildDetailRow('전화번호', restaurant.phoneNumber!, isDark),
|
||||
_buildDetailRow('도로명 주소', restaurant.roadAddress, isDark),
|
||||
_buildDetailRow('지번 주소', restaurant.jibunAddress, isDark),
|
||||
if (restaurant.lastVisitDate != null)
|
||||
_buildDetailRow(
|
||||
'마지막 방문',
|
||||
'${restaurant.lastVisitDate!.year}년 ${restaurant.lastVisitDate!.month}월 ${restaurant.lastVisitDate!.day}일',
|
||||
isDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value, bool isDark) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptions(BuildContext context, WidgetRef ref, bool isDark) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit, color: AppColors.lightPrimary),
|
||||
title: const Text('수정'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: 수정 기능 구현
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppColors.lightError),
|
||||
title: const Text('삭제'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('맛집 삭제'),
|
||||
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('삭제', style: TextStyle(color: AppColors.lightError)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(restaurantNotifierProvider.notifier).deleteRestaurant(restaurant.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
442
lib/presentation/pages/settings/settings_screen.dart
Normal file
442
lib/presentation/pages/settings/settings_screen.dart
Normal file
@@ -0,0 +1,442 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
int _daysToExclude = 7;
|
||||
int _notificationMinutes = 90;
|
||||
bool _notificationEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final daysToExclude = await ref.read(daysToExcludeProvider.future);
|
||||
final notificationMinutes = await ref.read(notificationDelayMinutesProvider.future);
|
||||
final notificationEnabled = await ref.read(notificationEnabledProvider.future);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_daysToExclude = daysToExclude;
|
||||
_notificationMinutes = notificationMinutes;
|
||||
_notificationEnabled = notificationEnabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('설정'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
// 추천 설정
|
||||
_buildSection(
|
||||
'추천 설정',
|
||||
[
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
title: const Text('중복 방문 제외 기간'),
|
||||
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: _daysToExclude > 1
|
||||
? () async {
|
||||
setState(() => _daysToExclude--);
|
||||
await ref.read(settingsNotifierProvider.notifier)
|
||||
.setDaysToExclude(_daysToExclude);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$_daysToExclude일',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () async {
|
||||
setState(() => _daysToExclude++);
|
||||
await ref.read(settingsNotifierProvider.notifier)
|
||||
.setDaysToExclude(_daysToExclude);
|
||||
},
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
isDark,
|
||||
),
|
||||
|
||||
// 권한 설정
|
||||
_buildSection(
|
||||
'권한 관리',
|
||||
[
|
||||
FutureBuilder<PermissionStatus>(
|
||||
future: Permission.location.status,
|
||||
builder: (context, snapshot) {
|
||||
final status = snapshot.data;
|
||||
final isGranted = status?.isGranted ?? false;
|
||||
|
||||
return _buildPermissionTile(
|
||||
icon: Icons.location_on,
|
||||
title: '위치 권한',
|
||||
subtitle: '주변 맛집 거리 계산에 필요',
|
||||
isGranted: isGranted,
|
||||
onRequest: _requestLocationPermission,
|
||||
isDark: isDark,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb)
|
||||
FutureBuilder<PermissionStatus>(
|
||||
future: Permission.bluetooth.status,
|
||||
builder: (context, snapshot) {
|
||||
final status = snapshot.data;
|
||||
final isGranted = status?.isGranted ?? false;
|
||||
|
||||
return _buildPermissionTile(
|
||||
icon: Icons.bluetooth,
|
||||
title: '블루투스 권한',
|
||||
subtitle: '맛집 리스트 공유에 필요',
|
||||
isGranted: isGranted,
|
||||
onRequest: _requestBluetoothPermission,
|
||||
isDark: isDark,
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<PermissionStatus>(
|
||||
future: Permission.notification.status,
|
||||
builder: (context, snapshot) {
|
||||
final status = snapshot.data;
|
||||
final isGranted = status?.isGranted ?? false;
|
||||
|
||||
return _buildPermissionTile(
|
||||
icon: Icons.notifications,
|
||||
title: '알림 권한',
|
||||
subtitle: '방문 확인 알림에 필요',
|
||||
isGranted: isGranted,
|
||||
onRequest: _requestNotificationPermission,
|
||||
isDark: isDark,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
isDark,
|
||||
),
|
||||
|
||||
// 알림 설정
|
||||
_buildSection(
|
||||
'알림 설정',
|
||||
[
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: const Text('방문 확인 알림'),
|
||||
subtitle: const Text('맛집 방문 후 확인 알림을 받습니다'),
|
||||
value: _notificationEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() => _notificationEnabled = value);
|
||||
await ref.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationEnabled(value);
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
enabled: _notificationEnabled,
|
||||
title: const Text('방문 확인 알림 시간'),
|
||||
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: _notificationEnabled && _notificationMinutes > 60
|
||||
? () async {
|
||||
setState(() => _notificationMinutes -= 30);
|
||||
await ref.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationDelayMinutes(_notificationMinutes);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}분',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _notificationEnabled ? AppColors.lightPrimary : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: _notificationEnabled && _notificationMinutes < 360
|
||||
? () async {
|
||||
setState(() => _notificationMinutes += 30);
|
||||
await ref.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationDelayMinutes(_notificationMinutes);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
isDark,
|
||||
),
|
||||
|
||||
// 테마 설정
|
||||
_buildSection(
|
||||
'테마',
|
||||
[
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isDark ? Icons.dark_mode : Icons.light_mode,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
title: const Text('테마 설정'),
|
||||
subtitle: Text(isDark ? '다크 모드' : '라이트 모드'),
|
||||
trailing: Switch(
|
||||
value: isDark,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
AdaptiveTheme.of(context).setDark();
|
||||
} else {
|
||||
AdaptiveTheme.of(context).setLight();
|
||||
}
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
isDark,
|
||||
),
|
||||
|
||||
// 앱 정보
|
||||
_buildSection(
|
||||
'앱 정보',
|
||||
[
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.info_outline, color: AppColors.lightPrimary),
|
||||
title: Text('버전'),
|
||||
subtitle: Text('1.0.0'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.person_outline, color: AppColors.lightPrimary),
|
||||
title: Text('개발자'),
|
||||
subtitle: Text('NatureBridgeAI'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.description_outlined, color: AppColors.lightPrimary),
|
||||
title: const Text('오픈소스 라이센스'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: '오늘 뭐 먹Z?',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationLegalese: '© 2025 NatureBridgeAI',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
isDark,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String title, List<Widget> children, bool isDark) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool isGranted,
|
||||
required VoidCallback onRequest,
|
||||
required bool isDark,
|
||||
}) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: isGranted ? Colors.green : Colors.grey),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isGranted
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: ElevatedButton(
|
||||
onPressed: onRequest,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('허용'),
|
||||
),
|
||||
enabled: !isGranted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestLocationPermission() async {
|
||||
final status = await Permission.location.request();
|
||||
if (status.isGranted) {
|
||||
setState(() {});
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDialog('위치');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestBluetoothPermission() async {
|
||||
final status = await Permission.bluetooth.request();
|
||||
if (status.isGranted) {
|
||||
setState(() {});
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDialog('블루투스');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
setState(() {});
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDialog('알림');
|
||||
}
|
||||
}
|
||||
|
||||
void _showPermissionDialog(String permissionName) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
title: const Text('권한 설정 필요'),
|
||||
content: Text('$permissionName 권한이 거부되었습니다. 설정에서 직접 권한을 허용해주세요.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
openAppSettings();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightPrimary,
|
||||
),
|
||||
child: const Text('설정으로 이동'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
219
lib/presentation/pages/share/share_screen.dart
Normal file
219
lib/presentation/pages/share/share_screen.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
|
||||
class ShareScreen extends ConsumerStatefulWidget {
|
||||
const ShareScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ShareScreen> createState() => _ShareScreenState();
|
||||
}
|
||||
|
||||
class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
String? _shareCode;
|
||||
bool _isScanning = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('리스트 공유'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 공유받기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.download_rounded,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'리스트 공유받기',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'다른 사람의 맛집 리스트를 받아보세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_shareCode != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_shareCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 6,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'이 코드를 상대방에게 알려주세요',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('취소'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: _generateShareCode,
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('공유 코드 생성'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 공유하기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightSecondary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.upload_rounded,
|
||||
size: 48,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'내 리스트 공유하기',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isScanning) ...[
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('스캔 중지'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.radar),
|
||||
label: const Text('주변 기기 스캔'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightSecondary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _generateShareCode() {
|
||||
// TODO: 실제 구현 시 랜덤 코드 생성
|
||||
setState(() {
|
||||
_shareCode = '123456';
|
||||
});
|
||||
}
|
||||
}
|
||||
189
lib/presentation/pages/splash/splash_screen.dart
Normal file
189
lib/presentation/pages/splash/splash_screen.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
||||
late List<AnimationController> _foodControllers;
|
||||
late AnimationController _questionMarkController;
|
||||
late AnimationController _centerIconController;
|
||||
|
||||
final List<IconData> foodIcons = [
|
||||
Icons.rice_bowl,
|
||||
Icons.ramen_dining,
|
||||
Icons.lunch_dining,
|
||||
Icons.fastfood,
|
||||
Icons.local_pizza,
|
||||
Icons.cake,
|
||||
Icons.coffee,
|
||||
Icons.icecream,
|
||||
Icons.bakery_dining,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
_navigateToHome();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
// 음식 아이콘 애니메이션 (여러 개)
|
||||
_foodControllers = List.generate(
|
||||
foodIcons.length,
|
||||
(index) => AnimationController(
|
||||
duration: Duration(seconds: 2 + index % 3),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true),
|
||||
);
|
||||
|
||||
// 물음표 애니메이션
|
||||
_questionMarkController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
|
||||
// 중앙 아이콘 애니메이션
|
||||
_centerIconController = AnimationController(
|
||||
duration: const Duration(seconds: 1),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 랜덤 위치 음식 아이콘들
|
||||
..._buildFoodIcons(),
|
||||
|
||||
// 중앙 컨텐츠
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 선택 아이콘
|
||||
ScaleTransition(
|
||||
scale: Tween(begin: 0.8, end: 1.2).animate(
|
||||
CurvedAnimation(
|
||||
parent: _centerIconController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 80,
|
||||
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 앱 타이틀
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'오늘 뭐 먹Z',
|
||||
style: AppTypography.heading1(isDark),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _questionMarkController,
|
||||
builder: (context, child) {
|
||||
final questionMarks = '?' * (((_questionMarkController.value * 3).floor() % 3) + 1);
|
||||
return Text(
|
||||
questionMarks,
|
||||
style: AppTypography.heading1(isDark),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 카피라이트
|
||||
Positioned(
|
||||
bottom: 30,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Text(
|
||||
AppConstants.appCopyright,
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary)
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildFoodIcons() {
|
||||
final random = math.Random();
|
||||
|
||||
return List.generate(foodIcons.length, (index) {
|
||||
final left = random.nextDouble() * 0.8 + 0.1;
|
||||
final top = random.nextDouble() * 0.7 + 0.1;
|
||||
|
||||
return Positioned(
|
||||
left: MediaQuery.of(context).size.width * left,
|
||||
top: MediaQuery.of(context).size.height * top,
|
||||
child: FadeTransition(
|
||||
opacity: Tween(begin: 0.2, end: 0.8).animate(
|
||||
CurvedAnimation(
|
||||
parent: _foodControllers[index],
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.5, end: 1.5).animate(
|
||||
CurvedAnimation(
|
||||
parent: _foodControllers[index],
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
foodIcons[index],
|
||||
size: 40,
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _navigateToHome() {
|
||||
Future.delayed(AppConstants.splashAnimationDuration, () {
|
||||
if (mounted) {
|
||||
context.go('/home');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _foodControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
_questionMarkController.dispose();
|
||||
_centerIconController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
163
lib/presentation/services/restaurant_form_validator.dart
Normal file
163
lib/presentation/services/restaurant_form_validator.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import '../../core/utils/validators.dart';
|
||||
import '../view_models/add_restaurant_view_model.dart';
|
||||
|
||||
/// 식당 폼 검증 서비스
|
||||
class RestaurantFormValidator {
|
||||
/// 폼 데이터 검증
|
||||
static Map<String, String?> validateFormData(RestaurantFormData formData) {
|
||||
final errors = <String, String?>{};
|
||||
|
||||
// 이름 검증
|
||||
if (formData.name.isEmpty) {
|
||||
errors['name'] = '가게 이름을 입력해주세요';
|
||||
}
|
||||
|
||||
// 카테고리 검증
|
||||
if (formData.category.isEmpty) {
|
||||
errors['category'] = '카테고리를 입력해주세요';
|
||||
}
|
||||
|
||||
// 도로명 주소 검증
|
||||
if (formData.roadAddress.isEmpty) {
|
||||
errors['roadAddress'] = '도로명 주소를 입력해주세요';
|
||||
}
|
||||
|
||||
// 위도 검증
|
||||
if (formData.latitude.isNotEmpty) {
|
||||
final latitudeError = Validators.validateLatitude(formData.latitude);
|
||||
if (latitudeError != null) {
|
||||
errors['latitude'] = latitudeError;
|
||||
}
|
||||
}
|
||||
|
||||
// 경도 검증
|
||||
if (formData.longitude.isNotEmpty) {
|
||||
final longitudeError = Validators.validateLongitude(formData.longitude);
|
||||
if (longitudeError != null) {
|
||||
errors['longitude'] = longitudeError;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// 네이버 URL 검증
|
||||
static String? validateNaverUrl(String url) {
|
||||
if (url.trim().isEmpty) {
|
||||
return 'URL을 입력해주세요';
|
||||
}
|
||||
|
||||
// 네이버 지도 URL 패턴 검증
|
||||
final naverMapRegex = RegExp(
|
||||
r'^https?://(map\.naver\.com|naver\.me)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
if (!naverMapRegex.hasMatch(url)) {
|
||||
return '네이버 지도 URL만 입력 가능합니다';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 전화번호 형식 검증
|
||||
static String? validatePhoneNumber(String? phoneNumber) {
|
||||
if (phoneNumber == null || phoneNumber.isEmpty) {
|
||||
return null; // 선택 필드
|
||||
}
|
||||
|
||||
// 전화번호 패턴: 02-1234-5678, 010-1234-5678 등
|
||||
final phoneRegex = RegExp(
|
||||
r'^0\d{1,2}-?\d{3,4}-?\d{4}$',
|
||||
);
|
||||
|
||||
if (!phoneRegex.hasMatch(phoneNumber.replaceAll(' ', ''))) {
|
||||
return '올바른 전화번호 형식이 아닙니다';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 주소 형식 검증
|
||||
static String? validateAddress(String? address) {
|
||||
if (address == null || address.isEmpty) {
|
||||
return '주소를 입력해주세요';
|
||||
}
|
||||
|
||||
// 최소 길이 검증
|
||||
if (address.length < 5) {
|
||||
return '올바른 주소를 입력해주세요';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 카테고리 검증
|
||||
static String? validateCategory(String? category) {
|
||||
if (category == null || category.isEmpty) {
|
||||
return '카테고리를 입력해주세요';
|
||||
}
|
||||
|
||||
// 허용된 카테고리 목록 (필요시 추가)
|
||||
// final allowedCategories = [
|
||||
// '한식', '중식', '일식', '양식', '아시안',
|
||||
// '카페', '디저트', '분식', '패스트푸드', '기타'
|
||||
// ];
|
||||
|
||||
// 정확한 매칭이 아니어도 허용 (사용자 입력 고려)
|
||||
// 필요시 더 엄격한 검증 추가 가능
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 전체 폼 유효성 검사
|
||||
static bool isFormValid(RestaurantFormData formData) {
|
||||
final errors = validateFormData(formData);
|
||||
return errors.isEmpty;
|
||||
}
|
||||
|
||||
/// 필수 필드만 검증
|
||||
static bool hasRequiredFields(RestaurantFormData formData) {
|
||||
return formData.name.isNotEmpty &&
|
||||
formData.category.isNotEmpty &&
|
||||
formData.roadAddress.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
/// 폼 필드 에러 메시지 클래스
|
||||
class FormFieldErrors {
|
||||
final String? name;
|
||||
final String? category;
|
||||
final String? roadAddress;
|
||||
final String? latitude;
|
||||
final String? longitude;
|
||||
final String? phoneNumber;
|
||||
|
||||
const FormFieldErrors({
|
||||
this.name,
|
||||
this.category,
|
||||
this.roadAddress,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.phoneNumber,
|
||||
});
|
||||
|
||||
bool get hasErrors =>
|
||||
name != null ||
|
||||
category != null ||
|
||||
roadAddress != null ||
|
||||
latitude != null ||
|
||||
longitude != null ||
|
||||
phoneNumber != null;
|
||||
|
||||
Map<String, String> toMap() {
|
||||
final map = <String, String>{};
|
||||
if (name != null) map['name'] = name!;
|
||||
if (category != null) map['category'] = category!;
|
||||
if (roadAddress != null) map['roadAddress'] = roadAddress!;
|
||||
if (latitude != null) map['latitude'] = latitude!;
|
||||
if (longitude != null) map['longitude'] = longitude!;
|
||||
if (phoneNumber != null) map['phoneNumber'] = phoneNumber!;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
246
lib/presentation/view_models/add_restaurant_view_model.dart
Normal file
246
lib/presentation/view_models/add_restaurant_view_model.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import '../providers/restaurant_provider.dart';
|
||||
|
||||
/// 식당 추가 화면의 상태 모델
|
||||
class AddRestaurantState {
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final Restaurant? fetchedRestaurantData;
|
||||
final RestaurantFormData formData;
|
||||
|
||||
const AddRestaurantState({
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
this.fetchedRestaurantData,
|
||||
required this.formData,
|
||||
});
|
||||
|
||||
AddRestaurantState copyWith({
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
Restaurant? fetchedRestaurantData,
|
||||
RestaurantFormData? formData,
|
||||
}) {
|
||||
return AddRestaurantState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
fetchedRestaurantData: fetchedRestaurantData ?? this.fetchedRestaurantData,
|
||||
formData: formData ?? this.formData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 식당 폼 데이터 모델
|
||||
class RestaurantFormData {
|
||||
final String name;
|
||||
final String category;
|
||||
final String subCategory;
|
||||
final String description;
|
||||
final String phoneNumber;
|
||||
final String roadAddress;
|
||||
final String jibunAddress;
|
||||
final String latitude;
|
||||
final String longitude;
|
||||
final String naverUrl;
|
||||
|
||||
const RestaurantFormData({
|
||||
this.name = '',
|
||||
this.category = '',
|
||||
this.subCategory = '',
|
||||
this.description = '',
|
||||
this.phoneNumber = '',
|
||||
this.roadAddress = '',
|
||||
this.jibunAddress = '',
|
||||
this.latitude = '',
|
||||
this.longitude = '',
|
||||
this.naverUrl = '',
|
||||
});
|
||||
|
||||
RestaurantFormData copyWith({
|
||||
String? name,
|
||||
String? category,
|
||||
String? subCategory,
|
||||
String? description,
|
||||
String? phoneNumber,
|
||||
String? roadAddress,
|
||||
String? jibunAddress,
|
||||
String? latitude,
|
||||
String? longitude,
|
||||
String? naverUrl,
|
||||
}) {
|
||||
return RestaurantFormData(
|
||||
name: name ?? this.name,
|
||||
category: category ?? this.category,
|
||||
subCategory: subCategory ?? this.subCategory,
|
||||
description: description ?? this.description,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
roadAddress: roadAddress ?? this.roadAddress,
|
||||
jibunAddress: jibunAddress ?? this.jibunAddress,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
naverUrl: naverUrl ?? this.naverUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// TextEditingController로부터 폼 데이터 생성
|
||||
factory RestaurantFormData.fromControllers({
|
||||
required TextEditingController nameController,
|
||||
required TextEditingController categoryController,
|
||||
required TextEditingController subCategoryController,
|
||||
required TextEditingController descriptionController,
|
||||
required TextEditingController phoneController,
|
||||
required TextEditingController roadAddressController,
|
||||
required TextEditingController jibunAddressController,
|
||||
required TextEditingController latitudeController,
|
||||
required TextEditingController longitudeController,
|
||||
required TextEditingController naverUrlController,
|
||||
}) {
|
||||
return RestaurantFormData(
|
||||
name: nameController.text.trim(),
|
||||
category: categoryController.text.trim(),
|
||||
subCategory: subCategoryController.text.trim(),
|
||||
description: descriptionController.text.trim(),
|
||||
phoneNumber: phoneController.text.trim(),
|
||||
roadAddress: roadAddressController.text.trim(),
|
||||
jibunAddress: jibunAddressController.text.trim(),
|
||||
latitude: latitudeController.text.trim(),
|
||||
longitude: longitudeController.text.trim(),
|
||||
naverUrl: naverUrlController.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Restaurant 엔티티로부터 폼 데이터 생성
|
||||
factory RestaurantFormData.fromRestaurant(Restaurant restaurant) {
|
||||
return RestaurantFormData(
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: restaurant.description ?? '',
|
||||
phoneNumber: restaurant.phoneNumber ?? '',
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
latitude: restaurant.latitude.toString(),
|
||||
longitude: restaurant.longitude.toString(),
|
||||
naverUrl: restaurant.naverUrl ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Restaurant 엔티티로 변환
|
||||
Restaurant toRestaurant() {
|
||||
final uuid = const Uuid();
|
||||
return Restaurant(
|
||||
id: uuid.v4(),
|
||||
name: name,
|
||||
category: category,
|
||||
subCategory: subCategory.isEmpty ? category : subCategory,
|
||||
description: description.isEmpty ? null : description,
|
||||
phoneNumber: phoneNumber.isEmpty ? null : phoneNumber,
|
||||
roadAddress: roadAddress,
|
||||
jibunAddress: jibunAddress.isEmpty ? roadAddress : jibunAddress,
|
||||
latitude: double.tryParse(latitude) ?? 37.5665,
|
||||
longitude: double.tryParse(longitude) ?? 126.9780,
|
||||
naverUrl: naverUrl.isEmpty ? null : naverUrl,
|
||||
source: naverUrl.isNotEmpty ? DataSource.NAVER : DataSource.USER_INPUT,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 식당 추가 화면의 ViewModel
|
||||
class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
final Ref _ref;
|
||||
|
||||
AddRestaurantViewModel(this._ref)
|
||||
: super(const AddRestaurantState(formData: RestaurantFormData()));
|
||||
|
||||
/// 네이버 URL로부터 식당 정보 가져오기
|
||||
Future<void> fetchFromNaverUrl(String url) async {
|
||||
if (url.trim().isEmpty) {
|
||||
state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
||||
try {
|
||||
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||
final restaurant = await notifier.addRestaurantFromUrl(url);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
fetchedRestaurantData: restaurant,
|
||||
formData: RestaurantFormData.fromRestaurant(restaurant),
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 식당 정보 저장
|
||||
Future<bool> saveRestaurant() async {
|
||||
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||
|
||||
try {
|
||||
Restaurant restaurantToSave;
|
||||
|
||||
// 네이버에서 가져온 데이터가 있으면 업데이트
|
||||
final fetchedData = state.fetchedRestaurantData;
|
||||
if (fetchedData != null) {
|
||||
restaurantToSave = fetchedData.copyWith(
|
||||
name: state.formData.name,
|
||||
category: state.formData.category,
|
||||
subCategory: state.formData.subCategory.isEmpty
|
||||
? state.formData.category
|
||||
: state.formData.subCategory,
|
||||
description: state.formData.description.isEmpty
|
||||
? null
|
||||
: state.formData.description,
|
||||
phoneNumber: state.formData.phoneNumber.isEmpty
|
||||
? null
|
||||
: state.formData.phoneNumber,
|
||||
roadAddress: state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress.isEmpty
|
||||
? state.formData.roadAddress
|
||||
: state.formData.jibunAddress,
|
||||
latitude: double.tryParse(state.formData.latitude) ?? fetchedData.latitude,
|
||||
longitude: double.tryParse(state.formData.longitude) ?? fetchedData.longitude,
|
||||
naverUrl: state.formData.naverUrl.isEmpty ? null : state.formData.naverUrl,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
// 직접 입력한 경우
|
||||
restaurantToSave = state.formData.toRestaurant();
|
||||
}
|
||||
|
||||
await notifier.addRestaurantDirect(restaurantToSave);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWith(errorMessage: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 폼 데이터 업데이트
|
||||
void updateFormData(RestaurantFormData formData) {
|
||||
state = state.copyWith(formData: formData);
|
||||
}
|
||||
|
||||
/// 에러 메시지 초기화
|
||||
void clearError() {
|
||||
state = state.copyWith(errorMessage: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// AddRestaurantViewModel Provider
|
||||
final addRestaurantViewModelProvider =
|
||||
StateNotifierProvider.autoDispose<AddRestaurantViewModel, AddRestaurantState>(
|
||||
(ref) => AddRestaurantViewModel(ref),
|
||||
);
|
||||
332
lib/presentation/widgets/category_selector.dart
Normal file
332
lib/presentation/widgets/category_selector.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/utils/category_mapper.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
class CategorySelector extends ConsumerWidget {
|
||||
final String? selectedCategory;
|
||||
final Function(String?) onCategorySelected;
|
||||
final bool showAllOption;
|
||||
final bool multiSelect;
|
||||
final List<String>? selectedCategories;
|
||||
final Function(List<String>)? onMultipleSelected;
|
||||
|
||||
const CategorySelector({
|
||||
super.key,
|
||||
this.selectedCategory,
|
||||
required this.onCategorySelected,
|
||||
this.showAllOption = true,
|
||||
this.multiSelect = false,
|
||||
this.selectedCategories,
|
||||
this.onMultipleSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
|
||||
return categoriesAsync.when(
|
||||
data: (categories) {
|
||||
return SizedBox(
|
||||
height: 50,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
if (showAllOption && !multiSelect) ...[
|
||||
_buildCategoryChip(
|
||||
context: context,
|
||||
label: '전체',
|
||||
icon: Icons.restaurant_menu,
|
||||
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
isSelected: selectedCategory == null,
|
||||
onTap: () => onCategorySelected(null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
...categories.map((category) {
|
||||
final isSelected = multiSelect
|
||||
? selectedCategories?.contains(category) ?? false
|
||||
: selectedCategory == category;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildCategoryChip(
|
||||
context: context,
|
||||
label: CategoryMapper.getDisplayName(category),
|
||||
icon: CategoryMapper.getIcon(category),
|
||||
color: CategoryMapper.getColor(category),
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
if (multiSelect) {
|
||||
_handleMultiSelect(category);
|
||||
} else {
|
||||
onCategorySelected(category);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => const SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: Text('카테고리를 불러올 수 없습니다'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMultiSelect(String category) {
|
||||
if (onMultipleSelected == null || selectedCategories == null) return;
|
||||
|
||||
final List<String> updatedCategories = List.from(selectedCategories!);
|
||||
|
||||
if (updatedCategories.contains(category)) {
|
||||
updatedCategories.remove(category);
|
||||
} else {
|
||||
updatedCategories.add(category);
|
||||
}
|
||||
|
||||
onMultipleSelected!(updatedCategories);
|
||||
}
|
||||
|
||||
Widget _buildCategoryChip({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? color.withOpacity(0.2)
|
||||
: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkText
|
||||
: AppColors.lightText,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 카테고리 선택 다이얼로그
|
||||
class CategorySelectionDialog extends ConsumerWidget {
|
||||
final List<String> selectedCategories;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const CategorySelectionDialog({
|
||||
super.key,
|
||||
required this.selectedCategories,
|
||||
this.title = '카테고리 선택',
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
content: categoriesAsync.when(
|
||||
data: (categories) => SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1.2,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final isSelected = selectedCategories.contains(category);
|
||||
|
||||
return _CategoryGridItem(
|
||||
category: category,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
final updatedCategories = List<String>.from(selectedCategories);
|
||||
if (isSelected) {
|
||||
updatedCategories.remove(category);
|
||||
} else {
|
||||
updatedCategories.add(category);
|
||||
}
|
||||
Navigator.pop(context, updatedCategories);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Text('카테고리를 불러올 수 없습니다: $error'),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'취소',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, selectedCategories),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryGridItem extends StatelessWidget {
|
||||
final String category;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CategoryGridItem({
|
||||
required this.category,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final color = CategoryMapper.getColor(category);
|
||||
final icon = CategoryMapper.getIcon(category);
|
||||
final displayName = CategoryMapper.getDisplayName(category);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? color.withOpacity(0.2)
|
||||
: isDark
|
||||
? AppColors.darkCard
|
||||
: AppColors.lightCard,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkText
|
||||
: AppColors.lightText,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user