feat(app): add manual entry and sharing flows

This commit is contained in:
JiWoong Sul
2025-11-19 16:36:39 +09:00
parent 5ade584370
commit 947fe59486
110 changed files with 5937 additions and 3781 deletions

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

View File

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

View File

@@ -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 {
'패스트푸드',
'기타',
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
}
}
''';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,4 +57,4 @@ abstract class SettingsRepository {
/// UserSettings 변경사항을 스트림으로 감시합니다
Stream<UserSettings> watchUserSettings();
}
}

View File

@@ -39,4 +39,4 @@ abstract class VisitRepository {
/// 카테고리별 방문 통계를 가져옵니다
Future<Map<String, int>> getCategoryVisitStats();
}
}

View File

@@ -18,4 +18,4 @@ abstract class WeatherRepository {
/// 날씨 정보 업데이트가 필요한지 확인합니다
Future<bool> isWeatherUpdateNeeded();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('저장'),
),
],
),
],
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -31,6 +31,8 @@ final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
});
/// RecommendationRepository Provider
final recommendationRepositoryProvider = Provider<RecommendationRepository>((ref) {
final recommendationRepositoryProvider = Provider<RecommendationRepository>((
ref,
) {
return RecommendationRepositoryImpl();
});
});

View File

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

View File

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

View File

@@ -16,4 +16,4 @@ final notificationPermissionProvider = FutureProvider<bool>((ref) async {
final pendingNotificationsProvider = FutureProvider((ref) async {
final service = ref.watch(notificationServiceProvider);
return await service.getPendingNotifications();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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