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

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