feat(app): add manual entry and sharing flows
This commit is contained in:
47
lib/core/constants/api_keys.dart
Normal file
47
lib/core/constants/api_keys.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// ApiKeys는 네이버 API 인증 정보를 환경 변수로 로드한다.
|
||||
///
|
||||
/// - `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET`는 `flutter run`/`flutter test`
|
||||
/// 실행 시 `--dart-define`으로 주입한다.
|
||||
/// - 민감 정보는 base64(난독화) 형태로 전달하고, 런타임에서 복호화한다.
|
||||
class ApiKeys {
|
||||
static const String _encodedClientId = String.fromEnvironment(
|
||||
'NAVER_CLIENT_ID',
|
||||
defaultValue: '',
|
||||
);
|
||||
static const String _encodedClientSecret = String.fromEnvironment(
|
||||
'NAVER_CLIENT_SECRET',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static String get naverClientId => _decodeIfNeeded(_encodedClientId);
|
||||
static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret);
|
||||
|
||||
static const String naverLocalSearchEndpoint =
|
||||
'https://openapi.naver.com/v1/search/local.json';
|
||||
|
||||
static bool areKeysConfigured() {
|
||||
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 배포 스크립트에서 사용할 수 있는 편의 메서드.
|
||||
static String obfuscate(String value) {
|
||||
if (value.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
return base64.encode(utf8.encode(value));
|
||||
}
|
||||
|
||||
static String _decodeIfNeeded(String value) {
|
||||
if (value.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return utf8.decode(base64.decode(value));
|
||||
} on FormatException {
|
||||
// base64가 아니면 일반 문자열로 간주 (로컬 개발 편의용)
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class AppColors {
|
||||
static const lightError = Color(0xFFFF5252);
|
||||
static const lightText = Color(0xFF222222); // 추가
|
||||
static const lightCard = Colors.white; // 추가
|
||||
|
||||
// Dark Theme Colors
|
||||
|
||||
// Dark Theme Colors
|
||||
static const darkPrimary = Color(0xFF03C75A);
|
||||
static const darkSecondary = Color(0xFF00BF63);
|
||||
static const darkBackground = Color(0xFF121212);
|
||||
@@ -24,4 +24,4 @@ class AppColors {
|
||||
static const darkError = Color(0xFFFF5252);
|
||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,35 @@ class AppConstants {
|
||||
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.';
|
||||
|
||||
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';
|
||||
|
||||
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 = [
|
||||
'한식',
|
||||
@@ -41,4 +43,4 @@ class AppConstants {
|
||||
'패스트푸드',
|
||||
'기타',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,28 @@ class AppTypography {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// 애플리케이션 전체 예외 클래스들
|
||||
///
|
||||
///
|
||||
/// 각 레이어별로 명확한 예외 계층 구조를 제공합니다.
|
||||
|
||||
/// 앱 예외 기본 클래스
|
||||
@@ -7,15 +7,12 @@ abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const AppException({
|
||||
required this.message,
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
|
||||
const AppException({required this.message, this.code, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
|
||||
String toString() =>
|
||||
'$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// 비즈니스 로직 예외
|
||||
@@ -24,23 +21,19 @@ class BusinessException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: 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();
|
||||
@@ -60,11 +53,7 @@ class DataException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 저장소 예외
|
||||
@@ -73,23 +62,19 @@ class StorageException extends DataException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: 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)';
|
||||
}
|
||||
@@ -100,19 +85,13 @@ class LocationException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 설정 예외
|
||||
class ConfigurationException extends AppException {
|
||||
const ConfigurationException({
|
||||
required String message,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
const ConfigurationException({required String message, String? code})
|
||||
: super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// UI 예외
|
||||
@@ -121,47 +100,36 @@ class UIException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: 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',
|
||||
);
|
||||
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',
|
||||
);
|
||||
|
||||
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);
|
||||
const RecommendationException({required String message, String? code})
|
||||
: super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// 알림 예외
|
||||
@@ -170,9 +138,5 @@ class NotificationException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// 데이터 레이어 예외 클래스들
|
||||
///
|
||||
///
|
||||
/// API, 데이터베이스, 파싱 관련 예외를 정의합니다.
|
||||
|
||||
import 'app_exceptions.dart';
|
||||
@@ -7,20 +7,17 @@ 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,
|
||||
);
|
||||
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
String toString() =>
|
||||
'$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 네이버 API 예외
|
||||
@@ -31,27 +28,27 @@ class NaverApiException extends ApiException {
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
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,
|
||||
);
|
||||
|
||||
message: message,
|
||||
code: 'HTML_PARSE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
@@ -63,18 +60,18 @@ class HtmlParsingException extends DataException {
|
||||
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,
|
||||
);
|
||||
|
||||
message: message,
|
||||
code: 'DATA_CONVERSION_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message ($fromType → $toType)';
|
||||
}
|
||||
@@ -86,10 +83,10 @@ class CacheException extends StorageException {
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'CACHE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
code: code ?? 'CACHE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hive 예외
|
||||
@@ -99,51 +96,47 @@ class HiveException extends StorageException {
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'HIVE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
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,
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
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',
|
||||
);
|
||||
}
|
||||
const UnsupportedUrlException({required String url, String? message})
|
||||
: super(
|
||||
message: message ?? '지원하지 않는 URL입니다',
|
||||
url: url,
|
||||
code: 'UNSUPPORTED_URL',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// 네트워크 관련 예외 클래스들
|
||||
///
|
||||
///
|
||||
/// 모든 네트워크 오류를 명확하게 분류하고 처리합니다.
|
||||
|
||||
/// 네트워크 예외 기본 클래스
|
||||
@@ -7,15 +7,16 @@ 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)' : ''}';
|
||||
String toString() =>
|
||||
'$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 연결 타임아웃 예외
|
||||
@@ -41,10 +42,10 @@ class ServerException extends NetworkException {
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 클라이언트 오류 예외 (4xx)
|
||||
@@ -54,25 +55,22 @@ class ClientException extends NetworkException {
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 파싱 오류 예외
|
||||
class ParseException extends NetworkException {
|
||||
const ParseException({
|
||||
required String message,
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
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);
|
||||
const ApiKeyException({String message = 'API 키가 설정되지 않았습니다'})
|
||||
: super(message: message);
|
||||
}
|
||||
|
||||
/// 재시도 횟수 초과 예외
|
||||
@@ -86,17 +84,13 @@ class MaxRetriesExceededException extends NetworkException {
|
||||
/// 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,
|
||||
);
|
||||
|
||||
}) : super(message: message, statusCode: 429, originalError: originalError);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
@@ -105,4 +99,4 @@ class RateLimitException extends NetworkException {
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +108,19 @@ try {
|
||||
|
||||
1. [네이버 개발자 센터](https://developers.naver.com)에서 애플리케이션 등록
|
||||
2. Client ID와 Client Secret 발급
|
||||
3. `lib/core/constants/api_keys.dart` 파일에 키 입력:
|
||||
3. 값을 base64로 인코딩한 뒤 `flutter run --dart-define`으로 전달:
|
||||
|
||||
```dart
|
||||
class ApiKeys {
|
||||
static const String naverClientId = 'YOUR_CLIENT_ID';
|
||||
static const String naverClientSecret = 'YOUR_CLIENT_SECRET';
|
||||
}
|
||||
```bash
|
||||
NAVER_CLIENT_ID=$(printf 'YOUR_CLIENT_ID' | base64)
|
||||
NAVER_CLIENT_SECRET=$(printf 'YOUR_CLIENT_SECRET' | base64)
|
||||
|
||||
flutter run \
|
||||
--dart-define=NAVER_CLIENT_ID=$NAVER_CLIENT_ID \
|
||||
--dart-define=NAVER_CLIENT_SECRET=$NAVER_CLIENT_SECRET
|
||||
```
|
||||
|
||||
로컬에서 빠르게 확인할 때는 base64 인코딩을 생략할 수 있습니다.
|
||||
|
||||
### 네트워크 설정 커스터마이징
|
||||
|
||||
`lib/core/network/network_config.dart`에서 타임아웃, 재시도 횟수 등을 조정할 수 있습니다:
|
||||
@@ -169,4 +173,4 @@ lib/
|
||||
네트워크가 느린 환경에서는 `NetworkConfig`의 타임아웃 값을 늘려보세요.
|
||||
|
||||
### API 키 에러
|
||||
API 키가 올바르게 설정되었는지 확인하고, 네이버 개발자 센터에서 API 사용 권한이 활성화되어 있는지 확인하세요.
|
||||
API 키가 올바르게 설정되었는지 확인하고, 네이버 개발자 센터에서 API 사용 권한이 활성화되어 있는지 확인하세요.
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
///
|
||||
///
|
||||
/// 네트워크 요청과 응답을 로그로 기록합니다.
|
||||
/// 디버그 모드에서만 활성화됩니다.
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
@@ -12,35 +12,35 @@ class LoggingInterceptor extends Interceptor {
|
||||
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) {
|
||||
@@ -48,32 +48,32 @@ class LoggingInterceptor extends Interceptor {
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,36 +5,38 @@ 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 대기');
|
||||
|
||||
|
||||
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) {
|
||||
// 재시도도 실패한 경우
|
||||
@@ -48,10 +50,10 @@ class RetryInterceptor extends Interceptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
|
||||
/// 재시도 가능한 오류인지 판단
|
||||
bool _shouldRetry(DioException err) {
|
||||
// 네이버 관련 요청은 재시도하지 않음
|
||||
@@ -60,7 +62,7 @@ class RetryInterceptor extends Interceptor {
|
||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 네트워크 연결 오류
|
||||
if (err.type == DioExceptionType.connectionTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout ||
|
||||
@@ -68,30 +70,30 @@ class RetryInterceptor extends Interceptor {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@ 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(
|
||||
@@ -37,20 +37,20 @@ class NetworkClient {
|
||||
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(
|
||||
@@ -60,24 +60,24 @@ class NetworkClient {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 캐시 인터셉터 설정
|
||||
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,
|
||||
@@ -86,33 +86,33 @@ class NetworkClient {
|
||||
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,
|
||||
@@ -133,14 +133,14 @@ class NetworkClient {
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
networkException = NoInternetException(
|
||||
message: error.message ?? '알 수 없는 네트워크 오류가 발생했습니다',
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
@@ -148,15 +148,15 @@ class NetworkClient {
|
||||
error: networkException,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 에러 메시지 추출
|
||||
String _getErrorMessage(Response? response) {
|
||||
if (response == null) {
|
||||
return '서버 응답을 받을 수 없습니다';
|
||||
}
|
||||
|
||||
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
|
||||
|
||||
// 상태 코드별 기본 메시지
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
@@ -179,7 +179,7 @@ class NetworkClient {
|
||||
return '서버 오류가 발생했습니다 (HTTP $statusCode)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// GET 요청
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
@@ -190,15 +190,12 @@ class NetworkClient {
|
||||
bool useCache = true,
|
||||
}) {
|
||||
final requestOptions = options ?? Options();
|
||||
|
||||
|
||||
// 캐시 사용 설정
|
||||
if (!useCache) {
|
||||
requestOptions.extra = {
|
||||
...?requestOptions.extra,
|
||||
'disableCache': true,
|
||||
};
|
||||
requestOptions.extra = {...?requestOptions.extra, 'disableCache': true};
|
||||
}
|
||||
|
||||
|
||||
return _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
@@ -207,7 +204,7 @@ class NetworkClient {
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// POST 요청
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
@@ -228,7 +225,7 @@ class NetworkClient {
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// HEAD 요청 (리다이렉션 확인용)
|
||||
Future<Response<T>> head<T>(
|
||||
String path, {
|
||||
@@ -243,12 +240,12 @@ class NetworkClient {
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 캐시 삭제
|
||||
Future<void> clearCache() async {
|
||||
await _cacheStore?.clean();
|
||||
}
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_dio.close();
|
||||
@@ -257,4 +254,4 @@ class NetworkClient {
|
||||
}
|
||||
|
||||
/// 기본 네트워크 클라이언트 인스턴스
|
||||
final networkClient = NetworkClient();
|
||||
final networkClient = NetworkClient();
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
/// 네트워크 설정 상수
|
||||
///
|
||||
///
|
||||
/// 모든 네트워크 관련 설정을 중앙 관리합니다.
|
||||
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';
|
||||
|
||||
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)}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
143
lib/core/services/ad_service.dart
Normal file
143
lib/core/services/ad_service.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 간단한 전면 광고(Interstitial Ad) 모의 서비스
|
||||
class AdService {
|
||||
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _MockInterstitialAdDialog(),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialog extends StatefulWidget {
|
||||
const _MockInterstitialAdDialog();
|
||||
|
||||
@override
|
||||
State<_MockInterstitialAdDialog> createState() =>
|
||||
_MockInterstitialAdDialogState();
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
static const int _adDurationSeconds = 4;
|
||||
|
||||
late Timer _timer;
|
||||
int _elapsedSeconds = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_elapsedSeconds++;
|
||||
});
|
||||
if (_elapsedSeconds >= _adDurationSeconds) {
|
||||
_timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canClose => _elapsedSeconds >= _adDurationSeconds;
|
||||
|
||||
double get _progress => (_elapsedSeconds / _adDurationSeconds).clamp(0, 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 80),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.ondemand_video,
|
||||
size: 56,
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'광고 시청 중...',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_canClose ? '광고가 완료되었습니다.' : '잠시만 기다려 주세요.',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.2),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_canClose
|
||||
? '이제 닫을 수 있어요.'
|
||||
: '남은 시간: ${_adDurationSeconds - _elapsedSeconds}초',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _canClose
|
||||
? () {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: Colors.deepPurple,
|
||||
),
|
||||
child: const Text('추천 계속 보기'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/core/services/bluetooth_service.dart
Normal file
89
lib/core/services/bluetooth_service.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/share_device.dart';
|
||||
|
||||
/// 실제 Bluetooth 통신을 대체하는 간단한 모의(Mock) 서비스.
|
||||
class BluetoothService {
|
||||
final _incomingDataController = StreamController<String>.broadcast();
|
||||
final Map<String, ShareDevice> _listeningDevices = {};
|
||||
final Random _random = Random();
|
||||
|
||||
Stream<String> get onDataReceived => _incomingDataController.stream;
|
||||
|
||||
/// 특정 코드로 수신 대기를 시작한다.
|
||||
Future<void> startListening(String code) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
stopListening();
|
||||
final shareDevice = ShareDevice(
|
||||
code: code,
|
||||
deviceId: 'LP-${_random.nextInt(900000) + 100000}',
|
||||
discoveredAt: DateTime.now(),
|
||||
);
|
||||
_listeningDevices[code] = shareDevice;
|
||||
}
|
||||
|
||||
/// 더 이상 수신 대기하지 않는다.
|
||||
void stopListening() {
|
||||
if (_listeningDevices.isEmpty) return;
|
||||
final codes = List<String>.from(_listeningDevices.keys);
|
||||
for (final code in codes) {
|
||||
_listeningDevices.remove(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 주변에서 수신 대기 중인 기기 목록을 반환한다.
|
||||
Future<List<ShareDevice>> scanNearbyDevices() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return _listeningDevices.values.toList();
|
||||
}
|
||||
|
||||
/// 대상 코드로 맛집 리스트를 전송한다. 실제 BT 대신 JSON 문자열을 브로드캐스트한다.
|
||||
Future<void> sendRestaurantList(
|
||||
String targetCode,
|
||||
List<Restaurant> restaurants,
|
||||
) async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
if (!_listeningDevices.containsKey(targetCode)) {
|
||||
throw Exception('해당 코드를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
final payload = jsonEncode(
|
||||
restaurants
|
||||
.map((restaurant) => _serializeRestaurant(restaurant))
|
||||
.toList(),
|
||||
);
|
||||
_incomingDataController.add(payload);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serializeRestaurant(Restaurant restaurant) {
|
||||
return {
|
||||
'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?.toIso8601String(),
|
||||
'source': restaurant.source.name,
|
||||
'createdAt': restaurant.createdAt.toIso8601String(),
|
||||
'updatedAt': restaurant.updatedAt.toIso8601String(),
|
||||
'naverPlaceId': restaurant.naverPlaceId,
|
||||
'naverUrl': restaurant.naverUrl,
|
||||
'businessHours': restaurant.businessHours,
|
||||
'lastVisited': restaurant.lastVisited?.toIso8601String(),
|
||||
'visitCount': restaurant.visitCount,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_incomingDataController.close();
|
||||
_listeningDevices.clear();
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,14 @@ class NotificationService {
|
||||
NotificationService._internal();
|
||||
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
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;
|
||||
|
||||
@@ -26,10 +27,12 @@ class NotificationService {
|
||||
Future<bool> initialize() async {
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const androidInitSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
// iOS 초기화 설정
|
||||
final iosInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
@@ -39,33 +42,33 @@ class NotificationService {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -79,9 +82,11 @@ class NotificationService {
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
}
|
||||
|
||||
@@ -89,23 +94,32 @@ class NotificationService {
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.requestNotificationsPermission();
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
} else if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
} else if (!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -114,7 +128,7 @@ class NotificationService {
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final granted = await macosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -124,7 +138,7 @@ class NotificationService {
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -132,11 +146,13 @@ class NotificationService {
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
@@ -144,27 +160,27 @@ class NotificationService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
return true;
|
||||
}
|
||||
|
||||
// 알림 탭 콜백
|
||||
static void Function(NotificationResponse)? onNotificationTap;
|
||||
|
||||
|
||||
/// 방문 확인 알림 예약
|
||||
Future<void> scheduleVisitReminder({
|
||||
required String restaurantId,
|
||||
required String restaurantName,
|
||||
required DateTime recommendationTime,
|
||||
int? delayMinutes,
|
||||
}) 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 minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
|
||||
final scheduledTime = tz.TZDateTime.now(
|
||||
tz.local,
|
||||
).add(Duration(minutes: minutesToWait));
|
||||
|
||||
// 알림 상세 설정
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
@@ -178,20 +194,20 @@ class NotificationService {
|
||||
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,
|
||||
@@ -202,11 +218,11 @@ class NotificationService {
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
payload:
|
||||
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($randomMinutes분 후)');
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
@@ -265,20 +281,15 @@ class NotificationService {
|
||||
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,
|
||||
);
|
||||
|
||||
await _notifications.show(0, title, body, notificationDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
lib/core/services/permission_service.dart
Normal file
31
lib/core/services/permission_service.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 공용 권한 유틸리티
|
||||
class PermissionService {
|
||||
static Future<bool> checkAndRequestBluetoothPermission() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final permissions = <Permission>[
|
||||
Permission.bluetooth,
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothAdvertise,
|
||||
];
|
||||
|
||||
for (final permission in permissions) {
|
||||
final status = await permission.status;
|
||||
if (status.isGranted) {
|
||||
continue;
|
||||
}
|
||||
final result = await permission.request();
|
||||
if (!result.isGranted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,14 @@ class CategoryMapper {
|
||||
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;
|
||||
}
|
||||
@@ -92,14 +92,14 @@ class CategoryMapper {
|
||||
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();
|
||||
@@ -129,14 +129,14 @@ class CategoryMapper {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ class DistanceCalculator {
|
||||
final double dLat = _toRadians(lat2 - lat1);
|
||||
final double dLon = _toRadians(lon2 - lon1);
|
||||
|
||||
final double a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
final double a =
|
||||
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_toRadians(lat1)) *
|
||||
math.cos(_toRadians(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
@@ -79,7 +80,7 @@ class DistanceCalculator {
|
||||
required double currentLon,
|
||||
}) {
|
||||
final List<T> sortedItems = List<T>.from(items);
|
||||
|
||||
|
||||
sortedItems.sort((a, b) {
|
||||
final distanceA = calculateDistance(
|
||||
lat1: currentLat,
|
||||
@@ -87,24 +88,21 @@ class DistanceCalculator {
|
||||
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,
|
||||
};
|
||||
return {'latitude': 37.5665, 'longitude': 126.9780};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,16 @@ class Validators {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -40,16 +40,16 @@ class Validators {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ class Validators {
|
||||
|
||||
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,}$',
|
||||
);
|
||||
@@ -85,8 +85,8 @@ class Validators {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,27 @@ 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;
|
||||
|
||||
@@ -41,7 +41,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -56,29 +56,28 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary
|
||||
).withValues(alpha: 0.1),
|
||||
color:
|
||||
(isDark ? AppColors.darkPrimary : AppColors.lightPrimary)
|
||||
.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
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),
|
||||
@@ -90,15 +89,15 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
// 액션 버튼 (있을 경우)
|
||||
if (actionText != null && onAction != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: onAction,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -126,20 +125,16 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 리스트 빈 상태 위젯
|
||||
///
|
||||
///
|
||||
/// 리스트나 그리드가 비어있을 때 사용하는 특화된 위젯
|
||||
class ListEmptyStateWidget extends StatelessWidget {
|
||||
/// 아이템 유형 (예: "식당", "기록" 등)
|
||||
final String itemType;
|
||||
|
||||
|
||||
/// 추가 액션 콜백 (선택사항)
|
||||
final VoidCallback? onAdd;
|
||||
|
||||
const ListEmptyStateWidget({
|
||||
super.key,
|
||||
required this.itemType,
|
||||
this.onAdd,
|
||||
});
|
||||
const ListEmptyStateWidget({super.key, required this.itemType, this.onAdd});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -151,4 +146,4 @@ class ListEmptyStateWidget extends StatelessWidget {
|
||||
onAction: onAdd,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@ 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;
|
||||
|
||||
@@ -29,7 +29,7 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
@@ -44,14 +44,14 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
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),
|
||||
@@ -61,7 +61,7 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
// 재시도 버튼 (있을 경우)
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
@@ -70,8 +70,8 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('다시 시도'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -96,21 +96,16 @@ void showErrorSnackBar({
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
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),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
margin: const EdgeInsets.all(8),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import 'package:flutter/material.dart';
|
||||
import '../constants/app_colors.dart';
|
||||
|
||||
/// 로딩 인디케이터 위젯
|
||||
///
|
||||
///
|
||||
/// 앱 전체에서 일관된 로딩 표시를 위한 공통 위젯
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
|
||||
/// 인디케이터 크기
|
||||
final double size;
|
||||
|
||||
|
||||
/// 스트로크 너비
|
||||
final double strokeWidth;
|
||||
|
||||
@@ -24,7 +24,7 @@ class LoadingIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -46,8 +46,8 @@ class LoadingIndicator extends StatelessWidget {
|
||||
message!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -60,12 +60,12 @@ class LoadingIndicator extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 전체 화면 로딩 인디케이터
|
||||
///
|
||||
///
|
||||
/// 화면 전체를 덮는 로딩 표시를 위한 위젯
|
||||
class FullScreenLoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
|
||||
/// 배경 투명도
|
||||
final double backgroundOpacity;
|
||||
|
||||
@@ -78,11 +78,12 @@ class FullScreenLoadingIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Container(
|
||||
color: (isDark ? Colors.black : Colors.white)
|
||||
.withValues(alpha: backgroundOpacity),
|
||||
color: (isDark ? Colors.black : Colors.white).withValues(
|
||||
alpha: backgroundOpacity,
|
||||
),
|
||||
child: LoadingIndicator(message: message),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../naver/naver_local_search_api.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
|
||||
/// 네이버 데이터 변환기
|
||||
///
|
||||
///
|
||||
/// 네이버 API 응답을 도메인 엔티티로 변환합니다.
|
||||
class NaverDataConverter {
|
||||
static const _uuid = Uuid();
|
||||
@@ -22,13 +22,21 @@ class NaverDataConverter {
|
||||
);
|
||||
|
||||
// 카테고리 파싱 및 정규화
|
||||
final categoryParts = result.category.split('>').map((s) => s.trim()).toList();
|
||||
final categoryParts = result.category
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
final mainCategory = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : mainCategory;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
mainCategory,
|
||||
subCategory,
|
||||
);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
name: result.title,
|
||||
@@ -36,8 +44,8 @@ class NaverDataConverter {
|
||||
subCategory: subCategory,
|
||||
description: result.description.isNotEmpty ? result.description : null,
|
||||
phoneNumber: result.telephone.isNotEmpty ? result.telephone : null,
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
roadAddress: result.roadAddress.isNotEmpty
|
||||
? result.roadAddress
|
||||
: result.address,
|
||||
jibunAddress: result.address,
|
||||
latitude: convertedCoords['latitude'] ?? 37.5665,
|
||||
@@ -77,10 +85,15 @@ class NaverDataConverter {
|
||||
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;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: mainCategory;
|
||||
|
||||
// CategoryMapper를 사용한 정규화
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(mainCategory, subCategory);
|
||||
final normalizedCategory = CategoryMapper.normalizeNaverCategory(
|
||||
mainCategory,
|
||||
subCategory,
|
||||
);
|
||||
|
||||
return Restaurant(
|
||||
id: id ?? _uuid.v4(),
|
||||
@@ -116,11 +129,6 @@ class NaverDataConverter {
|
||||
final longitude = mapx / 10000000.0;
|
||||
final latitude = mapy / 10000000.0;
|
||||
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
return {'latitude': latitude, 'longitude': longitude};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ 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';
|
||||
|
||||
static const String _graphqlEndpoint =
|
||||
'https://pcmap-api.place.naver.com/graphql';
|
||||
|
||||
NaverGraphQLApi({NetworkClient? networkClient})
|
||||
: _networkClient = networkClient ?? NetworkClient();
|
||||
@@ -40,9 +41,7 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
@@ -106,9 +105,7 @@ class NaverGraphQLApi {
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
@@ -149,9 +146,7 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
throw ParseException(
|
||||
message: 'GraphQL 오류: ${response['errors']}',
|
||||
);
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
@@ -164,4 +159,4 @@ class NaverGraphQLApi {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// \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!) {
|
||||
@@ -26,7 +26,7 @@ class NaverGraphQLQueries {
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
|
||||
/// \uc7a5\uc18c \uc0c1\uc138 \uc815\ubcf4 \ucffc\ub9ac - nxPlaces \uc0ac\uc6a9 (\ud3f4\ubc31)
|
||||
static const String nxPlaceDetailQuery = '''
|
||||
query getPlaceDetail(\$id: String!) {
|
||||
@@ -49,4 +49,4 @@ class NaverGraphQLQueries {
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,42 +50,46 @@ class NaverLocalSearchResult {
|
||||
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,
|
||||
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;
|
||||
@@ -142,7 +146,7 @@ class NaverLocalSearchApi {
|
||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
||||
debugPrint('Error type: ${e.type}');
|
||||
debugPrint('Error response: ${e.response?.data}');
|
||||
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
}
|
||||
@@ -194,4 +198,4 @@ class NaverLocalSearchApi {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../../../core/network/network_config.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
|
||||
/// 네이버 프록시 클라이언트
|
||||
///
|
||||
///
|
||||
/// 웹 환경에서 CORS 문제를 해결하기 위한 프록시 클라이언트입니다.
|
||||
class NaverProxyClient {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -23,22 +23,21 @@ class NaverProxyClient {
|
||||
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':
|
||||
'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: '프록시 응답이 비어있습니다',
|
||||
);
|
||||
throw ParseException(message: '프록시 응답이 비어있습니다');
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
@@ -46,7 +45,7 @@ class NaverProxyClient {
|
||||
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 프록시 접근이 거부되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
@@ -54,7 +53,7 @@ class NaverProxyClient {
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
throw ServerException(
|
||||
message: '프록시를 통한 페이지 로드에 실패했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
@@ -72,12 +71,10 @@ class NaverProxyClient {
|
||||
try {
|
||||
final testUrl = 'https://map.naver.com';
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(testUrl);
|
||||
|
||||
|
||||
final response = await _networkClient.head(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
validateStatus: (status) => status! < 500,
|
||||
),
|
||||
options: Options(validateStatus: (status) => status! < 500),
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
@@ -98,4 +95,4 @@ class NaverProxyClient {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
|
||||
/// 네이버 URL 리졸버
|
||||
///
|
||||
///
|
||||
/// 네이버 단축 URL을 실제 URL로 변환하고 최종 리다이렉트 URL을 추적합니다.
|
||||
class NaverUrlResolver {
|
||||
final NetworkClient _networkClient;
|
||||
@@ -40,7 +40,7 @@ class NaverUrlResolver {
|
||||
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');
|
||||
@@ -58,7 +58,7 @@ class NaverUrlResolver {
|
||||
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(shortUrl);
|
||||
|
||||
|
||||
final response = await _networkClient.get(
|
||||
proxyUrl,
|
||||
options: Options(
|
||||
@@ -70,7 +70,7 @@ class NaverUrlResolver {
|
||||
|
||||
// 응답에서 URL 정보 추출
|
||||
final responseData = response.data.toString();
|
||||
|
||||
|
||||
// meta refresh 태그에서 URL 추출
|
||||
final metaRefreshRegex = RegExp(
|
||||
'<meta[^>]+http-equiv="refresh"[^>]+content="0;url=([^"]+)"[^>]*>',
|
||||
@@ -105,7 +105,7 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
/// 최종 리다이렉트 URL 가져오기
|
||||
///
|
||||
///
|
||||
/// 여러 단계의 리다이렉트를 거쳐 최종 URL을 반환합니다.
|
||||
Future<String> getFinalRedirectUrl(String url) async {
|
||||
try {
|
||||
@@ -148,4 +148,4 @@ class NaverUrlResolver {
|
||||
void dispose() {
|
||||
// 필요시 리소스 정리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import '../datasources/remote/naver_html_extractor.dart';
|
||||
/// 내부적으로 각 기능별로 분리된 API 클라이언트를 사용합니다.
|
||||
class NaverApiClient {
|
||||
final NetworkClient _networkClient;
|
||||
|
||||
|
||||
// 분리된 API 클라이언트들
|
||||
late final NaverLocalSearchApi _localSearchApi;
|
||||
late final NaverUrlResolver _urlResolver;
|
||||
@@ -73,27 +73,27 @@ class NaverApiClient {
|
||||
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',
|
||||
'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 응답이 비어있습니다',
|
||||
);
|
||||
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,
|
||||
@@ -138,7 +138,8 @@ class NaverApiClient {
|
||||
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',
|
||||
'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/',
|
||||
@@ -162,12 +163,14 @@ class NaverApiClient {
|
||||
|
||||
// 모든 한글 텍스트 추출
|
||||
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
|
||||
|
||||
|
||||
// JSON-LD 데이터 추출 시도
|
||||
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
|
||||
|
||||
|
||||
// Apollo State 데이터 추출 시도
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
|
||||
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(
|
||||
html,
|
||||
);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
@@ -214,4 +217,4 @@ extension NaverLocalSearchResultExtension on NaverLocalSearchResult {
|
||||
Restaurant toRestaurant({required String id}) {
|
||||
return NaverDataConverter.fromLocalSearchResult(this, id: id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,37 +5,234 @@ import 'package:flutter/foundation.dart';
|
||||
class NaverHtmlExtractor {
|
||||
// 제외할 UI 텍스트 패턴 (확장)
|
||||
static const List<String> _excludePatterns = [
|
||||
'로그인', '메뉴', '검색', '지도', '리뷰', '사진', '네이버', '영업시간',
|
||||
'전화번호', '주소', '찾아오시는길', '예약', '홈', '이용약관', '개인정보',
|
||||
'고객센터', '신고', '공유', '즐겨찾기', '길찾기', '거리뷰', '저장',
|
||||
'더보기', '접기', '펼치기', '닫기', '취소', '확인', '선택', '전체', '삭제',
|
||||
'플레이스', '지도보기', '상세보기', '평점', '별점', '추천', '인기', '최신',
|
||||
'오늘', '내일', '영업중', '영업종료', '휴무', '정기휴무', '임시휴무',
|
||||
'배달', '포장', '매장', '주차', '단체석', '예약가능', '대기', '웨이팅',
|
||||
'영수증', '현금', '카드', '계산서', '할인', '쿠폰', '적립', '포인트',
|
||||
'회원', '비회원', '로그아웃', '마이페이지', '알림', '설정', '도움말',
|
||||
'문의', '제보', '수정', '삭제', '등록', '작성', '댓글', '답글', '좋아요',
|
||||
'싫어요', '스크랩', '북마크', '태그', '해시태그', '팔로우', '팔로잉',
|
||||
'팔로워', '차단', '신고하기', '게시물', '프로필', '활동', '통계', '분석',
|
||||
'다운로드', '업로드', '첨부', '파일', '이미지', '동영상', '음성', '링크',
|
||||
'복사', '붙여넣기', '되돌리기', '다시실행', '새로고침', '뒤로', '앞으로',
|
||||
'시작', '종료', '일시정지', '재생', '정지', '음량', '화면', '전체화면',
|
||||
'최소화', '최대화', '창닫기', '새창', '새탭', '인쇄', '저장하기', '열기',
|
||||
'가져오기', '내보내기', '동기화', '백업', '복원', '초기화', '재설정',
|
||||
'업데이트', '버전', '정보', '소개', '안내', '공지', '이벤트', '혜택',
|
||||
'쿠키', '개인정보처리방침', '서비스이용약관', '위치정보이용약관',
|
||||
'청소년보호정책', '저작권', '라이선스', '제휴', '광고', '비즈니스',
|
||||
'개발자', 'API', '오픈소스', '기여', '후원', '기부', '결제', '환불',
|
||||
'교환', '반품', '배송', '택배', '운송장', '추적', '도착', '출발',
|
||||
'네이버 지도', '카카오맵', '구글맵', 'T맵', '지도 앱', '내비게이션',
|
||||
'경로', '소요시간', '거리', '도보', '자전거', '대중교통', '자동차',
|
||||
'지하철', '버스', '택시', '기차', '비행기', '선박', '도보', '환승',
|
||||
'출구', '입구', '승강장', '매표소', '화장실', '편의시설', '주차장',
|
||||
'엘리베이터', '에스컬레이터', '계단', '경사로', '점자블록', '휠체어',
|
||||
'유모차', '애완동물', '흡연', '금연', '와이파이', '콘센트', '충전',
|
||||
'PC', '프린터', '팩스', '복사기', '회의실', '세미나실', '강당', '공연장',
|
||||
'전시장', '박물관', '미술관', '도서관', '체육관', '수영장', '운동장',
|
||||
'놀이터', '공원', '산책로', '자전거도로', '등산로', '캠핑장', '낚시터'
|
||||
'로그인',
|
||||
'메뉴',
|
||||
'검색',
|
||||
'지도',
|
||||
'리뷰',
|
||||
'사진',
|
||||
'네이버',
|
||||
'영업시간',
|
||||
'전화번호',
|
||||
'주소',
|
||||
'찾아오시는길',
|
||||
'예약',
|
||||
'홈',
|
||||
'이용약관',
|
||||
'개인정보',
|
||||
'고객센터',
|
||||
'신고',
|
||||
'공유',
|
||||
'즐겨찾기',
|
||||
'길찾기',
|
||||
'거리뷰',
|
||||
'저장',
|
||||
'더보기',
|
||||
'접기',
|
||||
'펼치기',
|
||||
'닫기',
|
||||
'취소',
|
||||
'확인',
|
||||
'선택',
|
||||
'전체',
|
||||
'삭제',
|
||||
'플레이스',
|
||||
'지도보기',
|
||||
'상세보기',
|
||||
'평점',
|
||||
'별점',
|
||||
'추천',
|
||||
'인기',
|
||||
'최신',
|
||||
'오늘',
|
||||
'내일',
|
||||
'영업중',
|
||||
'영업종료',
|
||||
'휴무',
|
||||
'정기휴무',
|
||||
'임시휴무',
|
||||
'배달',
|
||||
'포장',
|
||||
'매장',
|
||||
'주차',
|
||||
'단체석',
|
||||
'예약가능',
|
||||
'대기',
|
||||
'웨이팅',
|
||||
'영수증',
|
||||
'현금',
|
||||
'카드',
|
||||
'계산서',
|
||||
'할인',
|
||||
'쿠폰',
|
||||
'적립',
|
||||
'포인트',
|
||||
'회원',
|
||||
'비회원',
|
||||
'로그아웃',
|
||||
'마이페이지',
|
||||
'알림',
|
||||
'설정',
|
||||
'도움말',
|
||||
'문의',
|
||||
'제보',
|
||||
'수정',
|
||||
'삭제',
|
||||
'등록',
|
||||
'작성',
|
||||
'댓글',
|
||||
'답글',
|
||||
'좋아요',
|
||||
'싫어요',
|
||||
'스크랩',
|
||||
'북마크',
|
||||
'태그',
|
||||
'해시태그',
|
||||
'팔로우',
|
||||
'팔로잉',
|
||||
'팔로워',
|
||||
'차단',
|
||||
'신고하기',
|
||||
'게시물',
|
||||
'프로필',
|
||||
'활동',
|
||||
'통계',
|
||||
'분석',
|
||||
'다운로드',
|
||||
'업로드',
|
||||
'첨부',
|
||||
'파일',
|
||||
'이미지',
|
||||
'동영상',
|
||||
'음성',
|
||||
'링크',
|
||||
'복사',
|
||||
'붙여넣기',
|
||||
'되돌리기',
|
||||
'다시실행',
|
||||
'새로고침',
|
||||
'뒤로',
|
||||
'앞으로',
|
||||
'시작',
|
||||
'종료',
|
||||
'일시정지',
|
||||
'재생',
|
||||
'정지',
|
||||
'음량',
|
||||
'화면',
|
||||
'전체화면',
|
||||
'최소화',
|
||||
'최대화',
|
||||
'창닫기',
|
||||
'새창',
|
||||
'새탭',
|
||||
'인쇄',
|
||||
'저장하기',
|
||||
'열기',
|
||||
'가져오기',
|
||||
'내보내기',
|
||||
'동기화',
|
||||
'백업',
|
||||
'복원',
|
||||
'초기화',
|
||||
'재설정',
|
||||
'업데이트',
|
||||
'버전',
|
||||
'정보',
|
||||
'소개',
|
||||
'안내',
|
||||
'공지',
|
||||
'이벤트',
|
||||
'혜택',
|
||||
'쿠키',
|
||||
'개인정보처리방침',
|
||||
'서비스이용약관',
|
||||
'위치정보이용약관',
|
||||
'청소년보호정책',
|
||||
'저작권',
|
||||
'라이선스',
|
||||
'제휴',
|
||||
'광고',
|
||||
'비즈니스',
|
||||
'개발자',
|
||||
'API',
|
||||
'오픈소스',
|
||||
'기여',
|
||||
'후원',
|
||||
'기부',
|
||||
'결제',
|
||||
'환불',
|
||||
'교환',
|
||||
'반품',
|
||||
'배송',
|
||||
'택배',
|
||||
'운송장',
|
||||
'추적',
|
||||
'도착',
|
||||
'출발',
|
||||
'네이버 지도',
|
||||
'카카오맵',
|
||||
'구글맵',
|
||||
'T맵',
|
||||
'지도 앱',
|
||||
'내비게이션',
|
||||
'경로',
|
||||
'소요시간',
|
||||
'거리',
|
||||
'도보',
|
||||
'자전거',
|
||||
'대중교통',
|
||||
'자동차',
|
||||
'지하철',
|
||||
'버스',
|
||||
'택시',
|
||||
'기차',
|
||||
'비행기',
|
||||
'선박',
|
||||
'도보',
|
||||
'환승',
|
||||
'출구',
|
||||
'입구',
|
||||
'승강장',
|
||||
'매표소',
|
||||
'화장실',
|
||||
'편의시설',
|
||||
'주차장',
|
||||
'엘리베이터',
|
||||
'에스컬레이터',
|
||||
'계단',
|
||||
'경사로',
|
||||
'점자블록',
|
||||
'휠체어',
|
||||
'유모차',
|
||||
'애완동물',
|
||||
'흡연',
|
||||
'금연',
|
||||
'와이파이',
|
||||
'콘센트',
|
||||
'충전',
|
||||
'PC',
|
||||
'프린터',
|
||||
'팩스',
|
||||
'복사기',
|
||||
'회의실',
|
||||
'세미나실',
|
||||
'강당',
|
||||
'공연장',
|
||||
'전시장',
|
||||
'박물관',
|
||||
'미술관',
|
||||
'도서관',
|
||||
'체육관',
|
||||
'수영장',
|
||||
'운동장',
|
||||
'놀이터',
|
||||
'공원',
|
||||
'산책로',
|
||||
'자전거도로',
|
||||
'등산로',
|
||||
'캠핑장',
|
||||
'낚시터',
|
||||
];
|
||||
|
||||
/// HTML에서 유효한 한글 텍스트 추출 (UI 텍스트 제외)
|
||||
@@ -52,21 +249,35 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 특정 태그의 내용만 추출 (제목, 본문 등 중요 텍스트가 있을 가능성이 높은 태그)
|
||||
final contentTags = [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'span', 'div', 'li', 'td', 'th',
|
||||
'strong', 'em', 'b', 'i', 'a'
|
||||
'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 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'[가-힣]'))) {
|
||||
@@ -77,11 +288,11 @@ class NaverHtmlExtractor {
|
||||
// 모든 태그 제거 후 남은 텍스트도 추가
|
||||
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) {
|
||||
@@ -91,17 +302,19 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 중복 제거 및 필터링
|
||||
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)) {
|
||||
if (text == pattern ||
|
||||
text.startsWith(pattern) ||
|
||||
text.endsWith(pattern)) {
|
||||
isExcluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!isExcluded && text.length >= 2 && text.length <= 50) {
|
||||
uniqueTexts.add(text);
|
||||
}
|
||||
@@ -109,13 +322,13 @@ class NaverHtmlExtractor {
|
||||
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@@ -217,7 +430,7 @@ class NaverHtmlExtractor {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/// HTML에서 Place URL 추출 (og:url 메타 태그)
|
||||
static String? extractPlaceLink(String html) {
|
||||
try {
|
||||
@@ -232,7 +445,7 @@ class NaverHtmlExtractor {
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
// canonical 링크 태그에서 추출
|
||||
final canonicalRegex = RegExp(
|
||||
r'<link[^>]+rel="canonical"[^>]+href="([^"]+)"',
|
||||
@@ -247,7 +460,7 @@ class NaverHtmlExtractor {
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 페이지의 HTML에서 식당 정보를 추출합니다.
|
||||
class NaverHtmlParser {
|
||||
// CSS 셀렉터 상수
|
||||
@@ -13,38 +13,38 @@ class NaverHtmlParser {
|
||||
'[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 {
|
||||
@@ -60,7 +60,7 @@ class NaverHtmlParser {
|
||||
'businessHours': extractBusinessHours(document),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// 식당 이름 추출
|
||||
String? extractName(Document document) {
|
||||
try {
|
||||
@@ -82,7 +82,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 카테고리 추출
|
||||
String? extractCategory(Document document) {
|
||||
try {
|
||||
@@ -102,7 +102,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 서브 카테고리 추출
|
||||
String? extractSubCategory(Document document) {
|
||||
try {
|
||||
@@ -120,7 +120,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 설명 추출
|
||||
String? extractDescription(Document document) {
|
||||
try {
|
||||
@@ -142,7 +142,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 전화번호 추출
|
||||
String? extractPhoneNumber(Document document) {
|
||||
try {
|
||||
@@ -164,7 +164,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 도로명 주소 추출
|
||||
String? extractRoadAddress(Document document) {
|
||||
try {
|
||||
@@ -184,7 +184,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 지번 주소 추출
|
||||
String? extractJibunAddress(Document document) {
|
||||
try {
|
||||
@@ -193,8 +193,8 @@ class NaverHtmlParser {
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
// 지번 주소 패턴 확인 (숫자-숫자 형식 포함)
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
if (RegExp(r'\d+\-\d+').hasMatch(text) &&
|
||||
!text.contains('로') &&
|
||||
!text.contains('길')) {
|
||||
return text;
|
||||
}
|
||||
@@ -206,7 +206,7 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 위도 추출
|
||||
double? extractLatitude(Document document) {
|
||||
try {
|
||||
@@ -223,7 +223,7 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
@@ -236,14 +236,14 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 경도 추출
|
||||
double? extractLongitude(Document document) {
|
||||
try {
|
||||
@@ -260,7 +260,7 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자바스크립트 변수에서 추출 시도
|
||||
final scripts = document.querySelectorAll('script');
|
||||
for (final script in scripts) {
|
||||
@@ -273,14 +273,14 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 영업시간 추출
|
||||
String? extractBusinessHours(Document document) {
|
||||
try {
|
||||
@@ -288,10 +288,10 @@ class NaverHtmlParser {
|
||||
final elements = document.querySelectorAll(selector);
|
||||
for (final element in elements) {
|
||||
final text = element.text.trim();
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
if (text.isNotEmpty &&
|
||||
(text.contains('시') ||
|
||||
text.contains(':') ||
|
||||
text.contains('영업'))) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -302,4 +302,4 @@ class NaverHtmlParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,34 +18,37 @@ import '../../../core/utils/category_mapper.dart';
|
||||
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 _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})
|
||||
bool _isDisposed = false;
|
||||
|
||||
NaverMapParser({NaverApiClient? apiClient})
|
||||
: _apiClient = apiClient ?? NaverApiClient();
|
||||
|
||||
|
||||
/// 네이버 지도 URL에서 식당 정보를 파싱합니다.
|
||||
///
|
||||
///
|
||||
/// 지원하는 URL 형식:
|
||||
/// - https://map.naver.com/p/restaurant/1234567890
|
||||
/// - https://naver.me/abcdefgh
|
||||
///
|
||||
///
|
||||
/// [userLatitude]와 [userLongitude]를 제공하면 중복 상호명이 있을 때
|
||||
/// 가장 가까운 위치의 식당을 선택합니다.
|
||||
Future<Restaurant> parseRestaurantFromUrl(
|
||||
@@ -53,23 +56,26 @@ class NaverMapParser {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
if (_isDisposed) {
|
||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||
}
|
||||
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) {
|
||||
@@ -77,24 +83,31 @@ class NaverMapParser {
|
||||
final shortUrlId = _extractShortUrlId(url);
|
||||
if (shortUrlId != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Using short URL ID as place ID: $shortUrlId');
|
||||
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);
|
||||
final restaurant = await _parseWithLocalSearch(
|
||||
placeId,
|
||||
finalUrl,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
}
|
||||
@@ -106,7 +119,7 @@ class NaverMapParser {
|
||||
// 실패 시 기본 파싱으로 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GraphQL API로 식당 정보 가져오기 (기본 플로우)
|
||||
final restaurantData = await _fetchRestaurantFromGraphQL(
|
||||
placeId,
|
||||
@@ -114,7 +127,6 @@ class NaverMapParser {
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
rethrow;
|
||||
@@ -128,7 +140,7 @@ class NaverMapParser {
|
||||
throw NaverMapParseException('네이버 지도 파싱 중 오류가 발생했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// URL이 유효한 네이버 지도 URL인지 확인
|
||||
bool _isValidNaverUrl(String url) {
|
||||
try {
|
||||
@@ -138,15 +150,15 @@ class NaverMapParser {
|
||||
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 {
|
||||
@@ -156,7 +168,7 @@ class NaverMapParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// GraphQL API로 식당 정보 가져오기
|
||||
Future<Map<String, dynamic>> _fetchRestaurantFromGraphQL(
|
||||
String placeId, {
|
||||
@@ -168,35 +180,41 @@ class NaverMapParser {
|
||||
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 방지
|
||||
|
||||
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}');
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}');
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
@@ -205,21 +223,25 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Place ID로 검색
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: _longDelayMillis)); // 더 긴 지연
|
||||
|
||||
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}');
|
||||
debugPrint(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
@@ -227,7 +249,7 @@ class NaverMapParser {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
@@ -236,12 +258,11 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
throw RateLimitException(
|
||||
@@ -250,7 +271,7 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||
// 첫 번째 시도: places 쿼리
|
||||
try {
|
||||
@@ -262,7 +283,7 @@ class NaverMapParser {
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.placeDetailQuery,
|
||||
);
|
||||
|
||||
|
||||
// places 응답 처리 (배열일 수도 있음)
|
||||
final placesData = response['data']?['places'];
|
||||
if (placesData != null) {
|
||||
@@ -277,7 +298,7 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: places query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 두 번째 시도: nxPlaces 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
@@ -288,7 +309,7 @@ class NaverMapParser {
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||
);
|
||||
|
||||
|
||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||
final nxPlacesData = response['data']?['nxPlaces'];
|
||||
if (nxPlacesData != null) {
|
||||
@@ -303,21 +324,28 @@ class NaverMapParser {
|
||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: All GraphQL queries failed, falling back to HTML parsing');
|
||||
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 categoryParts = item.category
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
.toList();
|
||||
final category = categoryParts.isNotEmpty ? categoryParts.first : '음식점';
|
||||
final subCategory = categoryParts.length > 1 ? categoryParts.last : category;
|
||||
|
||||
final subCategory = categoryParts.length > 1
|
||||
? categoryParts.last
|
||||
: category;
|
||||
|
||||
return {
|
||||
'name': item.title,
|
||||
'category': category,
|
||||
@@ -326,25 +354,32 @@ class NaverMapParser {
|
||||
'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에서는 영업시간 정보 제공 안 함
|
||||
'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();
|
||||
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,
|
||||
@@ -360,26 +395,24 @@ class NaverMapParser {
|
||||
: 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,
|
||||
);
|
||||
throw RateLimitException(originalError: e);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Restaurant 객체 생성
|
||||
Restaurant _createRestaurant(
|
||||
Map<String, dynamic> data,
|
||||
@@ -397,25 +430,33 @@ class NaverMapParser {
|
||||
final double? latitude = data['latitude'];
|
||||
final double? longitude = data['longitude'];
|
||||
final String? businessHours = data['businessHours'];
|
||||
|
||||
|
||||
// 카테고리 정규화
|
||||
final String normalizedCategory = CategoryMapper.normalizeNaverCategory(rawCategory, rawSubCategory);
|
||||
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 : '주소 정보를 가져올 수 없습니다';
|
||||
|
||||
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 ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
description:
|
||||
description ?? '네이버 지도에서 가져온 장소입니다. 자세한 정보는 네이버 지도에서 확인해주세요.',
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: finalRoadAddress,
|
||||
jibunAddress: finalJibunAddress,
|
||||
@@ -432,7 +473,7 @@ class NaverMapParser {
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 기본 정보로 Restaurant 생성 (Fallback)
|
||||
Restaurant _createFallbackRestaurant(String placeId, String url) {
|
||||
return Restaurant(
|
||||
@@ -457,7 +498,7 @@ class NaverMapParser {
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 단축 URL을 위한 향상된 파싱 메서드
|
||||
/// 한글 텍스트를 추출하고 로컬 검색 API를 통해 정확한 정보를 획득
|
||||
Future<Restaurant> _parseWithLocalSearch(
|
||||
@@ -469,16 +510,16 @@ class NaverMapParser {
|
||||
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) {
|
||||
@@ -499,25 +540,27 @@ class NaverMapParser {
|
||||
} else {
|
||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
|
||||
// 2. 로컬 검색 API 호출
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: _shortDelayMillis)); // 429 에러 방지
|
||||
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: _shortDelayMillis),
|
||||
); // 429 에러 방지
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: searchQuery,
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
display: 20, // 더 많은 결과 검색
|
||||
display: 20, // 더 많은 결과 검색
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
throw NaverMapParseException('검색 결과가 없습니다: $searchQuery');
|
||||
}
|
||||
|
||||
|
||||
// 디버깅: 검색 결과 Place ID 분석
|
||||
if (kDebugMode) {
|
||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
@@ -530,10 +573,10 @@ class NaverMapParser {
|
||||
}
|
||||
debugPrint('=====================================');
|
||||
}
|
||||
|
||||
|
||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
|
||||
|
||||
// 1차: Place ID가 정확히 일치하는 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
final extractedId = result.extractPlaceId();
|
||||
@@ -545,18 +588,19 @@ class NaverMapParser {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2차: 상호명이 유사한 결과 찾기
|
||||
if (bestMatch == null) {
|
||||
// JSON-LD나 Apollo State에서 추출한 정확한 상호명이 있으면 사용
|
||||
String? exactName = koreanData['jsonLdName'] as String? ??
|
||||
koreanData['apolloStateName'] as String?;
|
||||
|
||||
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) ||
|
||||
if (result.title == exactName ||
|
||||
result.title.contains(exactName) ||
|
||||
exactName.contains(result.title)) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
@@ -567,15 +611,19 @@ class NaverMapParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 3차: 거리 기반 선택 (사용자 위치가 있는 경우)
|
||||
if (bestMatch == null && userLatitude != null && userLongitude != null) {
|
||||
bestMatch = _findNearestResult(searchResults, userLatitude, userLongitude);
|
||||
bestMatch = _findNearestResult(
|
||||
searchResults,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (bestMatch != null && kDebugMode) {
|
||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 최종: 첫 번째 결과 사용
|
||||
if (bestMatch == null) {
|
||||
bestMatch = searchResults.first;
|
||||
@@ -583,10 +631,10 @@ class NaverMapParser {
|
||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. Restaurant 객체 생성
|
||||
final restaurant = bestMatch.toRestaurant(id: _uuid.v4());
|
||||
|
||||
|
||||
// 추가 정보 보완
|
||||
return restaurant.copyWith(
|
||||
naverPlaceId: placeId,
|
||||
@@ -595,7 +643,7 @@ class NaverMapParser {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 가장 가까운 결과 찾기 (거리 기반)
|
||||
NaverLocalSearchResult? _findNearestResult(
|
||||
List<NaverLocalSearchResult> results,
|
||||
@@ -604,56 +652,66 @@ class NaverMapParser {
|
||||
) {
|
||||
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)');
|
||||
debugPrint(
|
||||
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
|
||||
/// 두 지점 간의 거리 계산 (Haversine 공식 사용)
|
||||
///
|
||||
///
|
||||
/// 반환값: 킬로미터 단위의 거리
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
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)) +
|
||||
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() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
_apiClient.dispose();
|
||||
}
|
||||
}
|
||||
@@ -661,9 +719,9 @@ class NaverMapParser {
|
||||
/// 네이버 지도 파싱 예외
|
||||
class NaverMapParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
|
||||
NaverMapParseException(this.message);
|
||||
|
||||
|
||||
@override
|
||||
String toString() => 'NaverMapParseException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,26 @@ 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);
|
||||
|
||||
|
||||
NaverSearchService({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||
: _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
/// URL에서 식당 정보 가져오기
|
||||
///
|
||||
///
|
||||
/// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다.
|
||||
///
|
||||
///
|
||||
/// [url] 네이버 지도 URL 또는 단축 URL
|
||||
///
|
||||
///
|
||||
/// Throws:
|
||||
/// - [NaverMapParseException] URL 파싱 실패 시
|
||||
/// - [NetworkException] 네트워크 오류 발생 시
|
||||
@@ -39,15 +37,12 @@ class NaverSearchService {
|
||||
if (e is NaverMapParseException || e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 정보를 가져올 수 없습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
throw ParseException(message: '식당 정보를 가져올 수 없습니다: $e', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 키워드로 주변 식당 검색
|
||||
///
|
||||
///
|
||||
/// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다.
|
||||
Future<List<Restaurant>> searchNearbyRestaurants({
|
||||
required String query,
|
||||
@@ -64,7 +59,7 @@ class NaverSearchService {
|
||||
display: maxResults,
|
||||
sort: sort,
|
||||
);
|
||||
|
||||
|
||||
return searchResults
|
||||
.map((result) => result.toRestaurant(id: _uuid.v4()))
|
||||
.toList();
|
||||
@@ -72,15 +67,12 @@ class NaverSearchService {
|
||||
if (e is NetworkException) {
|
||||
rethrow;
|
||||
}
|
||||
throw ParseException(
|
||||
message: '식당 검색에 실패했습니다: $e',
|
||||
originalError: e,
|
||||
);
|
||||
throw ParseException(message: '식당 검색에 실패했습니다: $e', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 식당 이름으로 상세 정보 검색
|
||||
///
|
||||
///
|
||||
/// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다.
|
||||
Future<Restaurant?> searchRestaurantDetails({
|
||||
required String name,
|
||||
@@ -98,7 +90,7 @@ class NaverSearchService {
|
||||
query = '${addressParts[0]} ${addressParts[1]} $name';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final searchResults = await _apiClient.searchLocal(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
@@ -106,37 +98,38 @@ class NaverSearchService {
|
||||
display: 5,
|
||||
sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선
|
||||
);
|
||||
|
||||
|
||||
if (searchResults.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 가장 유사한 결과 찾기 (주소가 없으면 거리 기반 선택 포함)
|
||||
final bestMatch = _findBestMatch(
|
||||
name,
|
||||
name,
|
||||
searchResults,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
);
|
||||
|
||||
|
||||
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,
|
||||
description:
|
||||
detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: restaurant.phoneNumber,
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
@@ -146,9 +139,11 @@ class NaverSearchService {
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverPlaceId:
|
||||
detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
businessHours:
|
||||
detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
@@ -159,10 +154,10 @@ class NaverSearchService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is NetworkException) {
|
||||
@@ -174,7 +169,7 @@ class NaverSearchService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 가장 유사한 검색 결과 찾기
|
||||
NaverLocalSearchResult? _findBestMatch(
|
||||
String targetName,
|
||||
@@ -184,30 +179,32 @@ class NaverSearchService {
|
||||
String? address,
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택
|
||||
// TODO: 네이버 좌표계(mapx, mapy)를 WGS84 좌표계로 변환하는 로직 필요
|
||||
// 현재는 네이버 API가 좌표 기반 정렬을 지원하므로 첫 번째 결과 사용
|
||||
if ((address == null || address.isEmpty) && latitude != null && longitude != null) {
|
||||
if ((address == null || address.isEmpty) &&
|
||||
latitude != null &&
|
||||
longitude != null) {
|
||||
// 네이버 API는 coordinate 파라미터로 좌표 기반 정렬을 지원
|
||||
// searchRestaurants에서 이미 가까운 순으로 정렬되어 반환됨
|
||||
return results.first;
|
||||
}
|
||||
|
||||
|
||||
// 유사도 계산 (간단한 버전)
|
||||
NaverLocalSearchResult? bestMatch;
|
||||
double bestScore = 0.0;
|
||||
|
||||
|
||||
for (final result in results) {
|
||||
final score = _calculateSimilarity(targetName, result.title);
|
||||
if (score > bestScore) {
|
||||
@@ -215,44 +212,44 @@ class NaverSearchService {
|
||||
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(
|
||||
@@ -263,16 +260,16 @@ class NaverSearchService {
|
||||
String? address,
|
||||
}) {
|
||||
return _findBestMatch(
|
||||
targetName,
|
||||
targetName,
|
||||
results,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@visibleForTesting
|
||||
double calculateSimilarityForTesting(String str1, String str2) {
|
||||
return _calculateSimilarity(str1, str2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,32 @@ import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
|
||||
class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
static const String _boxName = 'recommendations';
|
||||
|
||||
Future<Box<RecommendationRecord>> get _box async =>
|
||||
|
||||
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));
|
||||
records.sort(
|
||||
(a, b) => b.recommendationDate.compareTo(a.recommendationDate),
|
||||
);
|
||||
return records;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId) async {
|
||||
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 {
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(
|
||||
DateTime date,
|
||||
) async {
|
||||
final records = await getAllRecommendationRecords();
|
||||
return records.where((record) {
|
||||
return record.recommendationDate.year == date.year &&
|
||||
@@ -39,8 +45,12 @@ class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
}) 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)));
|
||||
return record.recommendationDate.isAfter(
|
||||
startDate.subtract(const Duration(days: 1)),
|
||||
) &&
|
||||
record.recommendationDate.isBefore(
|
||||
endDate.add(const Duration(days: 1)),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -93,14 +103,19 @@ class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
} catch (_) {
|
||||
yield <RecommendationRecord>[];
|
||||
}
|
||||
yield* box.watch().asyncMap((_) async => await getAllRecommendationRecords());
|
||||
yield* box.watch().asyncMap(
|
||||
(_) async => await getAllRecommendationRecords(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month) async {
|
||||
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,
|
||||
@@ -111,7 +126,7 @@ class RecommendationRepositoryImpl implements RecommendationRepository {
|
||||
final dayKey = record.recommendationDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ 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 =>
|
||||
|
||||
RestaurantRepositoryImpl({NaverSearchService? naverSearchService})
|
||||
: _naverSearchService = naverSearchService ?? NaverSearchService();
|
||||
|
||||
Future<Box<Restaurant>> get _box async =>
|
||||
await Hive.openBox<Restaurant>(_boxName);
|
||||
|
||||
@override
|
||||
@@ -69,7 +68,10 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate) async {
|
||||
Future<void> updateLastVisitDate(
|
||||
String restaurantId,
|
||||
DateTime visitDate,
|
||||
) async {
|
||||
final restaurant = await getRestaurantById(restaurantId);
|
||||
if (restaurant != null) {
|
||||
final updatedRestaurant = Restaurant(
|
||||
@@ -120,7 +122,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
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);
|
||||
@@ -132,39 +134,68 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
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.description?.toLowerCase().contains(lowercaseQuery) ??
|
||||
false) ||
|
||||
restaurant.category.toLowerCase().contains(lowercaseQuery) ||
|
||||
restaurant.roadAddress.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Restaurant>> searchRestaurantsFromNaver({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
return _naverSearchService.searchNearbyRestaurants(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant> addRestaurantFromUrl(String url) async {
|
||||
return _processRestaurantFromUrl(url, persist: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Restaurant> previewRestaurantFromUrl(String url) async {
|
||||
return _processRestaurantFromUrl(url, persist: false);
|
||||
}
|
||||
|
||||
Future<Restaurant> _processRestaurantFromUrl(
|
||||
String url, {
|
||||
required bool persist,
|
||||
}) async {
|
||||
try {
|
||||
// URL 유효성 검증
|
||||
if (!url.contains('naver.com') && !url.contains('naver.me')) {
|
||||
throw Exception('유효하지 않은 네이버 지도 URL입니다.');
|
||||
}
|
||||
|
||||
|
||||
// NaverSearchService로 식당 정보 추출
|
||||
Restaurant restaurant = await _naverSearchService.getRestaurantFromUrl(url);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
final detailedRestaurant = await _naverSearchService
|
||||
.searchRestaurantDetails(
|
||||
name: restaurant.name,
|
||||
address: restaurant.roadAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
);
|
||||
|
||||
if (detailedRestaurant != null) {
|
||||
// 기존 정보와 API 검색 결과 병합
|
||||
restaurant = Restaurant(
|
||||
@@ -172,8 +203,10 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
name: restaurant.name,
|
||||
category: detailedRestaurant.category,
|
||||
subCategory: detailedRestaurant.subCategory,
|
||||
description: detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber: detailedRestaurant.phoneNumber ?? restaurant.phoneNumber,
|
||||
description:
|
||||
detailedRestaurant.description ?? restaurant.description,
|
||||
phoneNumber:
|
||||
detailedRestaurant.phoneNumber ?? restaurant.phoneNumber,
|
||||
roadAddress: detailedRestaurant.roadAddress,
|
||||
jibunAddress: detailedRestaurant.jibunAddress,
|
||||
latitude: detailedRestaurant.latitude,
|
||||
@@ -184,7 +217,8 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
updatedAt: DateTime.now(),
|
||||
naverPlaceId: restaurant.naverPlaceId,
|
||||
naverUrl: restaurant.naverUrl,
|
||||
businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
businessHours:
|
||||
detailedRestaurant.businessHours ?? restaurant.businessHours,
|
||||
lastVisited: restaurant.lastVisited,
|
||||
visitCount: restaurant.visitCount,
|
||||
);
|
||||
@@ -193,50 +227,31 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 체크 개선
|
||||
final restaurants = await getAllRestaurants();
|
||||
|
||||
// 1. 주소 기반 중복 체크
|
||||
if (restaurant.roadAddress.isNotEmpty || restaurant.jibunAddress.isNotEmpty) {
|
||||
final addressDuplicate = 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 (addressDuplicate.id.isNotEmpty) {
|
||||
throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${addressDuplicate.name}');
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
await _ensureRestaurantIsUnique(restaurant);
|
||||
await addRestaurant(restaurant);
|
||||
}
|
||||
|
||||
// 2. 위치 기반 중복 체크 (50m 이내 같은 이름)
|
||||
final locationDuplicate = restaurants.firstWhere(
|
||||
(r) {
|
||||
if (r.name != restaurant.name) return false;
|
||||
|
||||
final distanceInKm = DistanceCalculator.calculateDistance(
|
||||
lat1: r.latitude,
|
||||
lon1: r.longitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceInMeters = distanceInKm * 1000;
|
||||
return distanceInMeters < 50; // 50m 이내
|
||||
},
|
||||
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
throw Exception('네이버 지도 파싱 실패: ${e.message}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureRestaurantIsUnique(Restaurant restaurant) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
|
||||
if (restaurant.roadAddress.isNotEmpty ||
|
||||
restaurant.jibunAddress.isNotEmpty) {
|
||||
final addressDuplicate = restaurants.firstWhere(
|
||||
(r) =>
|
||||
r.name == restaurant.name &&
|
||||
(r.roadAddress == restaurant.roadAddress ||
|
||||
r.jibunAddress == restaurant.jibunAddress),
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
@@ -251,20 +266,44 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (locationDuplicate.id.isNotEmpty) {
|
||||
throw Exception('50m 이내에 동일한 이름의 맛집이 이미 존재합니다: ${locationDuplicate.name}');
|
||||
|
||||
if (addressDuplicate.id.isNotEmpty) {
|
||||
throw Exception('동일한 이름과 주소의 맛집이 이미 존재합니다: ${addressDuplicate.name}');
|
||||
}
|
||||
|
||||
// 새 맛집 추가
|
||||
await addRestaurant(restaurant);
|
||||
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
throw Exception('네이버 지도 파싱 실패: ${e.message}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final locationDuplicate = restaurants.firstWhere(
|
||||
(r) {
|
||||
if (r.name != restaurant.name) return false;
|
||||
|
||||
final distanceInKm = DistanceCalculator.calculateDistance(
|
||||
lat1: r.latitude,
|
||||
lon1: r.longitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceInMeters = distanceInKm * 1000;
|
||||
return distanceInMeters < 50;
|
||||
},
|
||||
orElse: () => Restaurant(
|
||||
id: '',
|
||||
name: '',
|
||||
category: '',
|
||||
subCategory: '',
|
||||
roadAddress: '',
|
||||
jibunAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (locationDuplicate.id.isNotEmpty) {
|
||||
throw Exception(
|
||||
'50m 이내에 동일한 이름의 맛집이 이미 존재합니다: ${locationDuplicate.name}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,12 +311,9 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId) async {
|
||||
final restaurants = await getAllRestaurants();
|
||||
try {
|
||||
return restaurants.firstWhere(
|
||||
(r) => r.naverPlaceId == naverPlaceId,
|
||||
);
|
||||
return restaurants.firstWhere((r) => r.naverPlaceId == naverPlaceId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ 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 _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;
|
||||
@@ -29,24 +30,34 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@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);
|
||||
|
||||
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')}';
|
||||
|
||||
final notificationTime =
|
||||
'${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}';
|
||||
|
||||
return UserSettings(
|
||||
revisitPreventionDays: revisitPreventionDays,
|
||||
notificationEnabled: notificationEnabled,
|
||||
@@ -59,12 +70,15 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@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(
|
||||
_keyNotificationDelayMinutes,
|
||||
settings.notificationDelayMinutes,
|
||||
);
|
||||
|
||||
// 카테고리 가중치 저장
|
||||
await box.put(_keyCategoryWeights, settings.categoryWeights);
|
||||
}
|
||||
@@ -84,7 +98,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<int> getMaxDistanceRainy() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceRainy, defaultValue: _defaultMaxDistanceRainy);
|
||||
return box.get(
|
||||
_keyMaxDistanceRainy,
|
||||
defaultValue: _defaultMaxDistanceRainy,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -96,7 +113,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<int> getMaxDistanceNormal() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyMaxDistanceNormal, defaultValue: _defaultMaxDistanceNormal);
|
||||
return box.get(
|
||||
_keyMaxDistanceNormal,
|
||||
defaultValue: _defaultMaxDistanceNormal,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,7 +128,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<int> getNotificationDelayMinutes() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationDelayMinutes, defaultValue: _defaultNotificationDelayMinutes);
|
||||
return box.get(
|
||||
_keyNotificationDelayMinutes,
|
||||
defaultValue: _defaultNotificationDelayMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -120,7 +143,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Future<bool> isNotificationEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(_keyNotificationEnabled, defaultValue: _defaultNotificationEnabled);
|
||||
return box.get(
|
||||
_keyNotificationEnabled,
|
||||
defaultValue: _defaultNotificationEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -157,12 +183,15 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
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(
|
||||
_keyNotificationDelayMinutes,
|
||||
_defaultNotificationDelayMinutes,
|
||||
);
|
||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||
@@ -171,10 +200,10 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Stream<Map<String, dynamic>> watchSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 초기 값 전송
|
||||
yield await _getCurrentSettings();
|
||||
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await _getCurrentSettings());
|
||||
}
|
||||
@@ -194,11 +223,11 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
@override
|
||||
Stream<UserSettings> watchUserSettings() async* {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 초기 값 전송
|
||||
yield await getUserSettings();
|
||||
|
||||
|
||||
// 변경사항 감시
|
||||
yield* box.watch().asyncMap((_) async => await getUserSettings());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
|
||||
class VisitRepositoryImpl implements VisitRepository {
|
||||
static const String _boxName = 'visit_records';
|
||||
|
||||
Future<Box<VisitRecord>> get _box async =>
|
||||
|
||||
Future<Box<VisitRecord>> get _box async =>
|
||||
await Hive.openBox<VisitRecord>(_boxName);
|
||||
|
||||
@override
|
||||
@@ -17,7 +17,9 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(String restaurantId) async {
|
||||
Future<List<VisitRecord>> getVisitRecordsByRestaurantId(
|
||||
String restaurantId,
|
||||
) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((r) => r.restaurantId == restaurantId).toList();
|
||||
}
|
||||
@@ -39,7 +41,9 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
}) async {
|
||||
final records = await getAllVisitRecords();
|
||||
return records.where((record) {
|
||||
return record.visitDate.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
return record.visitDate.isAfter(
|
||||
startDate.subtract(const Duration(days: 1)),
|
||||
) &&
|
||||
record.visitDate.isBefore(endDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
}
|
||||
@@ -93,7 +97,7 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
Future<DateTime?> getLastVisitDate(String restaurantId) async {
|
||||
final records = await getVisitRecordsByRestaurantId(restaurantId);
|
||||
if (records.isEmpty) return null;
|
||||
|
||||
|
||||
// 이미 visitDate 기준으로 정렬되어 있으므로 첫 번째가 가장 최근
|
||||
return records.first.visitDate;
|
||||
}
|
||||
@@ -102,7 +106,7 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
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,
|
||||
@@ -113,7 +117,7 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
final dayKey = record.visitDate.day.toString();
|
||||
stats[dayKey] = (stats[dayKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -124,4 +128,4 @@ class VisitRepositoryImpl implements VisitRepository {
|
||||
// 여기서는 빈 Map 반환
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,30 +17,22 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: 22,
|
||||
isRainy: false,
|
||||
description: '맑음',
|
||||
),
|
||||
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) {
|
||||
@@ -56,20 +48,25 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print('WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}');
|
||||
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);
|
||||
|
||||
|
||||
final Map<String, dynamic> weatherMap = Map<String, dynamic>.from(
|
||||
cachedData,
|
||||
);
|
||||
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') || !weatherMap.containsKey('nextHour')) {
|
||||
if (!weatherMap.containsKey('current') ||
|
||||
!weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
@@ -82,7 +79,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
@override
|
||||
Future<void> cacheWeatherInfo(WeatherInfo weatherInfo) async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// WeatherInfo를 Map으로 변환하여 저장
|
||||
final weatherMap = _weatherInfoToMap(weatherInfo);
|
||||
await box.put(_keyCachedWeather, weatherMap);
|
||||
@@ -99,7 +96,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
@override
|
||||
Future<bool> isWeatherUpdateNeeded() async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
// 캐시된 날씨 정보가 없으면 업데이트 필요
|
||||
if (!box.containsKey(_keyCachedWeather)) {
|
||||
return true;
|
||||
@@ -111,7 +108,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
|
||||
Future<bool> _isCacheValid() async {
|
||||
final box = await _box;
|
||||
|
||||
|
||||
final lastUpdateTimeStr = box.get(_keyLastUpdateTime);
|
||||
if (lastUpdateTimeStr == null) {
|
||||
return false;
|
||||
@@ -124,10 +121,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
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');
|
||||
@@ -157,22 +154,22 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
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(),
|
||||
@@ -191,4 +188,4 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
lib/data/sample/manual_restaurant_samples.dart
Normal file
208
lib/data/sample/manual_restaurant_samples.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
/// 샘플 맛집과 방문 이력을 함께 제공하는 데이터 모델
|
||||
class ManualSampleData {
|
||||
final Restaurant restaurant;
|
||||
final List<VisitRecord> visits;
|
||||
|
||||
const ManualSampleData({required this.restaurant, required this.visits});
|
||||
}
|
||||
|
||||
/// 수동 입력을 위한 기본 맛집 샘플 세트
|
||||
class ManualRestaurantSamples {
|
||||
static List<ManualSampleData> build() {
|
||||
return [
|
||||
_buildSample(
|
||||
id: 'sample-euljiro-jinmi',
|
||||
name: '을지로 진미식당',
|
||||
category: '한식',
|
||||
subCategory: '백반/한정식',
|
||||
description:
|
||||
'50년 전통의 정갈한 백반집으로 제철 반찬과 명란구이가 유명합니다. 점심 회전율이 빨라 예약 없이 방문 가능.',
|
||||
phoneNumber: '02-777-1234',
|
||||
roadAddress: '서울 중구 을지로12길 34',
|
||||
jibunAddress: '서울 중구 수표동 67-1',
|
||||
latitude: 37.56698,
|
||||
longitude: 127.00531,
|
||||
visitDaysAgo: [3, 14, 27],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-seongsu-butter',
|
||||
name: '성수연방 버터',
|
||||
category: '카페',
|
||||
subCategory: '디저트 카페',
|
||||
description: '버터 향이 진한 크루아상과 라즈베리 타르트를 파는 성수연방 내 디저트 카페. 아침 9시에 오픈.',
|
||||
phoneNumber: '02-6204-1231',
|
||||
roadAddress: '서울 성동구 성수이로14길 14',
|
||||
jibunAddress: '서울 성동구 성수동2가 320-10',
|
||||
latitude: 37.54465,
|
||||
longitude: 127.05692,
|
||||
visitDaysAgo: [1, 2, 5, 12],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-mangwon-ramen',
|
||||
name: '망원 라라멘',
|
||||
category: '일식',
|
||||
subCategory: '라멘',
|
||||
description: '돼지뼈 육수에 유자 오일을 더한 하카타 스타일 라멘. 저녁에는 한정 교자도 제공.',
|
||||
phoneNumber: '02-333-9086',
|
||||
roadAddress: '서울 마포구 포은로 78-1',
|
||||
jibunAddress: '서울 마포구 망원동 389-50',
|
||||
latitude: 37.55721,
|
||||
longitude: 126.90763,
|
||||
visitDaysAgo: [9],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-haebangchon-salsa',
|
||||
name: '해방촌 살사포차',
|
||||
category: '세계요리',
|
||||
subCategory: '멕시칸/타코',
|
||||
description: '직접 구운 토르티야 위에 매콤한 살사를 얹어주는 캐주얼 타코펍. 주말에는 살사 댄스 클래스 운영.',
|
||||
phoneNumber: '02-792-7764',
|
||||
roadAddress: '서울 용산구 신흥로 68',
|
||||
jibunAddress: '서울 용산구 용산동2가 22-16',
|
||||
latitude: 37.54241,
|
||||
longitude: 126.9862,
|
||||
visitDaysAgo: [30, 45],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-yeonnam-poke',
|
||||
name: '연남 그로서리 포케',
|
||||
category: '세계요리',
|
||||
subCategory: '포케/샐러드',
|
||||
description: '직접 고른 토핑으로 만드는 하와이안 포케 볼 전문점. 비건 토핑과 현미밥 선택 가능.',
|
||||
phoneNumber: '02-336-0214',
|
||||
roadAddress: '서울 마포구 동교로38길 33',
|
||||
jibunAddress: '서울 마포구 연남동 229-54',
|
||||
latitude: 37.55955,
|
||||
longitude: 126.92579,
|
||||
visitDaysAgo: [6, 21],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-jeongdong-brewery',
|
||||
name: '정동 브루어리',
|
||||
category: '주점',
|
||||
subCategory: '수제맥주펍',
|
||||
description: '소규모 양조 탱크를 갖춘 다운타운 브루펍. 시즈널 IPA와 훈제 플래터를 함께 즐길 수 있습니다.',
|
||||
phoneNumber: '02-720-8183',
|
||||
roadAddress: '서울 중구 정동길 21-15',
|
||||
jibunAddress: '서울 중구 정동 1-18',
|
||||
latitude: 37.56605,
|
||||
longitude: 126.97013,
|
||||
visitDaysAgo: [10, 60, 120],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-mokdong-lamb',
|
||||
name: '목동 참숯 양꼬치',
|
||||
category: '중식',
|
||||
subCategory: '양꼬치/바비큐',
|
||||
description: '매장에서 직접 손질한 어린양 꼬치를 참숯에 구워내는 곳. 마라볶음과 칭다오 생맥 조합 추천.',
|
||||
phoneNumber: '02-2653-4411',
|
||||
roadAddress: '서울 양천구 목동동로 377',
|
||||
jibunAddress: '서울 양천구 목동 907-2',
|
||||
latitude: 37.52974,
|
||||
longitude: 126.86455,
|
||||
visitDaysAgo: [2],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-busan-minrak-burger',
|
||||
name: '부산 민락 수제버거',
|
||||
category: '패스트푸드',
|
||||
subCategory: '수제버거',
|
||||
description:
|
||||
'광안리 바다가 내려다보이는 루프탑 버거 전문점. 패티를 미디엄으로 구워 치즈와 구운 파인애플을 올립니다.',
|
||||
phoneNumber: '051-754-2278',
|
||||
roadAddress: '부산 수영구 광안해변로 141',
|
||||
jibunAddress: '부산 수영구 민락동 181-5',
|
||||
latitude: 35.15302,
|
||||
longitude: 129.1183,
|
||||
visitDaysAgo: [15, 32],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-jeju-dongmun-pasta',
|
||||
name: '제주 동문 파스타바',
|
||||
category: '양식',
|
||||
subCategory: '파스타/와인바',
|
||||
description: '동문시장 골목의 오픈키친 파스타바. 한치 크림 파스타와 제주산 와인을 코스로 제공.',
|
||||
phoneNumber: '064-723-9012',
|
||||
roadAddress: '제주 제주시 관덕로14길 18',
|
||||
jibunAddress: '제주 제주시 일도일동 1113-4',
|
||||
latitude: 33.51227,
|
||||
longitude: 126.52686,
|
||||
visitDaysAgo: [4, 11, 19],
|
||||
),
|
||||
_buildSample(
|
||||
id: 'sample-daegu-market-sand',
|
||||
name: '대구 중앙시장 샌드',
|
||||
category: '카페',
|
||||
subCategory: '샌드위치/브런치',
|
||||
description:
|
||||
'직접 구운 식빵과 사과 절임으로 만드는 시그니처 에그샐러드 샌드. 평일 오전 8시부터 테이크아웃 가능.',
|
||||
phoneNumber: '053-256-8874',
|
||||
roadAddress: '대구 중구 중앙대로 363-1',
|
||||
jibunAddress: '대구 중구 남일동 135-1',
|
||||
latitude: 35.87053,
|
||||
longitude: 128.59404,
|
||||
visitDaysAgo: [7, 44, 90],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static ManualSampleData _buildSample({
|
||||
required String id,
|
||||
required String name,
|
||||
required String category,
|
||||
required String subCategory,
|
||||
required String description,
|
||||
required String phoneNumber,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required List<int> visitDaysAgo,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final visitDates =
|
||||
visitDaysAgo.map((days) => now.subtract(Duration(days: days))).toList()
|
||||
..sort((a, b) => b.compareTo(a)); // 최신순
|
||||
|
||||
final restaurant = Restaurant(
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
subCategory: subCategory,
|
||||
description: description,
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: roadAddress,
|
||||
jibunAddress: jibunAddress,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastVisitDate: visitDates.isNotEmpty ? visitDates.first : null,
|
||||
source: DataSource.USER_INPUT,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: null,
|
||||
lastVisited: visitDates.isNotEmpty ? visitDates.first : null,
|
||||
visitCount: visitDates.length,
|
||||
);
|
||||
|
||||
final visits = <VisitRecord>[];
|
||||
for (var i = 0; i < visitDates.length; i++) {
|
||||
final visitDate = visitDates[i];
|
||||
visits.add(
|
||||
VisitRecord(
|
||||
id: '${id}_visit_$i',
|
||||
restaurantId: id,
|
||||
visitDate: visitDate,
|
||||
isConfirmed: true,
|
||||
createdAt: visitDate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ManualSampleData(restaurant: restaurant, visits: visits);
|
||||
}
|
||||
}
|
||||
27
lib/data/sample/sample_data_initializer.dart
Normal file
27
lib/data/sample/sample_data_initializer.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/core/constants/app_constants.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
import 'manual_restaurant_samples.dart';
|
||||
|
||||
/// 초기 구동 시 샘플 데이터를 채워 넣는 도우미
|
||||
class SampleDataInitializer {
|
||||
static Future<void> seedManualRestaurantsIfNeeded() async {
|
||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||
final visitBox = Hive.box<VisitRecord>(AppConstants.visitRecordBox);
|
||||
|
||||
// 이미 사용자 데이터가 있으면 샘플을 추가하지 않음
|
||||
if (restaurantBox.isNotEmpty || visitBox.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final samples = ManualRestaurantSamples.build();
|
||||
for (final sample in samples) {
|
||||
await restaurantBox.put(sample.restaurant.id, sample.restaurant);
|
||||
for (final visit in sample.visits) {
|
||||
await visitBox.put(visit.id, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,19 @@ part 'recommendation_record.g.dart';
|
||||
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,
|
||||
@@ -26,4 +26,4 @@ class RecommendationRecord extends HiveObject {
|
||||
required this.visited,
|
||||
required this.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,61 +6,61 @@ part 'restaurant.g.dart';
|
||||
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,
|
||||
@@ -132,7 +132,7 @@ class Restaurant extends HiveObject {
|
||||
enum DataSource {
|
||||
@HiveField(0)
|
||||
NAVER,
|
||||
|
||||
|
||||
@HiveField(1)
|
||||
USER_INPUT
|
||||
}
|
||||
USER_INPUT,
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ class ShareDevice {
|
||||
final String code;
|
||||
final String deviceId;
|
||||
final DateTime discoveredAt;
|
||||
|
||||
|
||||
ShareDevice({
|
||||
required this.code,
|
||||
required this.deviceId,
|
||||
required this.discoveredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@ part 'user_settings.g.dart';
|
||||
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;
|
||||
|
||||
@@ -35,11 +35,13 @@ class UserSettings {
|
||||
int? notificationDelayMinutes,
|
||||
}) {
|
||||
return UserSettings(
|
||||
revisitPreventionDays: revisitPreventionDays ?? this.revisitPreventionDays,
|
||||
revisitPreventionDays:
|
||||
revisitPreventionDays ?? this.revisitPreventionDays,
|
||||
notificationEnabled: notificationEnabled ?? this.notificationEnabled,
|
||||
notificationTime: notificationTime ?? this.notificationTime,
|
||||
categoryWeights: categoryWeights ?? this.categoryWeights,
|
||||
notificationDelayMinutes: notificationDelayMinutes ?? this.notificationDelayMinutes,
|
||||
notificationDelayMinutes:
|
||||
notificationDelayMinutes ?? this.notificationDelayMinutes,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,19 @@ part 'visit_record.g.dart';
|
||||
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,
|
||||
@@ -26,4 +26,4 @@ class VisitRecord extends HiveObject {
|
||||
required this.isConfirmed,
|
||||
required this.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
class WeatherInfo {
|
||||
final WeatherData current;
|
||||
final WeatherData nextHour;
|
||||
|
||||
WeatherInfo({
|
||||
required this.current,
|
||||
required this.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ abstract class RecommendationRepository {
|
||||
Future<List<RecommendationRecord>> getAllRecommendationRecords();
|
||||
|
||||
/// 특정 맛집의 추천 기록을 가져옵니다
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(String restaurantId);
|
||||
Future<List<RecommendationRecord>> getRecommendationsByRestaurantId(
|
||||
String restaurantId,
|
||||
);
|
||||
|
||||
/// 날짜별 추천 기록을 가져옵니다
|
||||
Future<List<RecommendationRecord>> getRecommendationsByDate(DateTime date);
|
||||
@@ -36,4 +38,4 @@ abstract class RecommendationRepository {
|
||||
|
||||
/// 월별 추천 통계를 가져옵니다
|
||||
Future<Map<String, int>> getMonthlyRecommendationStats(int year, int month);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ abstract class RestaurantRepository {
|
||||
/// 네이버 지도 URL로부터 맛집을 추가합니다
|
||||
Future<Restaurant> addRestaurantFromUrl(String url);
|
||||
|
||||
/// 네이버 지도 URL로부터 식당 정보를 미리보기로 가져옵니다
|
||||
Future<Restaurant> previewRestaurantFromUrl(String url);
|
||||
|
||||
/// 네이버 로컬 검색에서 식당을 검색합니다
|
||||
Future<List<Restaurant>> searchRestaurantsFromNaver({
|
||||
required String query,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
});
|
||||
|
||||
/// 네이버 Place ID로 맛집을 찾습니다
|
||||
Future<Restaurant?> getRestaurantByNaverPlaceId(String naverPlaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +57,4 @@ abstract class SettingsRepository {
|
||||
|
||||
/// UserSettings 변경사항을 스트림으로 감시합니다
|
||||
Stream<UserSettings> watchUserSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ abstract class VisitRepository {
|
||||
|
||||
/// 카테고리별 방문 통계를 가져옵니다
|
||||
Future<Map<String, int>> getCategoryVisitStats();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ abstract class WeatherRepository {
|
||||
|
||||
/// 날씨 정보 업데이트가 필요한지 확인합니다
|
||||
Future<bool> isWeatherUpdateNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@ class RecommendationEngine {
|
||||
if (eligibleRestaurants.isEmpty) return null;
|
||||
|
||||
// 3단계: 카테고리 필터링
|
||||
final filteredByCategory = _filterByCategory(eligibleRestaurants, config.selectedCategories);
|
||||
final filteredByCategory = _filterByCategory(
|
||||
eligibleRestaurants,
|
||||
config.selectedCategories,
|
||||
);
|
||||
if (filteredByCategory.isEmpty) return null;
|
||||
|
||||
// 4단계: 가중치 계산 및 선택
|
||||
@@ -57,7 +60,10 @@ class RecommendationEngine {
|
||||
}
|
||||
|
||||
/// 거리 기반 필터링
|
||||
List<Restaurant> _filterByDistance(List<Restaurant> restaurants, RecommendationConfig config) {
|
||||
List<Restaurant> _filterByDistance(
|
||||
List<Restaurant> restaurants,
|
||||
RecommendationConfig config,
|
||||
) {
|
||||
// 날씨에 따른 최대 거리 조정
|
||||
double effectiveMaxDistance = config.maxDistance;
|
||||
if (config.weather != null && config.weather!.current.isRainy) {
|
||||
@@ -98,7 +104,10 @@ class RecommendationEngine {
|
||||
}
|
||||
|
||||
/// 카테고리 필터링
|
||||
List<Restaurant> _filterByCategory(List<Restaurant> restaurants, List<String> selectedCategories) {
|
||||
List<Restaurant> _filterByCategory(
|
||||
List<Restaurant> restaurants,
|
||||
List<String> selectedCategories,
|
||||
) {
|
||||
if (selectedCategories.isEmpty) {
|
||||
return restaurants;
|
||||
}
|
||||
@@ -108,7 +117,10 @@ class RecommendationEngine {
|
||||
}
|
||||
|
||||
/// 가중치 기반 선택
|
||||
Restaurant? _selectWithWeights(List<Restaurant> restaurants, RecommendationConfig config) {
|
||||
Restaurant? _selectWithWeights(
|
||||
List<Restaurant> restaurants,
|
||||
RecommendationConfig config,
|
||||
) {
|
||||
if (restaurants.isEmpty) return null;
|
||||
|
||||
// 각 식당에 대한 가중치 계산
|
||||
@@ -116,7 +128,8 @@ class RecommendationEngine {
|
||||
double weight = 1.0;
|
||||
|
||||
// 카테고리 가중치 적용
|
||||
final categoryWeight = config.userSettings.categoryWeights[restaurant.category];
|
||||
final categoryWeight =
|
||||
config.userSettings.categoryWeights[restaurant.category];
|
||||
if (categoryWeight != null) {
|
||||
weight *= categoryWeight;
|
||||
}
|
||||
@@ -159,28 +172,23 @@ class RecommendationEngine {
|
||||
return 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// 점심 시간대 (11-14시)
|
||||
else if (hour >= 11 && hour < 14) {
|
||||
if (restaurant.category == 'korean' ||
|
||||
restaurant.category == 'chinese' ||
|
||||
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') {
|
||||
if (restaurant.category == 'bar' || restaurant.category == 'western') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 늦은 저녁 (21시 이후)
|
||||
else if (hour >= 21) {
|
||||
if (restaurant.category == 'bar' ||
|
||||
restaurant.category == 'fastfood') {
|
||||
if (restaurant.category == 'bar' || restaurant.category == 'fastfood') {
|
||||
return 1.3;
|
||||
}
|
||||
if (restaurant.category == 'cafe') {
|
||||
@@ -196,24 +204,21 @@ class RecommendationEngine {
|
||||
if (weather.current.isRainy) {
|
||||
// 비가 올 때는 가까운 식당 선호
|
||||
// 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호
|
||||
if (restaurant.category == 'cafe' ||
|
||||
restaurant.category == 'fastfood') {
|
||||
if (restaurant.category == 'cafe' || restaurant.category == 'fastfood') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 더운 날씨 (25도 이상)
|
||||
if (weather.current.temperature >= 25) {
|
||||
if (restaurant.category == 'cafe' ||
|
||||
restaurant.category == 'japanese') {
|
||||
if (restaurant.category == 'cafe' || restaurant.category == 'japanese') {
|
||||
return 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
// 추운 날씨 (10도 이하)
|
||||
if (weather.current.temperature <= 10) {
|
||||
if (restaurant.category == 'korean' ||
|
||||
restaurant.category == 'chinese') {
|
||||
if (restaurant.category == 'korean' || restaurant.category == 'chinese') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
@@ -222,7 +227,9 @@ class RecommendationEngine {
|
||||
}
|
||||
|
||||
/// 가중치 기반 랜덤 선택
|
||||
Restaurant? _weightedRandomSelection(List<_WeightedRestaurant> weightedRestaurants) {
|
||||
Restaurant? _weightedRandomSelection(
|
||||
List<_WeightedRestaurant> weightedRestaurants,
|
||||
) {
|
||||
if (weightedRestaurants.isEmpty) return null;
|
||||
|
||||
// 전체 가중치 합계 계산
|
||||
@@ -254,4 +261,4 @@ class _WeightedRestaurant {
|
||||
final double weight;
|
||||
|
||||
_WeightedRestaurant(this.restaurant, this.weight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,51 +15,48 @@ 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';
|
||||
import 'data/sample/sample_data_initializer.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');
|
||||
|
||||
await SampleDataInitializer.seedManualRestaurantsIfNeeded();
|
||||
|
||||
// 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),
|
||||
),
|
||||
);
|
||||
|
||||
runApp(ProviderScope(child: LunchPickApp(savedThemeMode: savedThemeMode)));
|
||||
}
|
||||
|
||||
class LunchPickApp extends StatelessWidget {
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
|
||||
|
||||
const LunchPickApp({super.key, this.savedThemeMode});
|
||||
|
||||
@override
|
||||
@@ -141,10 +138,7 @@ class LunchPickApp extends StatelessWidget {
|
||||
final _router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(path: '/', builder: (context, state) => const SplashScreen()),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) {
|
||||
@@ -173,4 +167,4 @@ final _router = GoRouter(
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
|
||||
@@ -15,13 +15,14 @@ class CalendarScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<CalendarScreen> createState() => _CalendarScreenState();
|
||||
}
|
||||
|
||||
class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTickerProviderStateMixin {
|
||||
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();
|
||||
@@ -29,27 +30,31 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
_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,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('방문 기록'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
@@ -73,12 +78,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildCalendarTab(bool isDark) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
||||
|
||||
|
||||
// 방문 기록을 날짜별로 그룹화
|
||||
visitRecordsAsync.whenData((records) {
|
||||
_visitRecordEvents = {};
|
||||
@@ -94,148 +99,147 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
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;
|
||||
});
|
||||
},
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
outsideDaysVisible: false,
|
||||
selectedDecoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
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),
|
||||
],
|
||||
|
||||
// 범례
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
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(
|
||||
@@ -244,18 +248,17 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
Icon(
|
||||
Icons.event_available,
|
||||
size: 48,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'이날의 기록이 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
Text('이날의 기록이 없습니다', style: AppTypography.body2(isDark)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
@@ -265,14 +268,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${day.month}월 ${day.day}일 방문 기록',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -289,7 +294,8 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
child: ListView.builder(
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sortedEvents = events..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
final sortedEvents = events
|
||||
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
return VisitRecordCard(
|
||||
visitRecord: sortedEvents[index],
|
||||
onTap: () {
|
||||
@@ -302,4 +308,4 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> with SingleTick
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,13 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
@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),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.restaurant,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
Icon(Icons.restaurant, size: 48, color: AppColors.lightPrimary),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'다녀왔음? 🍴',
|
||||
@@ -45,9 +39,9 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
restaurantName,
|
||||
style: AppTypography.heading2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
style: AppTypography.heading2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -60,7 +54,9 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? AppColors.darkBackground : AppColors.lightBackground),
|
||||
color: (isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -69,7 +65,9 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -93,7 +91,9 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
child: Text(
|
||||
'안 갔어요',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -103,15 +103,17 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
// 방문 기록 추가
|
||||
await ref.read(visitNotifierProvider.notifier).addVisitRecord(
|
||||
restaurantId: restaurantId,
|
||||
visitDate: DateTime.now(),
|
||||
isConfirmed: true,
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -164,4 +166,4 @@ class VisitConfirmationDialog extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
final VisitRecord visitRecord;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const VisitRecordCard({
|
||||
super.key,
|
||||
required this.visitRecord,
|
||||
this.onTap,
|
||||
});
|
||||
const VisitRecordCard({super.key, required this.visitRecord, this.onTap});
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
@@ -27,7 +23,7 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isConfirmed
|
||||
color: isConfirmed
|
||||
? AppColors.lightPrimary.withValues(alpha: 0.1)
|
||||
: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
@@ -43,7 +39,9 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final restaurantAsync = ref.watch(restaurantProvider(visitRecord.restaurantId));
|
||||
final restaurantAsync = ref.watch(
|
||||
restaurantProvider(visitRecord.restaurantId),
|
||||
);
|
||||
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
@@ -73,9 +71,9 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -85,7 +83,9 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -96,7 +96,9 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -121,15 +123,21 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
color: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightSurface,
|
||||
onSelected: (value) async {
|
||||
if (value == 'confirm' && !visitRecord.isConfirmed) {
|
||||
await ref.read(visitNotifierProvider.notifier).confirmVisit(visitRecord.id);
|
||||
await ref
|
||||
.read(visitNotifierProvider.notifier)
|
||||
.confirmVisit(visitRecord.id);
|
||||
} else if (value == 'delete') {
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -139,11 +147,13 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
content: const Text('이 방문 기록을 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
@@ -152,9 +162,11 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(visitNotifierProvider.notifier).deleteVisitRecord(visitRecord.id);
|
||||
await ref
|
||||
.read(visitNotifierProvider.notifier)
|
||||
.deleteVisitRecord(visitRecord.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -164,7 +176,11 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
value: 'confirm',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check, color: AppColors.lightPrimary, size: 20),
|
||||
const Icon(
|
||||
Icons.check,
|
||||
color: AppColors.lightPrimary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('방문 확인', style: AppTypography.body2(isDark)),
|
||||
],
|
||||
@@ -174,11 +190,18 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline, color: AppColors.lightError, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('삭제', style: AppTypography.body2(isDark).copyWith(
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
color: AppColors.lightError,
|
||||
)),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'삭제',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightError),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -194,12 +217,10 @@ class VisitRecordCard extends ConsumerWidget {
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,24 +8,23 @@ import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
class VisitStatistics extends ConsumerWidget {
|
||||
final DateTime selectedMonth;
|
||||
|
||||
const VisitStatistics({
|
||||
super.key,
|
||||
required this.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 monthlyStatsAsync = ref.watch(
|
||||
monthlyVisitStatsProvider((
|
||||
year: selectedMonth.year,
|
||||
month: selectedMonth.month,
|
||||
)),
|
||||
);
|
||||
|
||||
// 자주 방문한 맛집
|
||||
final frequentRestaurantsAsync = ref.watch(frequentRestaurantsProvider);
|
||||
|
||||
|
||||
// 주간 통계
|
||||
final weeklyStatsAsync = ref.watch(weeklyVisitStatsProvider);
|
||||
|
||||
@@ -36,11 +35,11 @@ class VisitStatistics extends ConsumerWidget {
|
||||
// 이번 달 통계
|
||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 주간 통계 차트
|
||||
_buildWeeklyChart(weeklyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 자주 방문한 맛집 TOP 3
|
||||
_buildFrequentRestaurants(frequentRestaurantsAsync, ref, isDark),
|
||||
],
|
||||
@@ -48,13 +47,14 @@ class VisitStatistics extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthlyStats(AsyncValue<Map<String, int>> statsAsync, bool 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),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -67,12 +67,14 @@ class VisitStatistics extends ConsumerWidget {
|
||||
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));
|
||||
|
||||
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(
|
||||
@@ -87,7 +89,8 @@ class VisitStatistics extends ConsumerWidget {
|
||||
_buildStatItem(
|
||||
icon: Icons.favorite,
|
||||
label: '가장 많이 간 카테고리',
|
||||
value: '${categoryCounts.first.key} (${categoryCounts.first.value}회)',
|
||||
value:
|
||||
'${categoryCounts.first.key} (${categoryCounts.first.value}회)',
|
||||
color: AppColors.lightSecondary,
|
||||
isDark: isDark,
|
||||
),
|
||||
@@ -96,10 +99,8 @@ class VisitStatistics extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text(
|
||||
'통계를 불러올 수 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
error: (error, stack) =>
|
||||
Text('통계를 불러올 수 없습니다', style: AppTypography.body2(isDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -107,35 +108,37 @@ class VisitStatistics extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyChart(AsyncValue<Map<String, int>> statsAsync, bool 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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
final height = maxCount == 0
|
||||
? 0.0
|
||||
: (entry.value / maxCount) * 80;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@@ -153,10 +156,7 @@ class VisitStatistics extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
entry.key,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
Text(entry.key, style: AppTypography.caption(isDark)),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
@@ -164,10 +164,8 @@ class VisitStatistics extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text(
|
||||
'차트를 불러올 수 없습니다',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
error: (error, stack) =>
|
||||
Text('차트를 불러올 수 없습니다', style: AppTypography.body2(isDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -183,18 +181,13 @@ class VisitStatistics extends ConsumerWidget {
|
||||
return Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
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),
|
||||
),
|
||||
Text('자주 방문한 맛집 TOP 3', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 16),
|
||||
frequentAsync.when(
|
||||
data: (frequentList) {
|
||||
@@ -206,78 +199,89 @@ class VisitStatistics extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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>,
|
||||
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),
|
||||
),
|
||||
error: (error, stack) =>
|
||||
Text('데이터를 불러올 수 없습니다', style: AppTypography.body2(isDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -301,26 +305,19 @@ class VisitStatistics extends ConsumerWidget {
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
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(label, style: AppTypography.caption(isDark)),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -328,4 +325,4 @@ class VisitStatistics extends ConsumerWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../settings/settings_screen.dart';
|
||||
|
||||
class MainScreen extends ConsumerStatefulWidget {
|
||||
final int initialTab;
|
||||
|
||||
|
||||
const MainScreen({super.key, this.initialTab = 2});
|
||||
|
||||
@override
|
||||
@@ -21,31 +21,30 @@ class MainScreen extends ConsumerStatefulWidget {
|
||||
|
||||
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,
|
||||
);
|
||||
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: '맛집'),
|
||||
@@ -53,7 +52,7 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||
(icon: Icons.calendar_month, label: '기록'),
|
||||
(icon: Icons.settings, label: '설정'),
|
||||
];
|
||||
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const ShareScreen(),
|
||||
const RestaurantListScreen(),
|
||||
@@ -61,28 +60,31 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||
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,
|
||||
),
|
||||
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(),
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
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 '../../../domain/entities/weather_info.dart';
|
||||
import '../../providers/ad_provider.dart';
|
||||
import '../../providers/location_provider.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../providers/settings_provider.dart'
|
||||
show notificationDelayMinutesProvider, notificationEnabledProvider;
|
||||
import '../../providers/visit_provider.dart';
|
||||
import '../../providers/weather_provider.dart';
|
||||
import 'widgets/recommendation_result_dialog.dart';
|
||||
|
||||
class RandomSelectionScreen extends ConsumerStatefulWidget {
|
||||
const RandomSelectionScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RandomSelectionScreen> createState() => _RandomSelectionScreenState();
|
||||
ConsumerState<RandomSelectionScreen> createState() =>
|
||||
_RandomSelectionScreenState();
|
||||
}
|
||||
|
||||
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
double _distanceValue = 500;
|
||||
final List<String> _selectedCategories = [];
|
||||
|
||||
bool _isProcessingRecommendation = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('오늘 뭐 먹Z?'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
@@ -58,37 +70,36 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final restaurantsAsync = ref.watch(restaurantListProvider);
|
||||
final restaurantsAsync = ref.watch(
|
||||
restaurantListProvider,
|
||||
);
|
||||
return restaurantsAsync.when(
|
||||
data: (restaurants) => Text(
|
||||
'${restaurants.length}개',
|
||||
style: AppTypography.heading1(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
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,
|
||||
),
|
||||
style: AppTypography.heading1(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'등록된 맛집',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
Text('등록된 맛집', style: AppTypography.body2(isDark)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 날씨 정보 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
@@ -109,7 +120,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
Container(
|
||||
width: 1,
|
||||
height: 50,
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
_buildWeatherData('1시간 후', weather.nextHour, isDark),
|
||||
],
|
||||
@@ -122,13 +135,27 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
error: (_, __) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildWeatherInfo('지금', Icons.wb_sunny, '맑음', 20, isDark),
|
||||
_buildWeatherInfo(
|
||||
'지금',
|
||||
Icons.wb_sunny,
|
||||
'맑음',
|
||||
20,
|
||||
isDark,
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 50,
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
_buildWeatherInfo(
|
||||
'1시간 후',
|
||||
Icons.wb_sunny,
|
||||
'맑음',
|
||||
22,
|
||||
isDark,
|
||||
),
|
||||
_buildWeatherInfo('1시간 후', Icons.wb_sunny, '맑음', 22, isDark),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -136,9 +163,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 거리 설정 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
@@ -151,10 +178,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'최대 거리',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
Text('최대 거리', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -162,7 +186,8 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: AppColors.lightPrimary,
|
||||
inactiveTrackColor: AppColors.lightPrimary.withValues(alpha: 0.3),
|
||||
inactiveTrackColor: AppColors.lightPrimary
|
||||
.withValues(alpha: 0.3),
|
||||
thumbColor: AppColors.lightPrimary,
|
||||
trackHeight: 4,
|
||||
),
|
||||
@@ -180,22 +205,27 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${_distanceValue.toInt()}m',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
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 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,
|
||||
@@ -208,7 +238,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Text(
|
||||
'위치 정보를 가져오는 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
@@ -219,9 +249,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 카테고리 선택 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
@@ -234,22 +264,26 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'카테고리',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
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(),
|
||||
: categories
|
||||
.map(
|
||||
(category) => _buildCategoryChip(
|
||||
category,
|
||||
isDark,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
|
||||
@@ -260,12 +294,14 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 추천받기 버튼
|
||||
ElevatedButton(
|
||||
onPressed: _canRecommend() ? _startRecommendation : null,
|
||||
onPressed: !_isProcessingRecommendation && _canRecommend()
|
||||
? () => _startRecommendation()
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
@@ -275,27 +311,36 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
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,
|
||||
child: _isProcessingRecommendation
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: 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: [
|
||||
@@ -309,47 +354,42 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${weatherData.temperature}°C',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
weatherData.description,
|
||||
style: AppTypography.caption(isDark),
|
||||
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) {
|
||||
|
||||
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,
|
||||
),
|
||||
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),
|
||||
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,
|
||||
@@ -362,18 +402,24 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
}
|
||||
});
|
||||
},
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground,
|
||||
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),
|
||||
color: isSelected
|
||||
? AppColors.lightPrimary
|
||||
: (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkDivider : AppColors.lightDivider),
|
||||
color: isSelected
|
||||
? AppColors.lightPrimary
|
||||
: (isDark ? AppColors.darkDivider : AppColors.lightDivider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
int _getRestaurantCountInRange(
|
||||
List<Restaurant> restaurants,
|
||||
Position location,
|
||||
@@ -389,62 +435,163 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
return distance <= maxDistance;
|
||||
}).length;
|
||||
}
|
||||
|
||||
|
||||
bool _canRecommend() {
|
||||
final locationAsync = ref.read(currentLocationProvider);
|
||||
final restaurantsAsync = ref.read(restaurantListProvider);
|
||||
|
||||
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) return false;
|
||||
|
||||
|
||||
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);
|
||||
|
||||
if (location == null || restaurants == null || restaurants.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final count = _getRestaurantCountInRange(
|
||||
restaurants,
|
||||
location,
|
||||
_distanceValue,
|
||||
);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
Future<void> _startRecommendation() async {
|
||||
|
||||
Future<void> _startRecommendation({bool skipAd = false}) async {
|
||||
if (_isProcessingRecommendation) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessingRecommendation = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final candidate = await _generateRecommendationCandidate();
|
||||
if (candidate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipAd) {
|
||||
final adService = ref.read(adServiceProvider);
|
||||
// Ad dialog 자체가 비동기 동작을 포함하므로 사용 후 mounted 체크를 수행한다.
|
||||
// ignore: use_build_context_synchronously
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showSnack(
|
||||
'광고를 끝까지 시청해야 추천을 받을 수 있어요.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_showRecommendationDialog(candidate);
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'추천을 준비하는 중 문제가 발생했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessingRecommendation = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Restaurant?> _generateRecommendationCandidate() 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (result.hasError) {
|
||||
final message = result.error?.toString() ?? '알 수 없는 오류';
|
||||
_showSnack(
|
||||
'추천 중 오류가 발생했습니다: $message',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final restaurant = result.asData?.value;
|
||||
if (restaurant == null) {
|
||||
_showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError);
|
||||
}
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
void _showRecommendationDialog(Restaurant restaurant) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => RecommendationResultDialog(
|
||||
restaurant: restaurant,
|
||||
onReroll: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _startRecommendation(skipAd: true);
|
||||
},
|
||||
onClose: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _handleRecommendationAccepted(restaurant);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRecommendationAccepted(Restaurant restaurant) async {
|
||||
final recommendationTime = DateTime.now();
|
||||
|
||||
try {
|
||||
final notificationEnabled = await ref.read(
|
||||
notificationEnabledProvider.future,
|
||||
);
|
||||
if (notificationEnabled) {
|
||||
final delayMinutes = await ref.read(
|
||||
notificationDelayMinutesProvider.future,
|
||||
);
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('조건에 맞는 맛집이 없습니다'),
|
||||
backgroundColor: AppColors.lightError,
|
||||
),
|
||||
final notificationService = ref.read(notificationServiceProvider);
|
||||
await notificationService.scheduleVisitReminder(
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: recommendationTime,
|
||||
delayMinutes: delayMinutes,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await ref
|
||||
.read(visitNotifierProvider.notifier)
|
||||
.createVisitFromRecommendation(
|
||||
restaurantId: restaurant.id,
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
|
||||
_showSnack('맛있게 드세요! 🍴');
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'방문 기록 또는 알림 예약에 실패했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(
|
||||
String message, {
|
||||
Color backgroundColor = AppColors.lightPrimary,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: backgroundColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
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 {
|
||||
class RecommendationResultDialog extends StatelessWidget {
|
||||
final Restaurant restaurant;
|
||||
final VoidCallback onReroll;
|
||||
final VoidCallback onConfirmVisit;
|
||||
final Future<void> Function() onReroll;
|
||||
final Future<void> Function() onClose;
|
||||
|
||||
const RecommendationResultDialog({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
required this.onReroll,
|
||||
required this.onConfirmVisit,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
@@ -56,9 +52,9 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(false).copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -68,13 +64,15 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 맛집 정보
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -90,24 +88,27 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
|
||||
// 카테고리
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
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,
|
||||
),
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
@@ -116,18 +117,20 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
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,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -138,7 +141,7 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
if (restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -146,7 +149,9 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -156,18 +161,22 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 버튼들
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onReroll,
|
||||
onPressed: () async {
|
||||
await onReroll();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(color: AppColors.lightPrimary),
|
||||
side: const BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -182,29 +191,7 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
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();
|
||||
await onClose();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
@@ -214,7 +201,7 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('여기로 갈게요!'),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -227,4 +214,4 @@ class RecommendationResultDialog extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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 'widgets/add_restaurant_form.dart';
|
||||
|
||||
class ManualRestaurantInputScreen extends ConsumerStatefulWidget {
|
||||
const ManualRestaurantInputScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ManualRestaurantInputScreen> createState() =>
|
||||
_ManualRestaurantInputScreenState();
|
||||
}
|
||||
|
||||
class _ManualRestaurantInputScreenState
|
||||
extends ConsumerState<ManualRestaurantInputScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController();
|
||||
_categoryController = TextEditingController();
|
||||
_subCategoryController = TextEditingController();
|
||||
_descriptionController = TextEditingController();
|
||||
_phoneController = TextEditingController();
|
||||
_roadAddressController = TextEditingController();
|
||||
_jibunAddressController = TextEditingController();
|
||||
_latitudeController = TextEditingController();
|
||||
_longitudeController = TextEditingController();
|
||||
_naverUrlController = TextEditingController();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(addRestaurantViewModelProvider.notifier).reset();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_categoryController.dispose();
|
||||
_subCategoryController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_phoneController.dispose();
|
||||
_roadAddressController.dispose();
|
||||
_jibunAddressController.dispose();
|
||||
_latitudeController.dispose();
|
||||
_longitudeController.dispose();
|
||||
_naverUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFieldChanged(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);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
final success = await viewModel.saveRestaurant();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
Navigator.of(context).pop(true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: const [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('맛집이 추가되었습니다'),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final errorMessage =
|
||||
ref.read(addRestaurantViewModelProvider).errorMessage ??
|
||||
'저장에 실패했습니다.';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final state = ref.watch(addRestaurantViewModelProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('직접 입력'),
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('가게 정보를 직접 입력하세요', style: AppTypography.body1(isDark)),
|
||||
const SizedBox(height: 16),
|
||||
AddRestaurantForm(
|
||||
formKey: _formKey,
|
||||
nameController: _nameController,
|
||||
categoryController: _categoryController,
|
||||
subCategoryController: _subCategoryController,
|
||||
descriptionController: _descriptionController,
|
||||
phoneController: _phoneController,
|
||||
roadAddressController: _roadAddressController,
|
||||
jibunAddressController: _jibunAddressController,
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
onFieldChanged: _onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: state.isLoading ? null : _save,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../widgets/category_selector.dart';
|
||||
import 'manual_restaurant_input_screen.dart';
|
||||
import 'widgets/restaurant_card.dart';
|
||||
import 'widgets/add_restaurant_dialog.dart';
|
||||
|
||||
@@ -11,34 +12,37 @@ class RestaurantListScreen extends ConsumerStatefulWidget {
|
||||
const RestaurantListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RestaurantListScreen> createState() => _RestaurantListScreenState();
|
||||
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
|
||||
searchQuery.isNotEmpty || selectedCategory != null
|
||||
? filteredRestaurantsProvider
|
||||
: restaurantListProvider,
|
||||
);
|
||||
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: _isSearching
|
||||
title: _isSearching
|
||||
? TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
@@ -53,7 +57,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
},
|
||||
)
|
||||
: const Text('내 맛집 리스트'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
@@ -101,7 +107,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
if (restaurants.isEmpty) {
|
||||
return _buildEmptyState(isDark);
|
||||
}
|
||||
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: restaurants.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -110,9 +116,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
child: CircularProgressIndicator(color: AppColors.lightPrimary),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
@@ -121,13 +125,12 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: isDark ? AppColors.darkError : AppColors.lightError,
|
||||
color: isDark
|
||||
? AppColors.darkError
|
||||
: AppColors.lightError,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'오류가 발생했습니다',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
@@ -148,12 +151,12 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildEmptyState(bool isDark) {
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final isFiltering = selectedCategory != null || searchQuery.isNotEmpty;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -161,21 +164,21 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
Icon(
|
||||
isFiltering ? Icons.search_off : Icons.restaurant_menu,
|
||||
size: 80,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isFiltering
|
||||
? '조건에 맞는 맛집이 없어요'
|
||||
: '아직 등록된 맛집이 없어요',
|
||||
isFiltering ? '조건에 맞는 맛집이 없어요' : '아직 등록된 맛집이 없어요',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isFiltering
|
||||
? selectedCategory != null
|
||||
? '선택한 카테고리에 해당하는 맛집이 없습니다'
|
||||
: '검색 결과가 없습니다'
|
||||
? selectedCategory != null
|
||||
? '선택한 카테고리에 해당하는 맛집이 없습니다'
|
||||
: '검색 결과가 없습니다'
|
||||
: '+ 버튼을 눌러 맛집을 추가해보세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
@@ -188,9 +191,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
},
|
||||
child: Text(
|
||||
'필터 초기화',
|
||||
style: TextStyle(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -198,11 +199,110 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showAddOptions() {
|
||||
showDialog(
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => const AddRestaurantDialog(initialTabIndex: 0),
|
||||
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: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.link, color: AppColors.lightPrimary),
|
||||
),
|
||||
title: const Text('네이버 지도 링크로 추가'),
|
||||
subtitle: const Text('네이버 지도앱에서 공유한 링크 붙여넣기'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addByNaverLink();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
title: const Text('상호명으로 검색'),
|
||||
subtitle: const Text('가게 이름으로 검색하여 추가'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addBySearch();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.edit, color: AppColors.lightPrimary),
|
||||
),
|
||||
title: const Text('직접 입력'),
|
||||
subtitle: const Text('가게 정보를 직접 입력하여 추가'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addManually();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addByNaverLink() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const AddRestaurantDialog(mode: AddRestaurantDialogMode.naverLink),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBySearch() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const AddRestaurantDialog(mode: AddRestaurantDialogMode.search),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addManually() async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ManualRestaurantInputScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
import '../../../../domain/entities/restaurant.dart';
|
||||
import '../../../view_models/add_restaurant_view_model.dart';
|
||||
import 'add_restaurant_form.dart';
|
||||
import 'add_restaurant_search_tab.dart';
|
||||
import 'add_restaurant_url_tab.dart';
|
||||
import 'fetched_restaurant_json_view.dart';
|
||||
|
||||
/// 식당 추가 다이얼로그
|
||||
///
|
||||
/// UI 렌더링만 담당하며, 비즈니스 로직은 ViewModel에 위임합니다.
|
||||
enum AddRestaurantDialogMode { naverLink, search }
|
||||
|
||||
/// 네이버 링크/검색 기반 맛집 추가 다이얼로그
|
||||
class AddRestaurantDialog extends ConsumerStatefulWidget {
|
||||
final int initialTabIndex;
|
||||
|
||||
const AddRestaurantDialog({
|
||||
super.key,
|
||||
this.initialTabIndex = 0,
|
||||
});
|
||||
final AddRestaurantDialogMode mode;
|
||||
|
||||
const AddRestaurantDialog({super.key, required this.mode});
|
||||
|
||||
@override
|
||||
ConsumerState<AddRestaurantDialog> createState() => _AddRestaurantDialogState();
|
||||
ConsumerState<AddRestaurantDialog> createState() =>
|
||||
_AddRestaurantDialogState();
|
||||
}
|
||||
|
||||
class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Form 관련
|
||||
class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// TextEditingController들
|
||||
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _categoryController;
|
||||
late final TextEditingController _subCategoryController;
|
||||
@@ -38,22 +35,11 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
late final TextEditingController _latitudeController;
|
||||
late final TextEditingController _longitudeController;
|
||||
late final TextEditingController _naverUrlController;
|
||||
|
||||
// UI 상태
|
||||
late TabController _tabController;
|
||||
|
||||
late final TextEditingController _searchQueryController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TabController 초기화
|
||||
_tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
initialIndex: widget.initialTabIndex,
|
||||
);
|
||||
|
||||
// TextEditingController 초기화
|
||||
_nameController = TextEditingController();
|
||||
_categoryController = TextEditingController();
|
||||
_subCategoryController = TextEditingController();
|
||||
@@ -64,14 +50,15 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
_latitudeController = TextEditingController();
|
||||
_longitudeController = TextEditingController();
|
||||
_naverUrlController = TextEditingController();
|
||||
_searchQueryController = TextEditingController();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(addRestaurantViewModelProvider.notifier).reset();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// TabController 정리
|
||||
_tabController.dispose();
|
||||
|
||||
// TextEditingController 정리
|
||||
_nameController.dispose();
|
||||
_categoryController.dispose();
|
||||
_subCategoryController.dispose();
|
||||
@@ -82,11 +69,10 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
_latitudeController.dispose();
|
||||
_longitudeController.dispose();
|
||||
_naverUrlController.dispose();
|
||||
|
||||
_searchQueryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 폼 데이터가 변경될 때 ViewModel 업데이트
|
||||
void _onFormDataChanged(String _) {
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
final formData = RestaurantFormData.fromControllers(
|
||||
@@ -104,41 +90,30 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 폼 컨트롤러 업데이트
|
||||
Future<void> _performSearch() async {
|
||||
final query = _searchQueryController.text.trim();
|
||||
await ref
|
||||
.read(addRestaurantViewModelProvider.notifier)
|
||||
.searchRestaurants(query);
|
||||
}
|
||||
|
||||
void _selectSearchResult(Restaurant restaurant) {
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
viewModel.selectSearchResult(restaurant);
|
||||
final state = ref.read(addRestaurantViewModelProvider);
|
||||
_updateFormControllers(state.formData);
|
||||
_naverUrlController.text = restaurant.naverUrl ?? _naverUrlController.text;
|
||||
}
|
||||
|
||||
void _updateFormControllers(RestaurantFormData formData) {
|
||||
_nameController.text = formData.name;
|
||||
_categoryController.text = formData.category;
|
||||
@@ -149,23 +124,30 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
_jibunAddressController.text = formData.jibunAddress;
|
||||
_latitudeController.text = formData.latitude;
|
||||
_longitudeController.text = formData.longitude;
|
||||
_naverUrlController.text = formData.naverUrl;
|
||||
}
|
||||
|
||||
/// 식당 저장
|
||||
Future<void> _saveRestaurant() async {
|
||||
final state = ref.read(addRestaurantViewModelProvider);
|
||||
if (state.fetchedRestaurantData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final viewModel = ref.read(addRestaurantViewModelProvider.notifier);
|
||||
final success = await viewModel.saveRestaurant();
|
||||
|
||||
if (success && mounted) {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
children: const [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('맛집이 추가되었습니다'),
|
||||
@@ -174,6 +156,25 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final errorMessage =
|
||||
ref.read(addRestaurantViewModelProvider).errorMessage ??
|
||||
'맛집 저장에 실패했습니다.';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get _title {
|
||||
switch (widget.mode) {
|
||||
case AddRestaurantDialogMode.naverLink:
|
||||
return '네이버 지도 링크로 추가';
|
||||
case AddRestaurantDialogMode.search:
|
||||
return '상호명으로 검색';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,150 +182,96 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog>
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_title,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// 버튼
|
||||
_buildButtons(isDark, state),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (widget.mode == AddRestaurantDialogMode.naverLink)
|
||||
AddRestaurantUrlTab(
|
||||
urlController: _naverUrlController,
|
||||
isLoading: state.isLoading,
|
||||
errorMessage: state.errorMessage,
|
||||
onFetchPressed: _fetchFromNaverUrl,
|
||||
)
|
||||
else
|
||||
AddRestaurantSearchTab(
|
||||
queryController: _searchQueryController,
|
||||
isSearching: state.isSearching,
|
||||
results: state.searchResults,
|
||||
selectedRestaurant: state.fetchedRestaurantData,
|
||||
onResultSelected: _selectSearchResult,
|
||||
onSearch: _performSearch,
|
||||
errorMessage: state.errorMessage,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.fetchedRestaurantData != null) ...[
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: FetchedRestaurantJsonView(
|
||||
isDark: isDark,
|
||||
nameController: _nameController,
|
||||
categoryController: _categoryController,
|
||||
subCategoryController: _subCategoryController,
|
||||
descriptionController: _descriptionController,
|
||||
phoneController: _phoneController,
|
||||
roadAddressController: _roadAddressController,
|
||||
jibunAddressController: _jibunAddressController,
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
naverUrlController: _naverUrlController,
|
||||
onFieldChanged: _onFormDataChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
state.isLoading || state.fetchedRestaurantData == null
|
||||
? null
|
||||
: _saveRestaurant,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 헤더 빌드
|
||||
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 ? '가져오기' : '저장',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 카테고리
|
||||
Row(
|
||||
children: [
|
||||
@@ -73,7 +73,8 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) => RestaurantFormValidator.validateCategory(value),
|
||||
validator: (value) =>
|
||||
RestaurantFormValidator.validateCategory(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -93,7 +94,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 설명
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
@@ -109,7 +110,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
onChanged: onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 전화번호
|
||||
TextFormField(
|
||||
controller: phoneController,
|
||||
@@ -123,10 +124,11 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) => RestaurantFormValidator.validatePhoneNumber(value),
|
||||
validator: (value) =>
|
||||
RestaurantFormValidator.validatePhoneNumber(value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 도로명 주소
|
||||
TextFormField(
|
||||
controller: roadAddressController,
|
||||
@@ -139,10 +141,11 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) => RestaurantFormValidator.validateAddress(value),
|
||||
validator: (value) =>
|
||||
RestaurantFormValidator.validateAddress(value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 지번 주소
|
||||
TextFormField(
|
||||
controller: jibunAddressController,
|
||||
@@ -157,14 +160,16 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
onChanged: onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 위도/경도 입력
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: '위도',
|
||||
hintText: '37.5665',
|
||||
@@ -189,7 +194,9 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: '경도',
|
||||
hintText: '126.9780',
|
||||
@@ -202,7 +209,9 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final longitude = double.tryParse(value);
|
||||
if (longitude == null || longitude < -180 || longitude > 180) {
|
||||
if (longitude == null ||
|
||||
longitude < -180 ||
|
||||
longitude > 180) {
|
||||
return '올바른 경도값을 입력해주세요';
|
||||
}
|
||||
}
|
||||
@@ -215,13 +224,13 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
import '../../../../domain/entities/restaurant.dart';
|
||||
|
||||
class AddRestaurantSearchTab extends StatelessWidget {
|
||||
final TextEditingController queryController;
|
||||
final bool isSearching;
|
||||
final List<Restaurant> results;
|
||||
final Restaurant? selectedRestaurant;
|
||||
final VoidCallback onSearch;
|
||||
final ValueChanged<Restaurant> onResultSelected;
|
||||
final String? errorMessage;
|
||||
|
||||
const AddRestaurantSearchTab({
|
||||
super.key,
|
||||
required this.queryController,
|
||||
required this.isSearching,
|
||||
required this.results,
|
||||
required this.selectedRestaurant,
|
||||
required this.onSearch,
|
||||
required this.onResultSelected,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@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.search,
|
||||
color: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'상호명으로 검색',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'가게 이름(필요 시 주소 키워드 포함)을 입력하면 네이버 로컬 검색 API로 결과를 불러옵니다.',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: queryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '상호명',
|
||||
prefixIcon: const Icon(Icons.storefront),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => onSearch(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: isSearching ? null : onSearch,
|
||||
icon: isSearching
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.search),
|
||||
label: Text(isSearching ? '검색 중...' : '검색'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
if (errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
errorMessage!,
|
||||
style: TextStyle(color: Colors.red[400], fontSize: 13),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (results.isNotEmpty)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: results.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
height: 1,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final restaurant = results[index];
|
||||
final isSelected = selectedRestaurant?.id == restaurant.id;
|
||||
return ListTile(
|
||||
onTap: () => onResultSelected(restaurant),
|
||||
selected: isSelected,
|
||||
selectedTileColor: isDark
|
||||
? AppColors.darkPrimary.withOpacity(0.08)
|
||||
: AppColors.lightPrimary.withOpacity(0.08),
|
||||
title: Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (restaurant.roadAddress.isNotEmpty)
|
||||
Text(
|
||||
restaurant.roadAddress,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColors.lightPrimary,
|
||||
)
|
||||
: const Icon(Icons.chevron_right),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'검색 결과가 여기에 표시됩니다.',
|
||||
style: AppTypography.caption(
|
||||
isDark,
|
||||
).copyWith(color: isDark ? Colors.grey[400] : Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -29,8 +29,8 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.darkPrimary.withOpacity(0.1)
|
||||
color: isDark
|
||||
? AppColors.darkPrimary.withOpacity(0.1)
|
||||
: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -42,14 +42,16 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
color: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'네이버 지도에서 맛집 정보 가져오기',
|
||||
style: AppTypography.body1(isDark).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -63,32 +65,30 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// URL 입력 필드
|
||||
TextField(
|
||||
controller: urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '네이버 지도 URL',
|
||||
hintText: kIsWeb
|
||||
? 'https://map.naver.com/...'
|
||||
hintText: kIsWeb
|
||||
? 'https://map.naver.com/...'
|
||||
: 'https://naver.me/...',
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
errorText: errorMessage,
|
||||
),
|
||||
onSubmitted: (_) => onFetchPressed(),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 가져오기 버튼
|
||||
ElevatedButton.icon(
|
||||
onPressed: isLoading ? null : onFetchPressed,
|
||||
icon: isLoading
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -103,9 +103,9 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 웹 환경 경고
|
||||
if (kIsWeb) ...[
|
||||
Container(
|
||||
@@ -117,15 +117,18 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded,
|
||||
color: Colors.orange, size: 20),
|
||||
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],
|
||||
),
|
||||
style: AppTypography.caption(
|
||||
isDark,
|
||||
).copyWith(color: Colors.orange[700]),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -135,4 +138,4 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
import '../../../services/restaurant_form_validator.dart';
|
||||
|
||||
class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
final bool isDark;
|
||||
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 TextEditingController naverUrlController;
|
||||
final ValueChanged<String> onFieldChanged;
|
||||
|
||||
const FetchedRestaurantJsonView({
|
||||
super.key,
|
||||
required this.isDark,
|
||||
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.naverUrlController,
|
||||
required this.onFieldChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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: [
|
||||
const Icon(Icons.code, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'가져온 정보',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'{',
|
||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'name',
|
||||
controller: nameController,
|
||||
icon: Icons.store,
|
||||
validator: (value) =>
|
||||
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'category',
|
||||
controller: categoryController,
|
||||
icon: Icons.category,
|
||||
validator: RestaurantFormValidator.validateCategory,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'subCategory',
|
||||
controller: subCategoryController,
|
||||
icon: Icons.label_outline,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'description',
|
||||
controller: descriptionController,
|
||||
icon: Icons.description,
|
||||
maxLines: 2,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'phoneNumber',
|
||||
controller: phoneController,
|
||||
icon: Icons.phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'roadAddress',
|
||||
controller: roadAddressController,
|
||||
icon: Icons.location_on,
|
||||
validator: RestaurantFormValidator.validateAddress,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'jibunAddress',
|
||||
controller: jibunAddressController,
|
||||
icon: Icons.map,
|
||||
),
|
||||
_buildCoordinateFields(context),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'naverUrl',
|
||||
controller: naverUrlController,
|
||||
icon: Icons.link,
|
||||
monospace: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'}',
|
||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoordinateFields(BuildContext context) {
|
||||
final border = OutlineInputBorder(borderRadius: BorderRadius.circular(8));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.my_location, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('coordinates'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'latitude',
|
||||
border: border,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '위도를 입력해주세요';
|
||||
}
|
||||
final latitude = double.tryParse(value);
|
||||
if (latitude == null || latitude < -90 || latitude > 90) {
|
||||
return '올바른 위도값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'longitude',
|
||||
border: border,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '경도를 입력해주세요';
|
||||
}
|
||||
final longitude = double.tryParse(value);
|
||||
if (longitude == null ||
|
||||
longitude < -180 ||
|
||||
longitude > 180) {
|
||||
return '올바른 경도값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJsonField(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required IconData icon,
|
||||
int maxLines = 1,
|
||||
TextInputType? keyboardType,
|
||||
bool monospace = false,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text('$label:'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onFieldChanged,
|
||||
validator: validator,
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
|
||||
: null,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,13 @@ import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
class RestaurantCard extends ConsumerWidget {
|
||||
final Restaurant restaurant;
|
||||
|
||||
const RestaurantCard({
|
||||
super.key,
|
||||
required this.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(
|
||||
@@ -46,7 +43,7 @@ class RestaurantCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
|
||||
// 가게 정보
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -64,11 +61,9 @@ class RestaurantCard extends ConsumerWidget {
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory != restaurant.category) ...[
|
||||
Text(
|
||||
' • ',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory !=
|
||||
restaurant.category) ...[
|
||||
Text(' • ', style: AppTypography.body2(isDark)),
|
||||
Text(
|
||||
restaurant.subCategory,
|
||||
style: AppTypography.body2(isDark),
|
||||
@@ -79,18 +74,20 @@ class RestaurantCard extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 더보기 버튼
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
onPressed: () => _showOptions(context, ref, isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -100,16 +97,18 @@ class RestaurantCard extends ConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
// 주소
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
@@ -121,12 +120,14 @@ class RestaurantCard extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
// 마지막 방문일
|
||||
lastVisitAsync.when(
|
||||
data: (lastVisit) {
|
||||
if (lastVisit != null) {
|
||||
final daysSinceVisit = DateTime.now().difference(lastVisit).inDays;
|
||||
final daysSinceVisit = DateTime.now()
|
||||
.difference(lastVisit)
|
||||
.inDays;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
@@ -134,12 +135,14 @@ class RestaurantCard extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
daysSinceVisit == 0
|
||||
? '오늘 방문'
|
||||
daysSinceVisit == 0
|
||||
? '오늘 방문'
|
||||
: '$daysSinceVisit일 전 방문',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
@@ -186,13 +189,19 @@ class RestaurantCard extends ConsumerWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
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),
|
||||
_buildDetailRow(
|
||||
'카테고리',
|
||||
'${restaurant.category} > ${restaurant.subCategory}',
|
||||
isDark,
|
||||
),
|
||||
if (restaurant.description != null)
|
||||
_buildDetailRow('설명', restaurant.description!, isDark),
|
||||
if (restaurant.phoneNumber != null)
|
||||
@@ -223,15 +232,9 @@ class RestaurantCard extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
Text(label, style: AppTypography.caption(isDark)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
Text(value, style: AppTypography.body2(isDark)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -254,7 +257,9 @@ class RestaurantCard extends ConsumerWidget {
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
@@ -283,14 +288,19 @@ class RestaurantCard extends ConsumerWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('삭제', style: TextStyle(color: AppColors.lightError)),
|
||||
child: const Text(
|
||||
'삭제',
|
||||
style: TextStyle(color: AppColors.lightError),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(restaurantNotifierProvider.notifier).deleteRestaurant(restaurant.id);
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -301,4 +311,4 @@ class RestaurantCard extends ConsumerWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,22 @@ 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);
|
||||
|
||||
final notificationMinutes = await ref.read(
|
||||
notificationDelayMinutesProvider.future,
|
||||
);
|
||||
final notificationEnabled = await ref.read(
|
||||
notificationEnabledProvider.future,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_daysToExclude = daysToExclude;
|
||||
@@ -38,297 +42,309 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('설정'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildSection('추천 설정', [
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
],
|
||||
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(
|
||||
child: ListTile(
|
||||
title: const Text('중복 방문 제외 기간'),
|
||||
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.info_outline, color: AppColors.lightPrimary),
|
||||
title: Text('버전'),
|
||||
subtitle: Text('1.0.0'),
|
||||
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,
|
||||
),
|
||||
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',
|
||||
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,
|
||||
),
|
||||
|
||||
),
|
||||
], 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,
|
||||
@@ -347,7 +363,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildPermissionTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
@@ -359,14 +375,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
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
|
||||
trailing: isGranted
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: ElevatedButton(
|
||||
onPressed: onRequest,
|
||||
@@ -383,7 +397,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> _requestLocationPermission() async {
|
||||
final status = await Permission.location.request();
|
||||
if (status.isGranted) {
|
||||
@@ -392,7 +406,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_showPermissionDialog('위치');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _requestBluetoothPermission() async {
|
||||
final status = await Permission.bluetooth.request();
|
||||
if (status.isGranted) {
|
||||
@@ -401,7 +415,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_showPermissionDialog('블루투스');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
@@ -410,14 +424,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_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,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightSurface,
|
||||
title: const Text('권한 설정 필요'),
|
||||
content: Text('$permissionName 권한이 거부되었습니다. 설정에서 직접 권한을 허용해주세요.'),
|
||||
actions: [
|
||||
@@ -439,4 +455,4 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
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 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/core/services/permission_service.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/share_device.dart';
|
||||
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ShareScreen extends ConsumerStatefulWidget {
|
||||
const ShareScreen({super.key});
|
||||
@@ -13,16 +24,39 @@ class ShareScreen extends ConsumerStatefulWidget {
|
||||
class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
String? _shareCode;
|
||||
bool _isScanning = false;
|
||||
|
||||
List<ShareDevice>? _nearbyDevices;
|
||||
StreamSubscription<String>? _dataSubscription;
|
||||
final _uuid = const Uuid();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final bluetoothService = ref.read(bluetoothServiceProvider);
|
||||
_dataSubscription = bluetoothService.onDataReceived.listen((payload) {
|
||||
_handleIncomingData(payload);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dataSubscription?.cancel();
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
appBar: AppBar(
|
||||
title: const Text('리스트 공유'),
|
||||
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
@@ -54,10 +88,7 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'리스트 공유받기',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
Text('리스트 공유받기', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'다른 사람의 맛집 리스트를 받아보세요',
|
||||
@@ -67,7 +98,10 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
const SizedBox(height: 20),
|
||||
if (_shareCode != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -97,6 +131,7 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('취소'),
|
||||
@@ -106,13 +141,18 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: _generateShareCode,
|
||||
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),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -122,9 +162,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 공유하기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
@@ -149,10 +189,7 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'내 리스트 공유하기',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
Text('내 리스트 공유하기', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
@@ -160,20 +197,61 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isScanning) ...[
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
if (_isScanning && _nearbyDevices != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: _nearbyDevices!.isEmpty
|
||||
? Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _nearbyDevices!.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _nearbyDevices![index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.phone_android,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
title: Text(
|
||||
device.code,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'기기 ID: ${device.deviceId}',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.send,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
onTap: () {
|
||||
_sendList(device.code);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.stop),
|
||||
@@ -185,16 +263,17 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
});
|
||||
_scanDevices();
|
||||
},
|
||||
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),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -209,11 +288,218 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _generateShareCode() {
|
||||
// TODO: 실제 구현 시 랜덤 코드 생성
|
||||
|
||||
Future<void> _generateShareCode() async {
|
||||
final adService = ref.read(adServiceProvider);
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final random = Random();
|
||||
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
||||
|
||||
setState(() {
|
||||
_shareCode = '123456';
|
||||
_shareCode = code;
|
||||
});
|
||||
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanDevices() async {
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('블루투스 권한이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
_nearbyDevices = [];
|
||||
});
|
||||
|
||||
try {
|
||||
final devices = await ref
|
||||
.read(bluetoothServiceProvider)
|
||||
.scanNearbyDevices();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_nearbyDevices = devices;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
_showErrorSnackBar('스캔 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendList(String targetCode) async {
|
||||
final restaurants = await ref.read(restaurantListProvider.future);
|
||||
if (!mounted) return;
|
||||
|
||||
_showLoadingDialog('리스트 전송 중...');
|
||||
try {
|
||||
await ref
|
||||
.read(bluetoothServiceProvider)
|
||||
.sendRestaurantList(targetCode, restaurants);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
_showSuccessSnackBar('리스트 전송 완료!');
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
_showErrorSnackBar('전송 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleIncomingData(String payload) async {
|
||||
if (!mounted) return;
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final restaurants = _parseReceivedData(payload);
|
||||
await _mergeRestaurantList(restaurants);
|
||||
} catch (_) {
|
||||
_showErrorSnackBar('전송된 데이터를 처리하는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
List<Restaurant> _parseReceivedData(String data) {
|
||||
final jsonList = jsonDecode(data) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((item) => _restaurantFromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Restaurant _restaurantFromJson(Map<String, dynamic> json) {
|
||||
return Restaurant(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
category: json['category'] as String,
|
||||
subCategory: json['subCategory'] as String,
|
||||
description: json['description'] as String?,
|
||||
phoneNumber: json['phoneNumber'] as String?,
|
||||
roadAddress: json['roadAddress'] as String,
|
||||
jibunAddress: json['jibunAddress'] as String,
|
||||
latitude: (json['latitude'] as num).toDouble(),
|
||||
longitude: (json['longitude'] as num).toDouble(),
|
||||
lastVisitDate: json['lastVisitDate'] != null
|
||||
? DateTime.parse(json['lastVisitDate'] as String)
|
||||
: null,
|
||||
source: DataSource.values.firstWhere(
|
||||
(source) =>
|
||||
source.name ==
|
||||
(json['source'] as String? ?? DataSource.USER_INPUT.name),
|
||||
orElse: () => DataSource.USER_INPUT,
|
||||
),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
naverPlaceId: json['naverPlaceId'] as String?,
|
||||
naverUrl: json['naverUrl'] as String?,
|
||||
businessHours: json['businessHours'] as String?,
|
||||
lastVisited: json['lastVisited'] != null
|
||||
? DateTime.parse(json['lastVisited'] as String)
|
||||
: null,
|
||||
visitCount: (json['visitCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mergeRestaurantList(List<Restaurant> receivedList) async {
|
||||
final currentList = await ref.read(restaurantListProvider.future);
|
||||
final notifier = ref.read(restaurantNotifierProvider.notifier);
|
||||
|
||||
final newRestaurants = <Restaurant>[];
|
||||
for (final restaurant in receivedList) {
|
||||
final exists = currentList.any(
|
||||
(existing) =>
|
||||
existing.name == restaurant.name &&
|
||||
existing.roadAddress == restaurant.roadAddress,
|
||||
);
|
||||
if (!exists) {
|
||||
newRestaurants.add(
|
||||
restaurant.copyWith(
|
||||
id: _uuid.v4(),
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
source: DataSource.USER_INPUT,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final restaurant in newRestaurants) {
|
||||
await notifier.addRestaurantDirect(restaurant);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (newRestaurants.isEmpty) {
|
||||
_showSuccessSnackBar('이미 등록된 맛집과 동일한 항목만 전송되었습니다.');
|
||||
} else {
|
||||
_showSuccessSnackBar('${newRestaurants.length}개의 새로운 맛집이 추가되었습니다!');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
}
|
||||
|
||||
void _showLoadingDialog(String message) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightSurface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: AppColors.lightPrimary),
|
||||
const SizedBox(width: 20),
|
||||
Flexible(
|
||||
child: Text(message, style: AppTypography.body2(isDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSuccessSnackBar(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: AppColors.lightPrimary),
|
||||
);
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: AppColors.lightError),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ class SplashScreen extends StatefulWidget {
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
||||
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,
|
||||
@@ -28,14 +29,14 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
Icons.icecream,
|
||||
Icons.bakery_dining,
|
||||
];
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
_navigateToHome();
|
||||
}
|
||||
|
||||
|
||||
void _initializeAnimations() {
|
||||
// 음식 아이콘 애니메이션 (여러 개)
|
||||
_foodControllers = List.generate(
|
||||
@@ -45,31 +46,33 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
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,
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 랜덤 위치 음식 아이콘들
|
||||
..._buildFoodIcons(),
|
||||
|
||||
|
||||
// 중앙 컨텐츠
|
||||
Center(
|
||||
child: Column(
|
||||
@@ -86,23 +89,25 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
child: Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 80,
|
||||
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
color: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
|
||||
// 앱 타이틀
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'오늘 뭐 먹Z',
|
||||
style: AppTypography.heading1(isDark),
|
||||
),
|
||||
Text('오늘 뭐 먹Z', style: AppTypography.heading1(isDark)),
|
||||
AnimatedBuilder(
|
||||
animation: _questionMarkController,
|
||||
builder: (context, child) {
|
||||
final questionMarks = '?' * (((_questionMarkController.value * 3).floor() % 3) + 1);
|
||||
final questionMarks =
|
||||
'?' *
|
||||
(((_questionMarkController.value * 3).floor() % 3) +
|
||||
1);
|
||||
return Text(
|
||||
questionMarks,
|
||||
style: AppTypography.heading1(isDark),
|
||||
@@ -114,7 +119,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 하단 카피라이트
|
||||
Positioned(
|
||||
bottom: 30,
|
||||
@@ -123,8 +128,11 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
child: Text(
|
||||
AppConstants.appCopyright,
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary)
|
||||
.withOpacity(0.5),
|
||||
color:
|
||||
(isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary)
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -133,14 +141,14 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
@@ -168,7 +176,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _navigateToHome() {
|
||||
Future.delayed(AppConstants.splashAnimationDuration, () {
|
||||
if (mounted) {
|
||||
@@ -176,7 +184,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _foodControllers) {
|
||||
@@ -186,4 +194,4 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||
_centerIconController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
lib/presentation/providers/ad_provider.dart
Normal file
7
lib/presentation/providers/ad_provider.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/services/ad_service.dart';
|
||||
|
||||
/// 광고 서비스 Provider
|
||||
final adServiceProvider = Provider<AdService>((ref) {
|
||||
return AdService();
|
||||
});
|
||||
8
lib/presentation/providers/bluetooth_provider.dart
Normal file
8
lib/presentation/providers/bluetooth_provider.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/services/bluetooth_service.dart';
|
||||
|
||||
final bluetoothServiceProvider = Provider<BluetoothService>((ref) {
|
||||
final service = BluetoothService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
@@ -31,6 +31,8 @@ final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
|
||||
});
|
||||
|
||||
/// RecommendationRepository Provider
|
||||
final recommendationRepositoryProvider = Provider<RecommendationRepository>((ref) {
|
||||
final recommendationRepositoryProvider = Provider<RecommendationRepository>((
|
||||
ref,
|
||||
) {
|
||||
return RecommendationRepositoryImpl();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 위치 권한 상태 Provider
|
||||
final locationPermissionProvider = FutureProvider<PermissionStatus>((ref) async {
|
||||
final locationPermissionProvider = FutureProvider<PermissionStatus>((
|
||||
ref,
|
||||
) async {
|
||||
return await Permission.location.status;
|
||||
});
|
||||
|
||||
@@ -11,7 +13,7 @@ final locationPermissionProvider = FutureProvider<PermissionStatus>((ref) async
|
||||
final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
// 위치 권한 확인
|
||||
final permissionStatus = await Permission.location.status;
|
||||
|
||||
|
||||
if (!permissionStatus.isGranted) {
|
||||
// 권한이 없으면 요청
|
||||
final result = await Permission.location.request();
|
||||
@@ -74,7 +76,7 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
/// 현재 위치 가져오기
|
||||
Future<void> getCurrentLocation() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
// 권한 확인
|
||||
final permissionStatus = await Permission.location.status;
|
||||
@@ -128,6 +130,7 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
}
|
||||
|
||||
/// LocationNotifier Provider
|
||||
final locationNotifierProvider = StateNotifierProvider<LocationNotifier, AsyncValue<Position?>>((ref) {
|
||||
return LocationNotifier();
|
||||
});
|
||||
final locationNotifierProvider =
|
||||
StateNotifierProvider<LocationNotifier, AsyncValue<Position?>>((ref) {
|
||||
return LocationNotifier();
|
||||
});
|
||||
|
||||
@@ -22,7 +22,9 @@ class NotificationPayload {
|
||||
try {
|
||||
final parts = payload.split('|');
|
||||
if (parts.length < 4) {
|
||||
throw FormatException('Invalid payload format - expected 4 parts but got ${parts.length}: $payload');
|
||||
throw FormatException(
|
||||
'Invalid payload format - expected 4 parts but got ${parts.length}: $payload',
|
||||
);
|
||||
}
|
||||
|
||||
// 각 필드 유효성 검증
|
||||
@@ -66,11 +68,14 @@ class NotificationPayload {
|
||||
/// 알림 핸들러 StateNotifier
|
||||
class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
final Ref _ref;
|
||||
|
||||
|
||||
NotificationHandlerNotifier(this._ref) : super(const AsyncValue.data(null));
|
||||
|
||||
/// 알림 클릭 처리
|
||||
Future<void> handleNotificationTap(BuildContext context, String? payload) async {
|
||||
Future<void> handleNotificationTap(
|
||||
BuildContext context,
|
||||
String? payload,
|
||||
) async {
|
||||
if (payload == null || payload.isEmpty) {
|
||||
print('Notification payload is null or empty');
|
||||
return;
|
||||
@@ -83,12 +88,13 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
if (payload.startsWith('visit_reminder:')) {
|
||||
final restaurantName = payload.substring(15);
|
||||
print('Legacy format - Restaurant name: $restaurantName');
|
||||
|
||||
|
||||
// 맛집 이름으로 ID 찾기
|
||||
final restaurantsAsync = await _ref.read(restaurantListProvider.future);
|
||||
final restaurant = restaurantsAsync.firstWhere(
|
||||
(r) => r.name == restaurantName,
|
||||
orElse: () => throw Exception('Restaurant not found: $restaurantName'),
|
||||
orElse: () =>
|
||||
throw Exception('Restaurant not found: $restaurantName'),
|
||||
);
|
||||
|
||||
// 방문 확인 다이얼로그 표시
|
||||
@@ -97,17 +103,21 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
context: context,
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: DateTime.now().subtract(const Duration(hours: 2)),
|
||||
recommendationTime: DateTime.now().subtract(
|
||||
const Duration(hours: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 새로운 형식의 payload 처리
|
||||
print('Attempting to parse new format payload');
|
||||
|
||||
|
||||
try {
|
||||
final notificationPayload = NotificationPayload.fromString(payload);
|
||||
print('Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}');
|
||||
|
||||
print(
|
||||
'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}',
|
||||
);
|
||||
|
||||
if (notificationPayload.type == 'visit_reminder') {
|
||||
// 방문 확인 다이얼로그 표시
|
||||
if (context.mounted) {
|
||||
@@ -127,7 +137,7 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
} catch (parseError) {
|
||||
print('Failed to parse new format, attempting fallback parsing');
|
||||
print('Parse error: $parseError');
|
||||
|
||||
|
||||
// Fallback: 간단한 파싱 시도
|
||||
if (payload.contains('|')) {
|
||||
final parts = payload.split('|');
|
||||
@@ -135,16 +145,14 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
// 최소한 캘린더로 이동
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('알림을 처리했습니다. 방문 기록을 확인해주세요.'),
|
||||
),
|
||||
const SnackBar(content: Text('알림을 처리했습니다. 방문 기록을 확인해주세요.')),
|
||||
);
|
||||
context.go('/home?tab=calendar');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 파싱 실패 시 원래 에러 다시 발생
|
||||
rethrow;
|
||||
}
|
||||
@@ -153,7 +161,7 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
print('Error handling notification: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
|
||||
|
||||
// 에러 발생 시 기본적으로 캘린더 화면으로 이동
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -169,6 +177,7 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
|
||||
/// NotificationHandler Provider
|
||||
final notificationHandlerProvider = StateNotifierProvider<NotificationHandlerNotifier, AsyncValue<void>>((ref) {
|
||||
return NotificationHandlerNotifier(ref);
|
||||
});
|
||||
final notificationHandlerProvider =
|
||||
StateNotifierProvider<NotificationHandlerNotifier, AsyncValue<void>>((ref) {
|
||||
return NotificationHandlerNotifier(ref);
|
||||
});
|
||||
|
||||
@@ -16,4 +16,4 @@ final notificationPermissionProvider = FutureProvider<bool>((ref) async {
|
||||
final pendingNotificationsProvider = FutureProvider((ref) async {
|
||||
final service = ref.watch(notificationServiceProvider);
|
||||
return await service.getPendingNotifications();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,19 @@ import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
import 'package:lunchpick/domain/usecases/recommendation_engine.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/settings_provider.dart' hide currentLocationProvider, locationPermissionProvider;
|
||||
import 'package:lunchpick/presentation/providers/settings_provider.dart'
|
||||
hide currentLocationProvider, locationPermissionProvider;
|
||||
import 'package:lunchpick/presentation/providers/weather_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 추천 기록 목록 Provider
|
||||
final recommendationRecordsProvider = StreamProvider<List<RecommendationRecord>>((ref) {
|
||||
final repository = ref.watch(recommendationRepositoryProvider);
|
||||
return repository.watchRecommendationRecords();
|
||||
});
|
||||
final recommendationRecordsProvider =
|
||||
StreamProvider<List<RecommendationRecord>>((ref) {
|
||||
final repository = ref.watch(recommendationRepositoryProvider);
|
||||
return repository.watchRecommendationRecords();
|
||||
});
|
||||
|
||||
/// 오늘의 추천 횟수 Provider
|
||||
final todayRecommendationCountProvider = FutureProvider<int>((ref) async {
|
||||
@@ -44,7 +46,8 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
final Ref _ref;
|
||||
final RecommendationEngine _recommendationEngine = RecommendationEngine();
|
||||
|
||||
RecommendationNotifier(this._repository, this._ref) : super(const AsyncValue.data(null));
|
||||
RecommendationNotifier(this._repository, this._ref)
|
||||
: super(const AsyncValue.data(null));
|
||||
|
||||
/// 랜덤 추천 실행
|
||||
Future<void> getRandomRecommendation({
|
||||
@@ -52,7 +55,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
required List<String> selectedCategories,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
@@ -62,16 +65,16 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
@@ -81,14 +84,15 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
);
|
||||
|
||||
|
||||
// 추천 엔진 사용
|
||||
final selectedRestaurant = await _recommendationEngine.generateRecommendation(
|
||||
allRestaurants: allRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
final selectedRestaurant = await _recommendationEngine
|
||||
.generateRecommendation(
|
||||
allRestaurants: allRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
if (selectedRestaurant == null) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
@@ -120,11 +124,15 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
Future<void> confirmVisit(String recommendationId) async {
|
||||
try {
|
||||
await _repository.markAsVisited(recommendationId);
|
||||
|
||||
|
||||
// 방문 기록도 생성
|
||||
final recommendations = await _ref.read(recommendationRecordsProvider.future);
|
||||
final recommendation = recommendations.firstWhere((r) => r.id == recommendationId);
|
||||
|
||||
final recommendations = await _ref.read(
|
||||
recommendationRecordsProvider.future,
|
||||
);
|
||||
final recommendation = recommendations.firstWhere(
|
||||
(r) => r.id == recommendationId,
|
||||
);
|
||||
|
||||
final visitNotifier = _ref.read(visitNotifierProvider.notifier);
|
||||
await visitNotifier.createVisitFromRecommendation(
|
||||
restaurantId: recommendation.restaurantId,
|
||||
@@ -146,16 +154,26 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
}
|
||||
|
||||
/// RecommendationNotifier Provider
|
||||
final recommendationNotifierProvider = StateNotifierProvider<RecommendationNotifier, AsyncValue<Restaurant?>>((ref) {
|
||||
final repository = ref.watch(recommendationRepositoryProvider);
|
||||
return RecommendationNotifier(repository, ref);
|
||||
});
|
||||
final recommendationNotifierProvider =
|
||||
StateNotifierProvider<RecommendationNotifier, AsyncValue<Restaurant?>>((
|
||||
ref,
|
||||
) {
|
||||
final repository = ref.watch(recommendationRepositoryProvider);
|
||||
return RecommendationNotifier(repository, ref);
|
||||
});
|
||||
|
||||
/// 월별 추천 통계 Provider
|
||||
final monthlyRecommendationStatsProvider = FutureProvider.family<Map<String, int>, ({int year, int month})>((ref, params) async {
|
||||
final repository = ref.watch(recommendationRepositoryProvider);
|
||||
return repository.getMonthlyRecommendationStats(params.year, params.month);
|
||||
});
|
||||
final monthlyRecommendationStatsProvider =
|
||||
FutureProvider.family<Map<String, int>, ({int year, int month})>((
|
||||
ref,
|
||||
params,
|
||||
) async {
|
||||
final repository = ref.watch(recommendationRepositoryProvider);
|
||||
return repository.getMonthlyRecommendationStats(
|
||||
params.year,
|
||||
params.month,
|
||||
);
|
||||
});
|
||||
|
||||
/// 추천 상태 관리 (다시 추천 기능 포함)
|
||||
class RecommendationState {
|
||||
@@ -163,14 +181,14 @@ class RecommendationState {
|
||||
final List<Restaurant> excludedRestaurants;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
|
||||
const RecommendationState({
|
||||
this.currentRecommendation,
|
||||
this.excludedRestaurants = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
|
||||
RecommendationState copyWith({
|
||||
Restaurant? currentRecommendation,
|
||||
List<Restaurant>? excludedRestaurants,
|
||||
@@ -178,7 +196,8 @@ class RecommendationState {
|
||||
String? error,
|
||||
}) {
|
||||
return RecommendationState(
|
||||
currentRecommendation: currentRecommendation ?? this.currentRecommendation,
|
||||
currentRecommendation:
|
||||
currentRecommendation ?? this.currentRecommendation,
|
||||
excludedRestaurants: excludedRestaurants ?? this.excludedRestaurants,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
@@ -187,28 +206,35 @@ class RecommendationState {
|
||||
}
|
||||
|
||||
/// 향상된 추천 StateNotifier (다시 추천 기능 포함)
|
||||
class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState> {
|
||||
class EnhancedRecommendationNotifier
|
||||
extends StateNotifier<RecommendationState> {
|
||||
final Ref _ref;
|
||||
final RecommendationEngine _recommendationEngine = RecommendationEngine();
|
||||
|
||||
EnhancedRecommendationNotifier(this._ref) : super(const RecommendationState());
|
||||
|
||||
|
||||
EnhancedRecommendationNotifier(this._ref)
|
||||
: super(const RecommendationState());
|
||||
|
||||
/// 다시 추천 (현재 추천 제외)
|
||||
Future<void> rerollRecommendation() async {
|
||||
if (state.currentRecommendation == null) return;
|
||||
|
||||
|
||||
// 현재 추천을 제외 목록에 추가
|
||||
final excluded = [...state.excludedRestaurants, state.currentRecommendation!];
|
||||
final excluded = [
|
||||
...state.excludedRestaurants,
|
||||
state.currentRecommendation!,
|
||||
];
|
||||
state = state.copyWith(excludedRestaurants: excluded);
|
||||
|
||||
|
||||
// 다시 추천 생성 (제외 목록 적용)
|
||||
await generateRecommendation(excludedRestaurants: excluded);
|
||||
}
|
||||
|
||||
|
||||
/// 추천 생성 (새로운 추천 엔진 활용)
|
||||
Future<void> generateRecommendation({List<Restaurant>? excludedRestaurants}) async {
|
||||
Future<void> generateRecommendation({
|
||||
List<Restaurant>? excludedRestaurants,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
|
||||
try {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
@@ -216,21 +242,27 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
|
||||
state = state.copyWith(error: '위치 정보를 가져올 수 없습니다', isLoading: false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 필요한 데이터 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
final maxDistanceNormal = await _ref.read(maxDistanceNormalProvider.future);
|
||||
final maxDistanceNormal = await _ref.read(
|
||||
maxDistanceNormalProvider.future,
|
||||
);
|
||||
final selectedCategory = _ref.read(selectedCategoryProvider);
|
||||
final categories = selectedCategory != null ? [selectedCategory] : <String>[];
|
||||
|
||||
final categories = selectedCategory != null
|
||||
? [selectedCategory]
|
||||
: <String>[];
|
||||
|
||||
// 제외 리스트 포함한 식당 필터링
|
||||
final availableRestaurants = excludedRestaurants != null
|
||||
? allRestaurants.where((r) => !excludedRestaurants.any((ex) => ex.id == r.id)).toList()
|
||||
? allRestaurants
|
||||
.where((r) => !excludedRestaurants.any((ex) => ex.id == r.id))
|
||||
.toList()
|
||||
: allRestaurants;
|
||||
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
@@ -240,14 +272,15 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
);
|
||||
|
||||
|
||||
// 추천 엔진 사용
|
||||
final selectedRestaurant = await _recommendationEngine.generateRecommendation(
|
||||
allRestaurants: availableRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
final selectedRestaurant = await _recommendationEngine
|
||||
.generateRecommendation(
|
||||
allRestaurants: availableRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
if (selectedRestaurant != null) {
|
||||
// 추천 기록 저장
|
||||
final record = RecommendationRecord(
|
||||
@@ -257,28 +290,22 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
|
||||
visited: false,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
|
||||
final repository = _ref.read(recommendationRepositoryProvider);
|
||||
await repository.addRecommendationRecord(record);
|
||||
|
||||
|
||||
state = state.copyWith(
|
||||
currentRecommendation: selectedRestaurant,
|
||||
isLoading: false,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
error: '조건에 맞는 맛집이 없습니다',
|
||||
isLoading: false,
|
||||
);
|
||||
state = state.copyWith(error: '조건에 맞는 맛집이 없습니다', isLoading: false);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
);
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 추천 초기화
|
||||
void resetRecommendation() {
|
||||
state = const RecommendationState();
|
||||
@@ -286,33 +313,39 @@ class EnhancedRecommendationNotifier extends StateNotifier<RecommendationState>
|
||||
}
|
||||
|
||||
/// 향상된 추천 Provider
|
||||
final enhancedRecommendationProvider =
|
||||
StateNotifierProvider<EnhancedRecommendationNotifier, RecommendationState>((ref) {
|
||||
return EnhancedRecommendationNotifier(ref);
|
||||
});
|
||||
final enhancedRecommendationProvider =
|
||||
StateNotifierProvider<EnhancedRecommendationNotifier, RecommendationState>((
|
||||
ref,
|
||||
) {
|
||||
return EnhancedRecommendationNotifier(ref);
|
||||
});
|
||||
|
||||
/// 추천 가능한 맛집 수 Provider
|
||||
final recommendableRestaurantsCountProvider = FutureProvider<int>((ref) async {
|
||||
final daysToExclude = await ref.watch(daysToExcludeProvider.future);
|
||||
final recentlyVisited = await ref.watch(
|
||||
restaurantsNotVisitedInDaysProvider(daysToExclude).future
|
||||
restaurantsNotVisitedInDaysProvider(daysToExclude).future,
|
||||
);
|
||||
|
||||
|
||||
return recentlyVisited.length;
|
||||
});
|
||||
|
||||
/// 카테고리별 추천 통계 Provider
|
||||
final recommendationStatsByCategoryProvider = FutureProvider<Map<String, int>>((ref) async {
|
||||
final recommendationStatsByCategoryProvider = FutureProvider<Map<String, int>>((
|
||||
ref,
|
||||
) async {
|
||||
final records = await ref.watch(recommendationRecordsProvider.future);
|
||||
|
||||
|
||||
final stats = <String, int>{};
|
||||
for (final record in records) {
|
||||
final restaurant = await ref.watch(restaurantProvider(record.restaurantId).future);
|
||||
final restaurant = await ref.watch(
|
||||
restaurantProvider(record.restaurantId).future,
|
||||
);
|
||||
if (restaurant != null) {
|
||||
stats[restaurant.category] = (stats[restaurant.category] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
@@ -320,22 +353,26 @@ final recommendationStatsByCategoryProvider = FutureProvider<Map<String, int>>((
|
||||
final recommendationSuccessRateProvider = FutureProvider<double>((ref) async {
|
||||
final records = await ref.watch(recommendationRecordsProvider.future);
|
||||
if (records.isEmpty) return 0.0;
|
||||
|
||||
|
||||
final visitedCount = records.where((r) => r.visited).length;
|
||||
return (visitedCount / records.length) * 100;
|
||||
});
|
||||
|
||||
/// 가장 많이 추천된 맛집 Top 5 Provider
|
||||
final topRecommendedRestaurantsProvider = FutureProvider<List<({String restaurantId, int count})>>((ref) async {
|
||||
final records = await ref.watch(recommendationRecordsProvider.future);
|
||||
|
||||
final counts = <String, int>{};
|
||||
for (final record in records) {
|
||||
counts[record.restaurantId] = (counts[record.restaurantId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final sorted = counts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return sorted.take(5).map((e) => (restaurantId: e.key, count: e.value)).toList();
|
||||
});
|
||||
final topRecommendedRestaurantsProvider =
|
||||
FutureProvider<List<({String restaurantId, int count})>>((ref) async {
|
||||
final records = await ref.watch(recommendationRecordsProvider.future);
|
||||
|
||||
final counts = <String, int>{};
|
||||
for (final record in records) {
|
||||
counts[record.restaurantId] = (counts[record.restaurantId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final sorted = counts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return sorted
|
||||
.take(5)
|
||||
.map((e) => (restaurantId: e.key, count: e.value))
|
||||
.toList();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
|
||||
});
|
||||
|
||||
/// 특정 맛집 Provider
|
||||
final restaurantProvider = FutureProvider.family<Restaurant?, String>((ref, id) async {
|
||||
final restaurantProvider = FutureProvider.family<Restaurant?, String>((
|
||||
ref,
|
||||
id,
|
||||
) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantById(id);
|
||||
});
|
||||
@@ -43,7 +46,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
required DataSource source,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
final restaurant = Restaurant(
|
||||
id: const Uuid().v4(),
|
||||
@@ -71,7 +74,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
/// 맛집 수정
|
||||
Future<void> updateRestaurant(Restaurant restaurant) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
final updated = Restaurant(
|
||||
id: restaurant.id,
|
||||
@@ -100,7 +103,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
/// 맛집 삭제
|
||||
Future<void> deleteRestaurant(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
await _repository.deleteRestaurant(id);
|
||||
state = const AsyncValue.data(null);
|
||||
@@ -110,7 +113,10 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
|
||||
/// 마지막 방문일 업데이트
|
||||
Future<void> updateLastVisitDate(String restaurantId, DateTime visitDate) async {
|
||||
Future<void> updateLastVisitDate(
|
||||
String restaurantId,
|
||||
DateTime visitDate,
|
||||
) async {
|
||||
try {
|
||||
await _repository.updateLastVisitDate(restaurantId, visitDate);
|
||||
} catch (e, stack) {
|
||||
@@ -121,7 +127,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
/// 네이버 지도 URL로부터 맛집 추가
|
||||
Future<Restaurant> addRestaurantFromUrl(String url) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
final restaurant = await _repository.addRestaurantFromUrl(url);
|
||||
state = const AsyncValue.data(null);
|
||||
@@ -135,7 +141,7 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
/// 미리 생성된 Restaurant 객체를 직접 추가
|
||||
Future<void> addRestaurantDirect(Restaurant restaurant) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
await _repository.addRestaurant(restaurant);
|
||||
state = const AsyncValue.data(null);
|
||||
@@ -147,38 +153,46 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
|
||||
/// RestaurantNotifier Provider
|
||||
final restaurantNotifierProvider = StateNotifierProvider<RestaurantNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return RestaurantNotifier(repository);
|
||||
});
|
||||
final restaurantNotifierProvider =
|
||||
StateNotifierProvider<RestaurantNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return RestaurantNotifier(repository);
|
||||
});
|
||||
|
||||
/// 거리 내 맛집 Provider
|
||||
final restaurantsWithinDistanceProvider = FutureProvider.family<List<Restaurant>, ({double latitude, double longitude, double maxDistance})>((ref, params) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantsWithinDistance(
|
||||
userLatitude: params.latitude,
|
||||
userLongitude: params.longitude,
|
||||
maxDistanceInMeters: params.maxDistance,
|
||||
);
|
||||
});
|
||||
final restaurantsWithinDistanceProvider =
|
||||
FutureProvider.family<
|
||||
List<Restaurant>,
|
||||
({double latitude, double longitude, double maxDistance})
|
||||
>((ref, params) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantsWithinDistance(
|
||||
userLatitude: params.latitude,
|
||||
userLongitude: params.longitude,
|
||||
maxDistanceInMeters: params.maxDistance,
|
||||
);
|
||||
});
|
||||
|
||||
/// n일 이내 방문하지 않은 맛집 Provider
|
||||
final restaurantsNotVisitedInDaysProvider = FutureProvider.family<List<Restaurant>, int>((ref, days) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantsNotVisitedInDays(days);
|
||||
});
|
||||
final restaurantsNotVisitedInDaysProvider =
|
||||
FutureProvider.family<List<Restaurant>, int>((ref, days) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantsNotVisitedInDays(days);
|
||||
});
|
||||
|
||||
/// 검색어로 맛집 검색 Provider
|
||||
final searchRestaurantsProvider = FutureProvider.family<List<Restaurant>, String>((ref, query) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.searchRestaurants(query);
|
||||
});
|
||||
final searchRestaurantsProvider =
|
||||
FutureProvider.family<List<Restaurant>, String>((ref, query) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.searchRestaurants(query);
|
||||
});
|
||||
|
||||
/// 카테고리별 맛집 Provider
|
||||
final restaurantsByCategoryProvider = FutureProvider.family<List<Restaurant>, String>((ref, category) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantsByCategory(category);
|
||||
});
|
||||
final restaurantsByCategoryProvider =
|
||||
FutureProvider.family<List<Restaurant>, String>((ref, category) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getRestaurantsByCategory(category);
|
||||
});
|
||||
|
||||
/// 검색 쿼리 상태 Provider
|
||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
@@ -187,37 +201,45 @@ final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 필터링된 맛집 목록 Provider (검색 + 카테고리)
|
||||
final filteredRestaurantsProvider = StreamProvider<List<Restaurant>>((ref) async* {
|
||||
final filteredRestaurantsProvider = StreamProvider<List<Restaurant>>((
|
||||
ref,
|
||||
) async* {
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
|
||||
|
||||
await for (final restaurants in restaurantsStream) {
|
||||
var filtered = restaurants;
|
||||
|
||||
|
||||
// 검색 필터 적용
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final lowercaseQuery = searchQuery.toLowerCase();
|
||||
filtered = filtered.where((restaurant) {
|
||||
return restaurant.name.toLowerCase().contains(lowercaseQuery) ||
|
||||
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ?? false) ||
|
||||
(restaurant.description?.toLowerCase().contains(lowercaseQuery) ??
|
||||
false) ||
|
||||
restaurant.category.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
// 카테고리 필터 적용
|
||||
if (selectedCategory != null) {
|
||||
filtered = filtered.where((restaurant) {
|
||||
// 정확한 일치 또는 부분 일치 확인
|
||||
// restaurant.category가 "음식점>한식>백반/한정식" 형태일 때
|
||||
// selectedCategory가 "백반/한정식"이면 매칭
|
||||
return restaurant.category == selectedCategory ||
|
||||
restaurant.category.contains(selectedCategory) ||
|
||||
CategoryMapper.normalizeNaverCategory(restaurant.category, restaurant.subCategory) == selectedCategory ||
|
||||
CategoryMapper.getDisplayName(restaurant.category) == selectedCategory;
|
||||
return restaurant.category == selectedCategory ||
|
||||
restaurant.category.contains(selectedCategory) ||
|
||||
CategoryMapper.normalizeNaverCategory(
|
||||
restaurant.category,
|
||||
restaurant.subCategory,
|
||||
) ==
|
||||
selectedCategory ||
|
||||
CategoryMapper.getDisplayName(restaurant.category) ==
|
||||
selectedCategory;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
yield filtered;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,10 +170,11 @@ class SettingsNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
|
||||
/// SettingsNotifier Provider
|
||||
final settingsNotifierProvider = StateNotifierProvider<SettingsNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return SettingsNotifier(repository);
|
||||
});
|
||||
final settingsNotifierProvider =
|
||||
StateNotifierProvider<SettingsNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return SettingsNotifier(repository);
|
||||
});
|
||||
|
||||
/// 설정 프리셋
|
||||
enum SettingsPreset {
|
||||
@@ -210,16 +211,20 @@ enum SettingsPreset {
|
||||
}
|
||||
|
||||
/// 프리셋 적용 Provider
|
||||
final applyPresetProvider = Provider.family<Future<void>, SettingsPreset>((ref, preset) async {
|
||||
final applyPresetProvider = Provider.family<Future<void>, SettingsPreset>((
|
||||
ref,
|
||||
preset,
|
||||
) async {
|
||||
final notifier = ref.read(settingsNotifierProvider.notifier);
|
||||
|
||||
|
||||
await notifier.setDaysToExclude(preset.daysToExclude);
|
||||
await notifier.setMaxDistanceNormal(preset.maxDistanceNormal);
|
||||
await notifier.setMaxDistanceRainy(preset.maxDistanceRainy);
|
||||
});
|
||||
|
||||
/// 현재 위치 Provider
|
||||
final currentLocationProvider = StateProvider<({double latitude, double longitude})?>((ref) => null);
|
||||
final currentLocationProvider =
|
||||
StateProvider<({double latitude, double longitude})?>((ref) => null);
|
||||
|
||||
/// 선호 카테고리 Provider
|
||||
final preferredCategoriesProvider = StateProvider<List<String>>((ref) => []);
|
||||
@@ -241,8 +246,10 @@ final allSettingsProvider = Provider<Map<String, dynamic>>((ref) {
|
||||
final daysToExclude = ref.watch(daysToExcludeProvider).value ?? 7;
|
||||
final maxDistanceRainy = ref.watch(maxDistanceRainyProvider).value ?? 500;
|
||||
final maxDistanceNormal = ref.watch(maxDistanceNormalProvider).value ?? 1000;
|
||||
final notificationDelay = ref.watch(notificationDelayMinutesProvider).value ?? 90;
|
||||
final notificationEnabled = ref.watch(notificationEnabledProvider).value ?? false;
|
||||
final notificationDelay =
|
||||
ref.watch(notificationDelayMinutesProvider).value ?? 90;
|
||||
final notificationEnabled =
|
||||
ref.watch(notificationEnabledProvider).value ?? false;
|
||||
final darkMode = ref.watch(darkModeEnabledProvider).value ?? false;
|
||||
final currentLocation = ref.watch(currentLocationProvider);
|
||||
final preferredCategories = ref.watch(preferredCategoriesProvider);
|
||||
@@ -261,4 +268,4 @@ final allSettingsProvider = Provider<Map<String, dynamic>>((ref) {
|
||||
'excludedCategories': excludedCategories,
|
||||
'language': language,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,29 +12,36 @@ final visitRecordsProvider = StreamProvider<List<VisitRecord>>((ref) {
|
||||
});
|
||||
|
||||
/// 날짜별 방문 기록 Provider
|
||||
final visitRecordsByDateProvider = FutureProvider.family<List<VisitRecord>, DateTime>((ref, date) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getVisitRecordsByDate(date);
|
||||
});
|
||||
final visitRecordsByDateProvider =
|
||||
FutureProvider.family<List<VisitRecord>, DateTime>((ref, date) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getVisitRecordsByDate(date);
|
||||
});
|
||||
|
||||
/// 맛집별 방문 기록 Provider
|
||||
final visitRecordsByRestaurantProvider = FutureProvider.family<List<VisitRecord>, String>((ref, restaurantId) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getVisitRecordsByRestaurantId(restaurantId);
|
||||
});
|
||||
final visitRecordsByRestaurantProvider =
|
||||
FutureProvider.family<List<VisitRecord>, String>((ref, restaurantId) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getVisitRecordsByRestaurantId(restaurantId);
|
||||
});
|
||||
|
||||
/// 월별 방문 통계 Provider
|
||||
final monthlyVisitStatsProvider = FutureProvider.family<Map<String, int>, ({int year, int month})>((ref, params) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getMonthlyVisitStats(params.year, params.month);
|
||||
});
|
||||
final monthlyVisitStatsProvider =
|
||||
FutureProvider.family<Map<String, int>, ({int year, int month})>((
|
||||
ref,
|
||||
params,
|
||||
) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getMonthlyVisitStats(params.year, params.month);
|
||||
});
|
||||
|
||||
/// 방문 기록 관리 StateNotifier
|
||||
class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
final VisitRepository _repository;
|
||||
final Ref _ref;
|
||||
|
||||
VisitNotifier(this._repository, this._ref) : super(const AsyncValue.data(null));
|
||||
VisitNotifier(this._repository, this._ref)
|
||||
: super(const AsyncValue.data(null));
|
||||
|
||||
/// 방문 기록 추가
|
||||
Future<void> addVisitRecord({
|
||||
@@ -43,7 +50,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
bool isConfirmed = false,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
final visitRecord = VisitRecord(
|
||||
id: const Uuid().v4(),
|
||||
@@ -54,11 +61,11 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
);
|
||||
|
||||
await _repository.addVisitRecord(visitRecord);
|
||||
|
||||
|
||||
// 맛집의 마지막 방문일도 업데이트
|
||||
final restaurantNotifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||
await restaurantNotifier.updateLastVisitDate(restaurantId, visitDate);
|
||||
|
||||
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
@@ -68,7 +75,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
/// 방문 확인
|
||||
Future<void> confirmVisit(String visitRecordId) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
await _repository.confirmVisit(visitRecordId);
|
||||
state = const AsyncValue.data(null);
|
||||
@@ -80,7 +87,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
/// 방문 기록 삭제
|
||||
Future<void> deleteVisitRecord(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
await _repository.deleteVisitRecord(id);
|
||||
state = const AsyncValue.data(null);
|
||||
@@ -96,7 +103,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}) async {
|
||||
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
|
||||
final visitTime = recommendationTime.add(const Duration(minutes: 90));
|
||||
|
||||
|
||||
await addVisitRecord(
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitTime,
|
||||
@@ -106,109 +113,138 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
|
||||
/// VisitNotifier Provider
|
||||
final visitNotifierProvider = StateNotifierProvider<VisitNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return VisitNotifier(repository, ref);
|
||||
});
|
||||
final visitNotifierProvider =
|
||||
StateNotifierProvider<VisitNotifier, AsyncValue<void>>((ref) {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return VisitNotifier(repository, ref);
|
||||
});
|
||||
|
||||
/// 특정 맛집의 마지막 방문일 Provider
|
||||
final lastVisitDateProvider = FutureProvider.family<DateTime?, String>((ref, restaurantId) async {
|
||||
final lastVisitDateProvider = FutureProvider.family<DateTime?, String>((
|
||||
ref,
|
||||
restaurantId,
|
||||
) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
return repository.getLastVisitDate(restaurantId);
|
||||
});
|
||||
|
||||
/// 기간별 방문 기록 Provider
|
||||
final visitRecordsByPeriodProvider = FutureProvider.family<List<VisitRecord>, ({DateTime startDate, DateTime endDate})>((ref, params) async {
|
||||
final allRecords = await ref.watch(visitRecordsProvider.future);
|
||||
return allRecords.where((record) {
|
||||
return record.visitDate.isAfter(params.startDate) &&
|
||||
record.visitDate.isBefore(params.endDate.add(const Duration(days: 1)));
|
||||
}).toList()
|
||||
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
});
|
||||
final visitRecordsByPeriodProvider =
|
||||
FutureProvider.family<
|
||||
List<VisitRecord>,
|
||||
({DateTime startDate, DateTime endDate})
|
||||
>((ref, params) async {
|
||||
final allRecords = await ref.watch(visitRecordsProvider.future);
|
||||
return allRecords.where((record) {
|
||||
return record.visitDate.isAfter(params.startDate) &&
|
||||
record.visitDate.isBefore(
|
||||
params.endDate.add(const Duration(days: 1)),
|
||||
);
|
||||
}).toList()..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
});
|
||||
|
||||
/// 주간 방문 통계 Provider (최근 7일)
|
||||
final weeklyVisitStatsProvider = FutureProvider<Map<String, int>>((ref) async {
|
||||
final now = DateTime.now();
|
||||
final startOfWeek = DateTime(now.year, now.month, now.day).subtract(const Duration(days: 6));
|
||||
final records = await ref.watch(visitRecordsByPeriodProvider((
|
||||
startDate: startOfWeek,
|
||||
endDate: now,
|
||||
)).future);
|
||||
|
||||
final startOfWeek = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(const Duration(days: 6));
|
||||
final records = await ref.watch(
|
||||
visitRecordsByPeriodProvider((startDate: startOfWeek, endDate: now)).future,
|
||||
);
|
||||
|
||||
final stats = <String, int>{};
|
||||
for (var i = 0; i < 7; i++) {
|
||||
final date = startOfWeek.add(Duration(days: i));
|
||||
final dateKey = '${date.month}/${date.day}';
|
||||
stats[dateKey] = records.where((r) =>
|
||||
r.visitDate.year == date.year &&
|
||||
r.visitDate.month == date.month &&
|
||||
r.visitDate.day == date.day
|
||||
).length;
|
||||
stats[dateKey] = records
|
||||
.where(
|
||||
(r) =>
|
||||
r.visitDate.year == date.year &&
|
||||
r.visitDate.month == date.month &&
|
||||
r.visitDate.day == date.day,
|
||||
)
|
||||
.length;
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
|
||||
/// 자주 방문하는 맛집 Provider (상위 10개)
|
||||
final frequentRestaurantsProvider = FutureProvider<List<({String restaurantId, int visitCount})>>((ref) async {
|
||||
final allRecords = await ref.watch(visitRecordsProvider.future);
|
||||
|
||||
final visitCounts = <String, int>{};
|
||||
for (final record in allRecords) {
|
||||
visitCounts[record.restaurantId] = (visitCounts[record.restaurantId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final sorted = visitCounts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return sorted.take(10).map((e) => (restaurantId: e.key, visitCount: e.value)).toList();
|
||||
});
|
||||
final frequentRestaurantsProvider =
|
||||
FutureProvider<List<({String restaurantId, int visitCount})>>((ref) async {
|
||||
final allRecords = await ref.watch(visitRecordsProvider.future);
|
||||
|
||||
final visitCounts = <String, int>{};
|
||||
for (final record in allRecords) {
|
||||
visitCounts[record.restaurantId] =
|
||||
(visitCounts[record.restaurantId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final sorted = visitCounts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return sorted
|
||||
.take(10)
|
||||
.map((e) => (restaurantId: e.key, visitCount: e.value))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// 방문 기록 정렬 옵션
|
||||
enum VisitSortOption {
|
||||
dateDesc, // 최신순
|
||||
dateAsc, // 오래된순
|
||||
dateDesc, // 최신순
|
||||
dateAsc, // 오래된순
|
||||
restaurant, // 맛집별
|
||||
}
|
||||
|
||||
/// 정렬된 방문 기록 Provider
|
||||
final sortedVisitRecordsProvider = Provider.family<AsyncValue<List<VisitRecord>>, VisitSortOption>((ref, sortOption) {
|
||||
final recordsAsync = ref.watch(visitRecordsProvider);
|
||||
|
||||
return recordsAsync.when(
|
||||
data: (records) {
|
||||
final sorted = List<VisitRecord>.from(records);
|
||||
switch (sortOption) {
|
||||
case VisitSortOption.dateDesc:
|
||||
sorted.sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
break;
|
||||
case VisitSortOption.dateAsc:
|
||||
sorted.sort((a, b) => a.visitDate.compareTo(b.visitDate));
|
||||
break;
|
||||
case VisitSortOption.restaurant:
|
||||
sorted.sort((a, b) => a.restaurantId.compareTo(b.restaurantId));
|
||||
break;
|
||||
}
|
||||
return AsyncValue.data(sorted);
|
||||
},
|
||||
loading: () => const AsyncValue.loading(),
|
||||
error: (error, stack) => AsyncValue.error(error, stack),
|
||||
);
|
||||
});
|
||||
final sortedVisitRecordsProvider =
|
||||
Provider.family<AsyncValue<List<VisitRecord>>, VisitSortOption>((
|
||||
ref,
|
||||
sortOption,
|
||||
) {
|
||||
final recordsAsync = ref.watch(visitRecordsProvider);
|
||||
|
||||
return recordsAsync.when(
|
||||
data: (records) {
|
||||
final sorted = List<VisitRecord>.from(records);
|
||||
switch (sortOption) {
|
||||
case VisitSortOption.dateDesc:
|
||||
sorted.sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
break;
|
||||
case VisitSortOption.dateAsc:
|
||||
sorted.sort((a, b) => a.visitDate.compareTo(b.visitDate));
|
||||
break;
|
||||
case VisitSortOption.restaurant:
|
||||
sorted.sort((a, b) => a.restaurantId.compareTo(b.restaurantId));
|
||||
break;
|
||||
}
|
||||
return AsyncValue.data(sorted);
|
||||
},
|
||||
loading: () => const AsyncValue.loading(),
|
||||
error: (error, stack) => AsyncValue.error(error, stack),
|
||||
);
|
||||
});
|
||||
|
||||
/// 카테고리별 방문 통계 Provider
|
||||
final categoryVisitStatsProvider = FutureProvider<Map<String, int>>((ref) async {
|
||||
final categoryVisitStatsProvider = FutureProvider<Map<String, int>>((
|
||||
ref,
|
||||
) async {
|
||||
final allRecords = await ref.watch(visitRecordsProvider.future);
|
||||
final restaurantsAsync = await ref.watch(restaurantListProvider.future);
|
||||
|
||||
|
||||
final categoryCount = <String, int>{};
|
||||
|
||||
|
||||
for (final record in allRecords) {
|
||||
final restaurant = restaurantsAsync.where((r) => r.id == record.restaurantId).firstOrNull;
|
||||
final restaurant = restaurantsAsync
|
||||
.where((r) => r.id == record.restaurantId)
|
||||
.firstOrNull;
|
||||
if (restaurant != null) {
|
||||
categoryCount[restaurant.category] = (categoryCount[restaurant.category] ?? 0) + 1;
|
||||
categoryCount[restaurant.category] =
|
||||
(categoryCount[restaurant.category] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return categoryCount;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
final location = await ref.watch(currentLocationProvider.future);
|
||||
|
||||
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
@@ -37,12 +37,13 @@ class WeatherNotifier extends StateNotifier<AsyncValue<WeatherInfo>> {
|
||||
final WeatherRepository _repository;
|
||||
final Ref _ref;
|
||||
|
||||
WeatherNotifier(this._repository, this._ref) : super(const AsyncValue.loading());
|
||||
WeatherNotifier(this._repository, this._ref)
|
||||
: super(const AsyncValue.loading());
|
||||
|
||||
/// 날씨 정보 새로고침
|
||||
Future<void> refreshWeather() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
try {
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
@@ -86,7 +87,8 @@ class WeatherNotifier extends StateNotifier<AsyncValue<WeatherInfo>> {
|
||||
}
|
||||
|
||||
/// WeatherNotifier Provider
|
||||
final weatherNotifierProvider = StateNotifierProvider<WeatherNotifier, AsyncValue<WeatherInfo>>((ref) {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
return WeatherNotifier(repository, ref);
|
||||
});
|
||||
final weatherNotifierProvider =
|
||||
StateNotifierProvider<WeatherNotifier, AsyncValue<WeatherInfo>>((ref) {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
return WeatherNotifier(repository, ref);
|
||||
});
|
||||
|
||||
@@ -67,9 +67,7 @@ class RestaurantFormValidator {
|
||||
}
|
||||
|
||||
// 전화번호 패턴: 02-1234-5678, 010-1234-5678 등
|
||||
final phoneRegex = RegExp(
|
||||
r'^0\d{1,2}-?\d{3,4}-?\d{4}$',
|
||||
);
|
||||
final phoneRegex = RegExp(r'^0\d{1,2}-?\d{3,4}-?\d{4}$');
|
||||
|
||||
if (!phoneRegex.hasMatch(phoneNumber.replaceAll(' ', ''))) {
|
||||
return '올바른 전화번호 형식이 아닙니다';
|
||||
@@ -100,7 +98,7 @@ class RestaurantFormValidator {
|
||||
|
||||
// 허용된 카테고리 목록 (필요시 추가)
|
||||
// final allowedCategories = [
|
||||
// '한식', '중식', '일식', '양식', '아시안',
|
||||
// '한식', '중식', '일식', '양식', '아시안',
|
||||
// '카페', '디저트', '분식', '패스트푸드', '기타'
|
||||
// ];
|
||||
|
||||
@@ -119,8 +117,8 @@ class RestaurantFormValidator {
|
||||
/// 필수 필드만 검증
|
||||
static bool hasRequiredFields(RestaurantFormData formData) {
|
||||
return formData.name.isNotEmpty &&
|
||||
formData.category.isNotEmpty &&
|
||||
formData.roadAddress.isNotEmpty;
|
||||
formData.category.isNotEmpty &&
|
||||
formData.roadAddress.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +140,11 @@ class FormFieldErrors {
|
||||
this.phoneNumber,
|
||||
});
|
||||
|
||||
bool get hasErrors =>
|
||||
name != null ||
|
||||
category != null ||
|
||||
roadAddress != null ||
|
||||
latitude != null ||
|
||||
bool get hasErrors =>
|
||||
name != null ||
|
||||
category != null ||
|
||||
roadAddress != null ||
|
||||
latitude != null ||
|
||||
longitude != null ||
|
||||
phoneNumber != null;
|
||||
|
||||
@@ -160,4 +158,4 @@ class FormFieldErrors {
|
||||
if (phoneNumber != null) map['phoneNumber'] = phoneNumber!;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,46 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import '../providers/di_providers.dart';
|
||||
import '../providers/restaurant_provider.dart';
|
||||
|
||||
/// 식당 추가 화면의 상태 모델
|
||||
class AddRestaurantState {
|
||||
final bool isLoading;
|
||||
final bool isSearching;
|
||||
final String? errorMessage;
|
||||
final Restaurant? fetchedRestaurantData;
|
||||
final RestaurantFormData formData;
|
||||
final List<Restaurant> searchResults;
|
||||
|
||||
const AddRestaurantState({
|
||||
this.isLoading = false,
|
||||
this.isSearching = false,
|
||||
this.errorMessage,
|
||||
this.fetchedRestaurantData,
|
||||
required this.formData,
|
||||
this.searchResults = const [],
|
||||
});
|
||||
|
||||
AddRestaurantState copyWith({
|
||||
bool? isLoading,
|
||||
bool? isSearching,
|
||||
String? errorMessage,
|
||||
Restaurant? fetchedRestaurantData,
|
||||
RestaurantFormData? formData,
|
||||
List<Restaurant>? searchResults,
|
||||
bool clearFetchedRestaurant = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return AddRestaurantState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
fetchedRestaurantData: fetchedRestaurantData ?? this.fetchedRestaurantData,
|
||||
isSearching: isSearching ?? this.isSearching,
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
fetchedRestaurantData: clearFetchedRestaurant
|
||||
? null
|
||||
: (fetchedRestaurantData ?? this.fetchedRestaurantData),
|
||||
formData: formData ?? this.formData,
|
||||
searchResults: searchResults ?? this.searchResults,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +169,12 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
final Ref _ref;
|
||||
|
||||
AddRestaurantViewModel(this._ref)
|
||||
: super(const AddRestaurantState(formData: RestaurantFormData()));
|
||||
: super(const AddRestaurantState(formData: RestaurantFormData()));
|
||||
|
||||
/// 상태 초기화
|
||||
void reset() {
|
||||
state = const AddRestaurantState(formData: RestaurantFormData());
|
||||
}
|
||||
|
||||
/// 네이버 URL로부터 식당 정보 가져오기
|
||||
Future<void> fetchFromNaverUrl(String url) async {
|
||||
@@ -165,11 +183,11 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||
final restaurant = await notifier.addRestaurantFromUrl(url);
|
||||
final repository = _ref.read(restaurantRepositoryProvider);
|
||||
final restaurant = await repository.previewRestaurantFromUrl(url);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
@@ -177,42 +195,83 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
formData: RestaurantFormData.fromRestaurant(restaurant),
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이버 검색으로 식당 목록 검색
|
||||
Future<void> searchRestaurants(
|
||||
String query, {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
state = state.copyWith(
|
||||
errorMessage: '검색어를 입력해주세요.',
|
||||
searchResults: const [],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isSearching: true, clearError: true);
|
||||
|
||||
try {
|
||||
final repository = _ref.read(restaurantRepositoryProvider);
|
||||
final results = await repository.searchRestaurantsFromNaver(
|
||||
query: query,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
state = state.copyWith(isSearching: false, searchResults: results);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isSearching: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 검색 결과 선택
|
||||
void selectSearchResult(Restaurant restaurant) {
|
||||
state = state.copyWith(
|
||||
fetchedRestaurantData: restaurant,
|
||||
formData: RestaurantFormData.fromRestaurant(restaurant),
|
||||
clearError: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 식당 정보 저장
|
||||
Future<bool> saveRestaurant() async {
|
||||
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||
|
||||
try {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
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
|
||||
subCategory: state.formData.subCategory.isEmpty
|
||||
? state.formData.category
|
||||
: state.formData.subCategory,
|
||||
description: state.formData.description.isEmpty
|
||||
? null
|
||||
description: state.formData.description.isEmpty
|
||||
? null
|
||||
: state.formData.description,
|
||||
phoneNumber: state.formData.phoneNumber.isEmpty
|
||||
? null
|
||||
phoneNumber: state.formData.phoneNumber.isEmpty
|
||||
? null
|
||||
: state.formData.phoneNumber,
|
||||
roadAddress: state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress.isEmpty
|
||||
? 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,
|
||||
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 {
|
||||
@@ -221,9 +280,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
}
|
||||
|
||||
await notifier.addRestaurantDirect(restaurantToSave);
|
||||
state = state.copyWith(isLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWith(errorMessage: e.toString());
|
||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -235,12 +295,13 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
|
||||
/// 에러 메시지 초기화
|
||||
void clearError() {
|
||||
state = state.copyWith(errorMessage: null);
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// AddRestaurantViewModel Provider
|
||||
final addRestaurantViewModelProvider =
|
||||
StateNotifierProvider.autoDispose<AddRestaurantViewModel, AddRestaurantState>(
|
||||
(ref) => AddRestaurantViewModel(ref),
|
||||
);
|
||||
StateNotifierProvider.autoDispose<
|
||||
AddRestaurantViewModel,
|
||||
AddRestaurantState
|
||||
>((ref) => AddRestaurantViewModel(ref));
|
||||
|
||||
@@ -26,7 +26,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
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(
|
||||
@@ -39,7 +39,9 @@ class CategorySelector extends ConsumerWidget {
|
||||
context: context,
|
||||
label: '전체',
|
||||
icon: Icons.restaurant_menu,
|
||||
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
color: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
isSelected: selectedCategory == null,
|
||||
onTap: () => onCategorySelected(null),
|
||||
),
|
||||
@@ -49,7 +51,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
final isSelected = multiSelect
|
||||
? selectedCategories?.contains(category) ?? false
|
||||
: selectedCategory == category;
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildCategoryChip(
|
||||
@@ -74,30 +76,26 @@ class CategorySelector extends ConsumerWidget {
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => const SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: Text('카테고리를 불러올 수 없습니다'),
|
||||
),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -110,7 +108,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
@@ -120,11 +118,11 @@ class CategorySelector extends ConsumerWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
color: isSelected
|
||||
? color.withOpacity(0.2)
|
||||
: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightBackground,
|
||||
: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
@@ -137,21 +135,21 @@ class CategorySelector extends ConsumerWidget {
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkText
|
||||
: AppColors.lightText,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkText
|
||||
: AppColors.lightText,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
@@ -180,7 +178,7 @@ class CategorySelectionDialog extends ConsumerWidget {
|
||||
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(
|
||||
@@ -193,7 +191,9 @@ class CategorySelectionDialog extends ConsumerWidget {
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -214,12 +214,14 @@ class CategorySelectionDialog extends ConsumerWidget {
|
||||
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);
|
||||
final updatedCategories = List<String>.from(
|
||||
selectedCategories,
|
||||
);
|
||||
if (isSelected) {
|
||||
updatedCategories.remove(category);
|
||||
} else {
|
||||
@@ -231,12 +233,9 @@ class CategorySelectionDialog extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Text('카테고리를 불러올 수 없습니다: $error'),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) =>
|
||||
Center(child: Text('카테고리를 불러올 수 없습니다: $error')),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -244,7 +243,9 @@ class CategorySelectionDialog extends ConsumerWidget {
|
||||
child: Text(
|
||||
'취소',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -274,7 +275,7 @@ class _CategoryGridItem extends StatelessWidget {
|
||||
final color = CategoryMapper.getColor(category);
|
||||
final icon = CategoryMapper.getIcon(category);
|
||||
final displayName = CategoryMapper.getDisplayName(category);
|
||||
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
@@ -284,11 +285,11 @@ class _CategoryGridItem extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
color: isSelected
|
||||
? color.withOpacity(0.2)
|
||||
: isDark
|
||||
? AppColors.darkCard
|
||||
: AppColors.lightCard,
|
||||
: isDark
|
||||
? AppColors.darkCard
|
||||
: AppColors.lightCard,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
@@ -301,22 +302,22 @@ class _CategoryGridItem extends StatelessWidget {
|
||||
Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
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,
|
||||
color: isSelected
|
||||
? color
|
||||
: isDark
|
||||
? AppColors.darkText
|
||||
: AppColors.lightText,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -329,4 +330,4 @@ class _CategoryGridItem extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user