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:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View 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); // 추가
}

View 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 = [
'한식',
'중식',
'일식',
'양식',
'분식',
'카페',
'패스트푸드',
'기타',
];
}

View 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,
);
}

View 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,
);
}

View 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',
);
}

View 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
View 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 사용 권한이 활성화되어 있는지 확인하세요.

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

View 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;
}
}

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

View 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)}';
}
}

View 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,
);
}
}

View 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;
}
}

View 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,
};
}
}

View 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;
}
}

View 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,
);
}
}

View 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),
),
);
}

View 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),
);
}
}

View 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,
};
}
}

View 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() {
// 필요시 리소스 정리
}
}

View 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
}
}
}
''';
}

View 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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
}
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() {
// 필요시 리소스 정리
}
}

View 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() {
// 필요시 리소스 정리
}
}

View 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() {
// 필요시 리소스 정리
}
}

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

View 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,
);
}
}

View 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;
}
}

View 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;
}
}
}

View 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';
}

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

View 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;
}
}

View 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;
}
}
}

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

View 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 {};
}
}

View 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;
}
}
}

View 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,
});
}

View 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
}

View 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,
});
}

View 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,
);
}
}

View 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,
});
}

View 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,
});
}

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

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

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

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

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

View 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
View 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);
},
),
],
);

View 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: 맛집 상세 페이지로 이동
},
);
},
),
),
],
);
}
}

View File

@@ -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,
),
);
}
}

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

View 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,
),
),
],
),
),
],
);
}
}

View 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),
),
);
}
}

View File

@@ -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,
),
);
}
});
}
}

View File

@@ -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('여기로 갈게요!'),
),
),
],
),
],
),
),
],
),
),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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 ? '가져오기' : '저장',
),
),
],
),
);
}
}

View File

@@ -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,
),
);
}
}
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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],
),
),
),
],
),
),
],
],
);
}
}

View File

@@ -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),
],
),
);
},
);
}
}

View 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('설정으로 이동'),
),
],
),
);
}
}

View 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';
});
}
}

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

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

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

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

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

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

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

View 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,
};
});

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

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

View 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;
}
}

View 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),
);

View 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,
),
],
),
),
),
);
}
}