feat(app): add manual entry and sharing flows
This commit is contained in:
47
lib/core/constants/api_keys.dart
Normal file
47
lib/core/constants/api_keys.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// ApiKeys는 네이버 API 인증 정보를 환경 변수로 로드한다.
|
||||
///
|
||||
/// - `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET`는 `flutter run`/`flutter test`
|
||||
/// 실행 시 `--dart-define`으로 주입한다.
|
||||
/// - 민감 정보는 base64(난독화) 형태로 전달하고, 런타임에서 복호화한다.
|
||||
class ApiKeys {
|
||||
static const String _encodedClientId = String.fromEnvironment(
|
||||
'NAVER_CLIENT_ID',
|
||||
defaultValue: '',
|
||||
);
|
||||
static const String _encodedClientSecret = String.fromEnvironment(
|
||||
'NAVER_CLIENT_SECRET',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static String get naverClientId => _decodeIfNeeded(_encodedClientId);
|
||||
static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret);
|
||||
|
||||
static const String naverLocalSearchEndpoint =
|
||||
'https://openapi.naver.com/v1/search/local.json';
|
||||
|
||||
static bool areKeysConfigured() {
|
||||
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 배포 스크립트에서 사용할 수 있는 편의 메서드.
|
||||
static String obfuscate(String value) {
|
||||
if (value.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
return base64.encode(utf8.encode(value));
|
||||
}
|
||||
|
||||
static String _decodeIfNeeded(String value) {
|
||||
if (value.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return utf8.decode(base64.decode(value));
|
||||
} on FormatException {
|
||||
// base64가 아니면 일반 문자열로 간주 (로컬 개발 편의용)
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class AppColors {
|
||||
static const lightError = Color(0xFFFF5252);
|
||||
static const lightText = Color(0xFF222222); // 추가
|
||||
static const lightCard = Colors.white; // 추가
|
||||
|
||||
// Dark Theme Colors
|
||||
|
||||
// Dark Theme Colors
|
||||
static const darkPrimary = Color(0xFF03C75A);
|
||||
static const darkSecondary = Color(0xFF00BF63);
|
||||
static const darkBackground = Color(0xFF121212);
|
||||
@@ -24,4 +24,4 @@ class AppColors {
|
||||
static const darkError = Color(0xFFFF5252);
|
||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,35 @@ class AppConstants {
|
||||
static const String appName = '오늘 뭐 먹Z?';
|
||||
static const String appDescription = '점심 메뉴 추천 앱';
|
||||
static const String appVersion = '1.0.0';
|
||||
static const String appCopyright = '© 2025. NatureBridgeAI. All rights reserved.';
|
||||
|
||||
static const String appCopyright =
|
||||
'© 2025. NatureBridgeAI. All rights reserved.';
|
||||
|
||||
// Animation Durations
|
||||
static const Duration splashAnimationDuration = Duration(seconds: 3);
|
||||
static const Duration defaultAnimationDuration = Duration(milliseconds: 300);
|
||||
|
||||
|
||||
// API Keys (These should be moved to .env in production)
|
||||
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
||||
static const String weatherApiKey = 'YOUR_WEATHER_API_KEY';
|
||||
|
||||
|
||||
// AdMob IDs (Test IDs - Replace with real IDs in production)
|
||||
static const String androidAdAppId = 'ca-app-pub-3940256099942544~3347511713';
|
||||
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
||||
static const String interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712';
|
||||
|
||||
static const String interstitialAdUnitId =
|
||||
'ca-app-pub-3940256099942544/1033173712';
|
||||
|
||||
// Hive Box Names
|
||||
static const String restaurantBox = 'restaurants';
|
||||
static const String visitRecordBox = 'visit_records';
|
||||
static const String recommendationBox = 'recommendations';
|
||||
static const String settingsBox = 'settings';
|
||||
|
||||
|
||||
// Default Settings
|
||||
static const int defaultDaysToExclude = 7;
|
||||
static const int defaultNotificationMinutes = 90;
|
||||
static const int defaultMaxDistanceNormal = 1000; // meters
|
||||
static const int defaultMaxDistanceRainy = 500; // meters
|
||||
|
||||
|
||||
// Categories
|
||||
static const List<String> foodCategories = [
|
||||
'한식',
|
||||
@@ -41,4 +43,4 @@ class AppConstants {
|
||||
'패스트푸드',
|
||||
'기타',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,28 @@ class AppTypography {
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
|
||||
);
|
||||
|
||||
|
||||
static TextStyle heading2(bool isDark) => TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
|
||||
);
|
||||
|
||||
|
||||
static TextStyle body1(bool isDark) => TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
|
||||
);
|
||||
|
||||
|
||||
static TextStyle body2(bool isDark) => TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
);
|
||||
|
||||
|
||||
static TextStyle caption(bool isDark) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// 애플리케이션 전체 예외 클래스들
|
||||
///
|
||||
///
|
||||
/// 각 레이어별로 명확한 예외 계층 구조를 제공합니다.
|
||||
|
||||
/// 앱 예외 기본 클래스
|
||||
@@ -7,15 +7,12 @@ abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const AppException({
|
||||
required this.message,
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
|
||||
const AppException({required this.message, this.code, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
|
||||
String toString() =>
|
||||
'$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// 비즈니스 로직 예외
|
||||
@@ -24,23 +21,19 @@ class BusinessException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 검증 예외
|
||||
class ValidationException extends AppException {
|
||||
final Map<String, String>? fieldErrors;
|
||||
|
||||
|
||||
const ValidationException({
|
||||
required String message,
|
||||
this.fieldErrors,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
@@ -60,11 +53,7 @@ class DataException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 저장소 예외
|
||||
@@ -73,23 +62,19 @@ class StorageException extends DataException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 권한 예외
|
||||
class PermissionException extends AppException {
|
||||
final String permission;
|
||||
|
||||
|
||||
const PermissionException({
|
||||
required String message,
|
||||
required this.permission,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message (권한: $permission)';
|
||||
}
|
||||
@@ -100,19 +85,13 @@ class LocationException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 설정 예외
|
||||
class ConfigurationException extends AppException {
|
||||
const ConfigurationException({
|
||||
required String message,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
const ConfigurationException({required String message, String? code})
|
||||
: super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// UI 예외
|
||||
@@ -121,47 +100,36 @@ class UIException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 리소스를 찾을 수 없음 예외
|
||||
class NotFoundException extends AppException {
|
||||
final String resourceType;
|
||||
final dynamic resourceId;
|
||||
|
||||
|
||||
const NotFoundException({
|
||||
required this.resourceType,
|
||||
required this.resourceId,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '$resourceType을(를) 찾을 수 없습니다 (ID: $resourceId)',
|
||||
code: 'NOT_FOUND',
|
||||
);
|
||||
message: message ?? '$resourceType을(를) 찾을 수 없습니다 (ID: $resourceId)',
|
||||
code: 'NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
/// 중복 리소스 예외
|
||||
class DuplicateException extends AppException {
|
||||
final String resourceType;
|
||||
|
||||
const DuplicateException({
|
||||
required this.resourceType,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '이미 존재하는 $resourceType입니다',
|
||||
code: 'DUPLICATE',
|
||||
);
|
||||
|
||||
const DuplicateException({required this.resourceType, String? message})
|
||||
: super(message: message ?? '이미 존재하는 $resourceType입니다', code: 'DUPLICATE');
|
||||
}
|
||||
|
||||
/// 추천 엔진 예외
|
||||
class RecommendationException extends BusinessException {
|
||||
const RecommendationException({
|
||||
required String message,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
const RecommendationException({required String message, String? code})
|
||||
: super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// 알림 예외
|
||||
@@ -170,9 +138,5 @@ class NotificationException extends AppException {
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// 데이터 레이어 예외 클래스들
|
||||
///
|
||||
///
|
||||
/// API, 데이터베이스, 파싱 관련 예외를 정의합니다.
|
||||
|
||||
import 'app_exceptions.dart';
|
||||
@@ -7,20 +7,17 @@ import 'app_exceptions.dart';
|
||||
/// API 예외 기본 클래스
|
||||
abstract class ApiException extends DataException {
|
||||
final int? statusCode;
|
||||
|
||||
|
||||
const ApiException({
|
||||
required String message,
|
||||
this.statusCode,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
}) : super(message: message, code: code, originalError: originalError);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
String toString() =>
|
||||
'$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 네이버 API 예외
|
||||
@@ -31,27 +28,27 @@ class NaverApiException extends ApiException {
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// HTML 파싱 예외
|
||||
class HtmlParsingException extends DataException {
|
||||
final String? url;
|
||||
|
||||
|
||||
const HtmlParsingException({
|
||||
required String message,
|
||||
this.url,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: 'HTML_PARSE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
message: message,
|
||||
code: 'HTML_PARSE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
@@ -63,18 +60,18 @@ class HtmlParsingException extends DataException {
|
||||
class DataConversionException extends DataException {
|
||||
final String fromType;
|
||||
final String toType;
|
||||
|
||||
|
||||
const DataConversionException({
|
||||
required String message,
|
||||
required this.fromType,
|
||||
required this.toType,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: 'DATA_CONVERSION_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
message: message,
|
||||
code: 'DATA_CONVERSION_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message ($fromType → $toType)';
|
||||
}
|
||||
@@ -86,10 +83,10 @@ class CacheException extends StorageException {
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'CACHE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
code: code ?? 'CACHE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hive 예외
|
||||
@@ -99,51 +96,47 @@ class HiveException extends StorageException {
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'HIVE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
code: code ?? 'HIVE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// URL 처리 예외
|
||||
class UrlProcessingException extends DataException {
|
||||
final String url;
|
||||
|
||||
|
||||
const UrlProcessingException({
|
||||
required String message,
|
||||
required this.url,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'URL_PROCESSING_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
message: message,
|
||||
code: code ?? 'URL_PROCESSING_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message (URL: $url)';
|
||||
}
|
||||
|
||||
/// 잘못된 URL 형식 예외
|
||||
class InvalidUrlException extends UrlProcessingException {
|
||||
const InvalidUrlException({
|
||||
required String url,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '올바르지 않은 URL 형식입니다',
|
||||
url: url,
|
||||
code: 'INVALID_URL',
|
||||
);
|
||||
const InvalidUrlException({required String url, String? message})
|
||||
: super(
|
||||
message: message ?? '올바르지 않은 URL 형식입니다',
|
||||
url: url,
|
||||
code: 'INVALID_URL',
|
||||
);
|
||||
}
|
||||
|
||||
/// 지원하지 않는 URL 예외
|
||||
class UnsupportedUrlException extends UrlProcessingException {
|
||||
const UnsupportedUrlException({
|
||||
required String url,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '지원하지 않는 URL입니다',
|
||||
url: url,
|
||||
code: 'UNSUPPORTED_URL',
|
||||
);
|
||||
}
|
||||
const UnsupportedUrlException({required String url, String? message})
|
||||
: super(
|
||||
message: message ?? '지원하지 않는 URL입니다',
|
||||
url: url,
|
||||
code: 'UNSUPPORTED_URL',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/// 네트워크 관련 예외 클래스들
|
||||
///
|
||||
///
|
||||
/// 모든 네트워크 오류를 명확하게 분류하고 처리합니다.
|
||||
|
||||
/// 네트워크 예외 기본 클래스
|
||||
@@ -7,15 +7,16 @@ abstract class NetworkException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final dynamic originalError;
|
||||
|
||||
|
||||
const NetworkException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
String toString() =>
|
||||
'$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 연결 타임아웃 예외
|
||||
@@ -41,10 +42,10 @@ class ServerException extends NetworkException {
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 클라이언트 오류 예외 (4xx)
|
||||
@@ -54,25 +55,22 @@ class ClientException extends NetworkException {
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 파싱 오류 예외
|
||||
class ParseException extends NetworkException {
|
||||
const ParseException({
|
||||
required String message,
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
const ParseException({required String message, dynamic originalError})
|
||||
: super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// API 키 오류 예외
|
||||
class ApiKeyException extends NetworkException {
|
||||
const ApiKeyException({
|
||||
String message = 'API 키가 설정되지 않았습니다',
|
||||
}) : super(message: message);
|
||||
const ApiKeyException({String message = 'API 키가 설정되지 않았습니다'})
|
||||
: super(message: message);
|
||||
}
|
||||
|
||||
/// 재시도 횟수 초과 예외
|
||||
@@ -86,17 +84,13 @@ class MaxRetriesExceededException extends NetworkException {
|
||||
/// Rate Limit (429) 예외
|
||||
class RateLimitException extends NetworkException {
|
||||
final String? retryAfter;
|
||||
|
||||
|
||||
const RateLimitException({
|
||||
String message = '너무 많은 요청으로 인해 차단되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
this.retryAfter,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: 429,
|
||||
originalError: originalError,
|
||||
);
|
||||
|
||||
}) : super(message: message, statusCode: 429, originalError: originalError);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
@@ -105,4 +99,4 @@ class RateLimitException extends NetworkException {
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +108,19 @@ try {
|
||||
|
||||
1. [네이버 개발자 센터](https://developers.naver.com)에서 애플리케이션 등록
|
||||
2. Client ID와 Client Secret 발급
|
||||
3. `lib/core/constants/api_keys.dart` 파일에 키 입력:
|
||||
3. 값을 base64로 인코딩한 뒤 `flutter run --dart-define`으로 전달:
|
||||
|
||||
```dart
|
||||
class ApiKeys {
|
||||
static const String naverClientId = 'YOUR_CLIENT_ID';
|
||||
static const String naverClientSecret = 'YOUR_CLIENT_SECRET';
|
||||
}
|
||||
```bash
|
||||
NAVER_CLIENT_ID=$(printf 'YOUR_CLIENT_ID' | base64)
|
||||
NAVER_CLIENT_SECRET=$(printf 'YOUR_CLIENT_SECRET' | base64)
|
||||
|
||||
flutter run \
|
||||
--dart-define=NAVER_CLIENT_ID=$NAVER_CLIENT_ID \
|
||||
--dart-define=NAVER_CLIENT_SECRET=$NAVER_CLIENT_SECRET
|
||||
```
|
||||
|
||||
로컬에서 빠르게 확인할 때는 base64 인코딩을 생략할 수 있습니다.
|
||||
|
||||
### 네트워크 설정 커스터마이징
|
||||
|
||||
`lib/core/network/network_config.dart`에서 타임아웃, 재시도 횟수 등을 조정할 수 있습니다:
|
||||
@@ -169,4 +173,4 @@ lib/
|
||||
네트워크가 느린 환경에서는 `NetworkConfig`의 타임아웃 값을 늘려보세요.
|
||||
|
||||
### API 키 에러
|
||||
API 키가 올바르게 설정되었는지 확인하고, 네이버 개발자 센터에서 API 사용 권한이 활성화되어 있는지 확인하세요.
|
||||
API 키가 올바르게 설정되었는지 확인하고, 네이버 개발자 센터에서 API 사용 권한이 활성화되어 있는지 확인하세요.
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
///
|
||||
///
|
||||
/// 네트워크 요청과 응답을 로그로 기록합니다.
|
||||
/// 디버그 모드에서만 활성화됩니다.
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
@@ -12,35 +12,35 @@ class LoggingInterceptor extends Interceptor {
|
||||
final uri = options.uri;
|
||||
final method = options.method;
|
||||
final headers = options.headers;
|
||||
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('>>> REQUEST [$method] $uri');
|
||||
print('>>> Headers: $headers');
|
||||
|
||||
|
||||
if (options.data != null) {
|
||||
print('>>> Body: ${options.data}');
|
||||
}
|
||||
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
print('>>> Query Parameters: ${options.queryParameters}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final statusCode = response.statusCode;
|
||||
final uri = response.requestOptions.uri;
|
||||
|
||||
|
||||
print('<<< RESPONSE [$statusCode] $uri');
|
||||
|
||||
|
||||
if (response.headers.map.isNotEmpty) {
|
||||
print('<<< Headers: ${response.headers.map}');
|
||||
}
|
||||
|
||||
|
||||
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
|
||||
final responseData = response.data.toString();
|
||||
if (responseData.length > 500) {
|
||||
@@ -48,32 +48,32 @@ class LoggingInterceptor extends Interceptor {
|
||||
} else {
|
||||
print('<<< Body: $responseData');
|
||||
}
|
||||
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
||||
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final uri = err.requestOptions.uri;
|
||||
final message = err.message;
|
||||
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('!!! ERROR $uri');
|
||||
print('!!! Message: $message');
|
||||
|
||||
|
||||
if (err.response != null) {
|
||||
print('!!! Status Code: ${err.response!.statusCode}');
|
||||
print('!!! Response: ${err.response!.data}');
|
||||
}
|
||||
|
||||
|
||||
print('!!! Error Type: ${err.type}');
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,36 +5,38 @@ import '../network_config.dart';
|
||||
import '../../errors/network_exceptions.dart';
|
||||
|
||||
/// 재시도 인터셉터
|
||||
///
|
||||
///
|
||||
/// 네트워크 오류 발생 시 자동으로 재시도합니다.
|
||||
/// 지수 백오프(exponential backoff) 알고리즘을 사용합니다.
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final Dio dio;
|
||||
|
||||
|
||||
RetryInterceptor({required this.dio});
|
||||
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// 재시도 카운트 확인
|
||||
final retryCount = err.requestOptions.extra['retryCount'] ?? 0;
|
||||
|
||||
|
||||
// 재시도 가능한 오류인지 확인
|
||||
if (_shouldRetry(err) && retryCount < NetworkConfig.maxRetries) {
|
||||
try {
|
||||
// 지수 백오프 계산
|
||||
final delay = _calculateBackoffDelay(retryCount);
|
||||
|
||||
print('RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기');
|
||||
|
||||
|
||||
print(
|
||||
'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기',
|
||||
);
|
||||
|
||||
// 대기
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
|
||||
|
||||
// 재시도 카운트 증가
|
||||
err.requestOptions.extra['retryCount'] = retryCount + 1;
|
||||
|
||||
|
||||
// 재시도 실행
|
||||
final response = await dio.fetch(err.requestOptions);
|
||||
|
||||
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
// 재시도도 실패한 경우
|
||||
@@ -48,10 +50,10 @@ class RetryInterceptor extends Interceptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
|
||||
/// 재시도 가능한 오류인지 판단
|
||||
bool _shouldRetry(DioException err) {
|
||||
// 네이버 관련 요청은 재시도하지 않음
|
||||
@@ -60,7 +62,7 @@ class RetryInterceptor extends Interceptor {
|
||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 네트워크 연결 오류
|
||||
if (err.type == DioExceptionType.connectionTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout ||
|
||||
@@ -68,30 +70,30 @@ class RetryInterceptor extends Interceptor {
|
||||
err.type == DioExceptionType.connectionError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// 서버 오류 (5xx)
|
||||
final statusCode = err.response?.statusCode;
|
||||
if (statusCode != null && statusCode >= 500 && statusCode < 600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// 429 Too Many Requests는 재시도하지 않음
|
||||
// 재시도하면 더 많은 요청이 발생하여 문제가 악화됨
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/// 지수 백오프 지연 시간 계산
|
||||
int _calculateBackoffDelay(int retryCount) {
|
||||
final baseDelay = NetworkConfig.retryDelayMillis;
|
||||
final multiplier = NetworkConfig.retryDelayMultiplier;
|
||||
|
||||
|
||||
// 지수 백오프: delay = baseDelay * (multiplier ^ retryCount)
|
||||
final exponentialDelay = baseDelay * pow(multiplier, retryCount);
|
||||
|
||||
|
||||
// 지터(jitter) 추가로 동시 재시도 방지
|
||||
final jitter = Random().nextInt(1000);
|
||||
|
||||
|
||||
return exponentialDelay.toInt() + jitter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@ import 'interceptors/retry_interceptor.dart';
|
||||
import 'interceptors/logging_interceptor.dart';
|
||||
|
||||
/// 네트워크 클라이언트
|
||||
///
|
||||
///
|
||||
/// Dio를 기반으로 한 중앙화된 HTTP 클라이언트입니다.
|
||||
/// 재시도, 캐싱, 로깅 등의 기능을 제공합니다.
|
||||
class NetworkClient {
|
||||
late final Dio _dio;
|
||||
CacheStore? _cacheStore;
|
||||
|
||||
|
||||
NetworkClient() {
|
||||
_dio = Dio(_createBaseOptions());
|
||||
_setupInterceptors();
|
||||
}
|
||||
|
||||
|
||||
/// 기본 옵션 생성
|
||||
BaseOptions _createBaseOptions() {
|
||||
return BaseOptions(
|
||||
@@ -37,20 +37,20 @@ class NetworkClient {
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 인터셉터 설정
|
||||
Future<void> _setupInterceptors() async {
|
||||
// 로깅 인터셉터 (디버그 모드에서만)
|
||||
if (kDebugMode) {
|
||||
_dio.interceptors.add(LoggingInterceptor());
|
||||
}
|
||||
|
||||
|
||||
// 재시도 인터셉터
|
||||
_dio.interceptors.add(RetryInterceptor(dio: _dio));
|
||||
|
||||
|
||||
// 캐시 인터셉터 설정
|
||||
await _setupCacheInterceptor();
|
||||
|
||||
|
||||
// 에러 변환 인터셉터
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
@@ -60,24 +60,24 @@ class NetworkClient {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 캐시 인터셉터 설정
|
||||
Future<void> _setupCacheInterceptor() async {
|
||||
try {
|
||||
if (!kIsWeb) {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheDir = Directory('${dir.path}/lunchpick_cache');
|
||||
|
||||
|
||||
if (!await cacheDir.exists()) {
|
||||
await cacheDir.create(recursive: true);
|
||||
}
|
||||
|
||||
|
||||
_cacheStore = HiveCacheStore(cacheDir.path);
|
||||
} else {
|
||||
// 웹 환경에서는 메모리 캐시 사용
|
||||
_cacheStore = MemCacheStore();
|
||||
}
|
||||
|
||||
|
||||
final cacheOptions = CacheOptions(
|
||||
store: _cacheStore,
|
||||
policy: CachePolicy.forceCache,
|
||||
@@ -86,33 +86,33 @@ class NetworkClient {
|
||||
keyBuilder: CacheOptions.defaultCacheKeyBuilder,
|
||||
allowPostMethod: false,
|
||||
);
|
||||
|
||||
|
||||
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
|
||||
} catch (e) {
|
||||
debugPrint('NetworkClient: 캐시 설정 실패 - $e');
|
||||
// 캐시 실패해도 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 에러 변환
|
||||
DioException _transformError(DioException error) {
|
||||
NetworkException networkException;
|
||||
|
||||
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
networkException = ConnectionTimeoutException(originalError: error);
|
||||
break;
|
||||
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
networkException = NoInternetException(originalError: error);
|
||||
break;
|
||||
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode ?? 0;
|
||||
final message = _getErrorMessage(error.response);
|
||||
|
||||
|
||||
if (statusCode >= 500) {
|
||||
networkException = ServerException(
|
||||
message: message,
|
||||
@@ -133,14 +133,14 @@ class NetworkClient {
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
networkException = NoInternetException(
|
||||
message: error.message ?? '알 수 없는 네트워크 오류가 발생했습니다',
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
@@ -148,15 +148,15 @@ class NetworkClient {
|
||||
error: networkException,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 에러 메시지 추출
|
||||
String _getErrorMessage(Response? response) {
|
||||
if (response == null) {
|
||||
return '서버 응답을 받을 수 없습니다';
|
||||
}
|
||||
|
||||
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
|
||||
|
||||
// 상태 코드별 기본 메시지
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
@@ -179,7 +179,7 @@ class NetworkClient {
|
||||
return '서버 오류가 발생했습니다 (HTTP $statusCode)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// GET 요청
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
@@ -190,15 +190,12 @@ class NetworkClient {
|
||||
bool useCache = true,
|
||||
}) {
|
||||
final requestOptions = options ?? Options();
|
||||
|
||||
|
||||
// 캐시 사용 설정
|
||||
if (!useCache) {
|
||||
requestOptions.extra = {
|
||||
...?requestOptions.extra,
|
||||
'disableCache': true,
|
||||
};
|
||||
requestOptions.extra = {...?requestOptions.extra, 'disableCache': true};
|
||||
}
|
||||
|
||||
|
||||
return _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
@@ -207,7 +204,7 @@ class NetworkClient {
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// POST 요청
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
@@ -228,7 +225,7 @@ class NetworkClient {
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// HEAD 요청 (리다이렉션 확인용)
|
||||
Future<Response<T>> head<T>(
|
||||
String path, {
|
||||
@@ -243,12 +240,12 @@ class NetworkClient {
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 캐시 삭제
|
||||
Future<void> clearCache() async {
|
||||
await _cacheStore?.clean();
|
||||
}
|
||||
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_dio.close();
|
||||
@@ -257,4 +254,4 @@ class NetworkClient {
|
||||
}
|
||||
|
||||
/// 기본 네트워크 클라이언트 인스턴스
|
||||
final networkClient = NetworkClient();
|
||||
final networkClient = NetworkClient();
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
/// 네트워크 설정 상수
|
||||
///
|
||||
///
|
||||
/// 모든 네트워크 관련 설정을 중앙 관리합니다.
|
||||
class NetworkConfig {
|
||||
// 타임아웃 설정 (밀리초)
|
||||
static const int connectTimeout = 15000; // 15초
|
||||
static const int receiveTimeout = 30000; // 30초
|
||||
static const int sendTimeout = 15000; // 15초
|
||||
|
||||
|
||||
// 재시도 설정
|
||||
static const int maxRetries = 3;
|
||||
static const int retryDelayMillis = 1000; // 1초
|
||||
static const double retryDelayMultiplier = 2.0; // 지수 백오프
|
||||
|
||||
|
||||
// 캐시 설정
|
||||
static const Duration cacheMaxAge = Duration(minutes: 15);
|
||||
static const int cacheMaxSize = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
|
||||
// 네이버 API 설정
|
||||
static const String naverApiBaseUrl = 'https://openapi.naver.com';
|
||||
static const String naverMapBaseUrl = 'https://map.naver.com';
|
||||
static const String naverShortUrlBase = 'https://naver.me';
|
||||
|
||||
|
||||
// CORS 프록시 (웹 환경용)
|
||||
static const String corsProxyUrl = 'https://api.allorigins.win/get';
|
||||
|
||||
|
||||
// User Agent
|
||||
static const String userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
static const String userAgent =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
/// CORS 프록시 URL 생성
|
||||
static String getCorsProxyUrl(String originalUrl) {
|
||||
return '$corsProxyUrl?url=${Uri.encodeComponent(originalUrl)}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
143
lib/core/services/ad_service.dart
Normal file
143
lib/core/services/ad_service.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 간단한 전면 광고(Interstitial Ad) 모의 서비스
|
||||
class AdService {
|
||||
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _MockInterstitialAdDialog(),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialog extends StatefulWidget {
|
||||
const _MockInterstitialAdDialog();
|
||||
|
||||
@override
|
||||
State<_MockInterstitialAdDialog> createState() =>
|
||||
_MockInterstitialAdDialogState();
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
static const int _adDurationSeconds = 4;
|
||||
|
||||
late Timer _timer;
|
||||
int _elapsedSeconds = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_elapsedSeconds++;
|
||||
});
|
||||
if (_elapsedSeconds >= _adDurationSeconds) {
|
||||
_timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canClose => _elapsedSeconds >= _adDurationSeconds;
|
||||
|
||||
double get _progress => (_elapsedSeconds / _adDurationSeconds).clamp(0, 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 80),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.ondemand_video,
|
||||
size: 56,
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'광고 시청 중...',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_canClose ? '광고가 완료되었습니다.' : '잠시만 기다려 주세요.',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.2),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_canClose
|
||||
? '이제 닫을 수 있어요.'
|
||||
: '남은 시간: ${_adDurationSeconds - _elapsedSeconds}초',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _canClose
|
||||
? () {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: Colors.deepPurple,
|
||||
),
|
||||
child: const Text('추천 계속 보기'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/core/services/bluetooth_service.dart
Normal file
89
lib/core/services/bluetooth_service.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/share_device.dart';
|
||||
|
||||
/// 실제 Bluetooth 통신을 대체하는 간단한 모의(Mock) 서비스.
|
||||
class BluetoothService {
|
||||
final _incomingDataController = StreamController<String>.broadcast();
|
||||
final Map<String, ShareDevice> _listeningDevices = {};
|
||||
final Random _random = Random();
|
||||
|
||||
Stream<String> get onDataReceived => _incomingDataController.stream;
|
||||
|
||||
/// 특정 코드로 수신 대기를 시작한다.
|
||||
Future<void> startListening(String code) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
stopListening();
|
||||
final shareDevice = ShareDevice(
|
||||
code: code,
|
||||
deviceId: 'LP-${_random.nextInt(900000) + 100000}',
|
||||
discoveredAt: DateTime.now(),
|
||||
);
|
||||
_listeningDevices[code] = shareDevice;
|
||||
}
|
||||
|
||||
/// 더 이상 수신 대기하지 않는다.
|
||||
void stopListening() {
|
||||
if (_listeningDevices.isEmpty) return;
|
||||
final codes = List<String>.from(_listeningDevices.keys);
|
||||
for (final code in codes) {
|
||||
_listeningDevices.remove(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 주변에서 수신 대기 중인 기기 목록을 반환한다.
|
||||
Future<List<ShareDevice>> scanNearbyDevices() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return _listeningDevices.values.toList();
|
||||
}
|
||||
|
||||
/// 대상 코드로 맛집 리스트를 전송한다. 실제 BT 대신 JSON 문자열을 브로드캐스트한다.
|
||||
Future<void> sendRestaurantList(
|
||||
String targetCode,
|
||||
List<Restaurant> restaurants,
|
||||
) async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
if (!_listeningDevices.containsKey(targetCode)) {
|
||||
throw Exception('해당 코드를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
final payload = jsonEncode(
|
||||
restaurants
|
||||
.map((restaurant) => _serializeRestaurant(restaurant))
|
||||
.toList(),
|
||||
);
|
||||
_incomingDataController.add(payload);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serializeRestaurant(Restaurant restaurant) {
|
||||
return {
|
||||
'id': restaurant.id,
|
||||
'name': restaurant.name,
|
||||
'category': restaurant.category,
|
||||
'subCategory': restaurant.subCategory,
|
||||
'description': restaurant.description,
|
||||
'phoneNumber': restaurant.phoneNumber,
|
||||
'roadAddress': restaurant.roadAddress,
|
||||
'jibunAddress': restaurant.jibunAddress,
|
||||
'latitude': restaurant.latitude,
|
||||
'longitude': restaurant.longitude,
|
||||
'lastVisitDate': restaurant.lastVisitDate?.toIso8601String(),
|
||||
'source': restaurant.source.name,
|
||||
'createdAt': restaurant.createdAt.toIso8601String(),
|
||||
'updatedAt': restaurant.updatedAt.toIso8601String(),
|
||||
'naverPlaceId': restaurant.naverPlaceId,
|
||||
'naverUrl': restaurant.naverUrl,
|
||||
'businessHours': restaurant.businessHours,
|
||||
'lastVisited': restaurant.lastVisited?.toIso8601String(),
|
||||
'visitCount': restaurant.visitCount,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_incomingDataController.close();
|
||||
_listeningDevices.clear();
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,14 @@ class NotificationService {
|
||||
NotificationService._internal();
|
||||
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
// 알림 채널 정보
|
||||
static const String _channelId = 'lunchpick_visit_reminder';
|
||||
static const String _channelName = '방문 확인 알림';
|
||||
static const String _channelDescription = '점심 식사 후 방문을 확인하는 알림입니다.';
|
||||
|
||||
|
||||
// 알림 ID (방문 확인용)
|
||||
static const int _visitReminderNotificationId = 1;
|
||||
|
||||
@@ -26,10 +27,12 @@ class NotificationService {
|
||||
Future<bool> initialize() async {
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const androidInitSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
// iOS 초기화 설정
|
||||
final iosInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
@@ -39,33 +42,33 @@ class NotificationService {
|
||||
// iOS 9 이하 버전 대응
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// macOS 초기화 설정
|
||||
final macOSInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
|
||||
// 플랫폼별 초기화 설정 통합
|
||||
final initSettings = InitializationSettings(
|
||||
android: androidInitSettings,
|
||||
iOS: iosInitSettings,
|
||||
macOS: macOSInitSettings,
|
||||
);
|
||||
|
||||
|
||||
// 알림 플러그인 초기화
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
|
||||
);
|
||||
|
||||
|
||||
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await _createNotificationChannel();
|
||||
}
|
||||
|
||||
|
||||
return initialized ?? false;
|
||||
}
|
||||
|
||||
@@ -79,9 +82,11 @@ class NotificationService {
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
}
|
||||
|
||||
@@ -89,23 +94,32 @@ class NotificationService {
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.requestNotificationsPermission();
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
} else if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
} else if (!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -114,7 +128,7 @@ class NotificationService {
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final granted = await macosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -124,7 +138,7 @@ class NotificationService {
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -132,11 +146,13 @@ class NotificationService {
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
@@ -144,27 +160,27 @@ class NotificationService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
return true;
|
||||
}
|
||||
|
||||
// 알림 탭 콜백
|
||||
static void Function(NotificationResponse)? onNotificationTap;
|
||||
|
||||
|
||||
/// 방문 확인 알림 예약
|
||||
Future<void> scheduleVisitReminder({
|
||||
required String restaurantId,
|
||||
required String restaurantName,
|
||||
required DateTime recommendationTime,
|
||||
int? delayMinutes,
|
||||
}) async {
|
||||
try {
|
||||
// 1.5~2시간 사이의 랜덤 시간 계산 (90~120분)
|
||||
final randomMinutes = 90 + Random().nextInt(31); // 90 + 0~30분
|
||||
final scheduledTime = tz.TZDateTime.now(tz.local).add(
|
||||
Duration(minutes: randomMinutes),
|
||||
);
|
||||
|
||||
final minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
|
||||
final scheduledTime = tz.TZDateTime.now(
|
||||
tz.local,
|
||||
).add(Duration(minutes: minutesToWait));
|
||||
|
||||
// 알림 상세 설정
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
@@ -178,20 +194,20 @@ class NotificationService {
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
sound: 'default',
|
||||
);
|
||||
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: iosDetails,
|
||||
);
|
||||
|
||||
|
||||
// 알림 예약
|
||||
await _notifications.zonedSchedule(
|
||||
_visitReminderNotificationId,
|
||||
@@ -202,11 +218,11 @@ class NotificationService {
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
payload:
|
||||
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($randomMinutes분 후)');
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
@@ -265,20 +281,15 @@ class NotificationService {
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
0,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(0, title, body, notificationDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
lib/core/services/permission_service.dart
Normal file
31
lib/core/services/permission_service.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 공용 권한 유틸리티
|
||||
class PermissionService {
|
||||
static Future<bool> checkAndRequestBluetoothPermission() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final permissions = <Permission>[
|
||||
Permission.bluetooth,
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothAdvertise,
|
||||
];
|
||||
|
||||
for (final permission in permissions) {
|
||||
final status = await permission.status;
|
||||
if (status.isGranted) {
|
||||
continue;
|
||||
}
|
||||
final result = await permission.request();
|
||||
if (!result.isGranted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,14 @@ class CategoryMapper {
|
||||
if (_iconMap.containsKey(category)) {
|
||||
return _iconMap[category]!;
|
||||
}
|
||||
|
||||
|
||||
// 부분 일치 검색 (키워드 포함)
|
||||
for (final entry in _iconMap.entries) {
|
||||
if (category.contains(entry.key) || entry.key.contains(category)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 기본 아이콘
|
||||
return Icons.restaurant_menu;
|
||||
}
|
||||
@@ -92,14 +92,14 @@ class CategoryMapper {
|
||||
if (_colorMap.containsKey(category)) {
|
||||
return _colorMap[category]!;
|
||||
}
|
||||
|
||||
|
||||
// 부분 일치 검색 (키워드 포함)
|
||||
for (final entry in _colorMap.entries) {
|
||||
if (category.contains(entry.key) || entry.key.contains(category)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 카테고리 문자열 기반 색상 생성 (일관된 색상)
|
||||
final hash = category.hashCode;
|
||||
final hue = (hash % 360).toDouble();
|
||||
@@ -129,14 +129,14 @@ class CategoryMapper {
|
||||
if (category == '음식점' && subCategory != null && subCategory.isNotEmpty) {
|
||||
return subCategory;
|
||||
}
|
||||
|
||||
|
||||
// ">"로 구분된 카테고리의 경우 가장 구체적인 부분 사용
|
||||
if (category.contains('>')) {
|
||||
final parts = category.split('>').map((s) => s.trim()).toList();
|
||||
// 마지막 부분이 가장 구체적
|
||||
return parts.last;
|
||||
}
|
||||
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ class DistanceCalculator {
|
||||
final double dLat = _toRadians(lat2 - lat1);
|
||||
final double dLon = _toRadians(lon2 - lon1);
|
||||
|
||||
final double a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
final double a =
|
||||
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_toRadians(lat1)) *
|
||||
math.cos(_toRadians(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
@@ -79,7 +80,7 @@ class DistanceCalculator {
|
||||
required double currentLon,
|
||||
}) {
|
||||
final List<T> sortedItems = List<T>.from(items);
|
||||
|
||||
|
||||
sortedItems.sort((a, b) {
|
||||
final distanceA = calculateDistance(
|
||||
lat1: currentLat,
|
||||
@@ -87,24 +88,21 @@ class DistanceCalculator {
|
||||
lat2: getLat(a),
|
||||
lon2: getLon(a),
|
||||
);
|
||||
|
||||
|
||||
final distanceB = calculateDistance(
|
||||
lat1: currentLat,
|
||||
lon1: currentLon,
|
||||
lat2: getLat(b),
|
||||
lon2: getLon(b),
|
||||
);
|
||||
|
||||
|
||||
return distanceA.compareTo(distanceB);
|
||||
});
|
||||
|
||||
|
||||
return sortedItems;
|
||||
}
|
||||
|
||||
static Map<String, double> getDefaultLocationForKorea() {
|
||||
return {
|
||||
'latitude': 37.5665,
|
||||
'longitude': 126.9780,
|
||||
};
|
||||
return {'latitude': 37.5665, 'longitude': 126.9780};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,16 @@ class Validators {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
final lat = double.tryParse(value);
|
||||
if (lat == null) {
|
||||
return '올바른 위도 값을 입력해주세요';
|
||||
}
|
||||
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return '위도는 -90도에서 90도 사이여야 합니다';
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -40,16 +40,16 @@ class Validators {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
final lng = double.tryParse(value);
|
||||
if (lng == null) {
|
||||
return '올바른 경도 값을 입력해주세요';
|
||||
}
|
||||
|
||||
|
||||
if (lng < -180 || lng > 180) {
|
||||
return '경도는 -180도에서 180도 사이여야 합니다';
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ class Validators {
|
||||
|
||||
static bool isValidEmail(String? email) {
|
||||
if (email == null || email.isEmpty) return false;
|
||||
|
||||
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
@@ -85,8 +85,8 @@ class Validators {
|
||||
|
||||
static bool isValidPhoneNumber(String? phone) {
|
||||
if (phone == null || phone.isEmpty) return false;
|
||||
|
||||
|
||||
final phoneRegex = RegExp(r'^[0-9-+() ]+$');
|
||||
return phoneRegex.hasMatch(phone) && phone.length >= 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,27 @@ import '../constants/app_colors.dart';
|
||||
import '../constants/app_typography.dart';
|
||||
|
||||
/// 빈 상태 위젯
|
||||
///
|
||||
///
|
||||
/// 데이터가 없을 때 표시하는 공통 위젯
|
||||
class EmptyStateWidget extends StatelessWidget {
|
||||
/// 제목
|
||||
final String title;
|
||||
|
||||
|
||||
/// 설명 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
|
||||
/// 아이콘 (선택사항)
|
||||
final IconData? icon;
|
||||
|
||||
|
||||
/// 아이콘 크기
|
||||
final double iconSize;
|
||||
|
||||
|
||||
/// 액션 버튼 텍스트 (선택사항)
|
||||
final String? actionText;
|
||||
|
||||
|
||||
/// 액션 버튼 콜백 (선택사항)
|
||||
final VoidCallback? onAction;
|
||||
|
||||
|
||||
/// 커스텀 위젯 (아이콘 대신 사용할 수 있음)
|
||||
final Widget? customWidget;
|
||||
|
||||
@@ -41,7 +41,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -56,29 +56,28 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary
|
||||
).withValues(alpha: 0.1),
|
||||
color:
|
||||
(isDark ? AppColors.darkPrimary : AppColors.lightPrimary)
|
||||
.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 제목
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.heading2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
|
||||
// 설명 메시지 (있을 경우)
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
@@ -90,15 +89,15 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
// 액션 버튼 (있을 경우)
|
||||
if (actionText != null && onAction != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: onAction,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -126,20 +125,16 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 리스트 빈 상태 위젯
|
||||
///
|
||||
///
|
||||
/// 리스트나 그리드가 비어있을 때 사용하는 특화된 위젯
|
||||
class ListEmptyStateWidget extends StatelessWidget {
|
||||
/// 아이템 유형 (예: "식당", "기록" 등)
|
||||
final String itemType;
|
||||
|
||||
|
||||
/// 추가 액션 콜백 (선택사항)
|
||||
final VoidCallback? onAdd;
|
||||
|
||||
const ListEmptyStateWidget({
|
||||
super.key,
|
||||
required this.itemType,
|
||||
this.onAdd,
|
||||
});
|
||||
const ListEmptyStateWidget({super.key, required this.itemType, this.onAdd});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -151,4 +146,4 @@ class ListEmptyStateWidget extends StatelessWidget {
|
||||
onAction: onAdd,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@ import '../constants/app_colors.dart';
|
||||
import '../constants/app_typography.dart';
|
||||
|
||||
/// 커스텀 에러 위젯
|
||||
///
|
||||
///
|
||||
/// Flutter의 기본 ErrorWidget과 이름 충돌을 피하기 위해 CustomErrorWidget으로 명명
|
||||
class CustomErrorWidget extends StatelessWidget {
|
||||
/// 에러 메시지
|
||||
final String message;
|
||||
|
||||
|
||||
/// 에러 아이콘 (선택사항)
|
||||
final IconData? icon;
|
||||
|
||||
|
||||
/// 재시도 버튼 콜백 (선택사항)
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
|
||||
/// 상세 에러 메시지 (선택사항)
|
||||
final String? details;
|
||||
|
||||
@@ -29,7 +29,7 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
@@ -44,14 +44,14 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
color: isDark ? AppColors.darkError : AppColors.lightError,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 에러 메시지
|
||||
Text(
|
||||
message,
|
||||
style: AppTypography.heading2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
|
||||
// 상세 메시지 (있을 경우)
|
||||
if (details != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
@@ -61,7 +61,7 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
// 재시도 버튼 (있을 경우)
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
@@ -70,8 +70,8 @@ class CustomErrorWidget extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('다시 시도'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -96,21 +96,16 @@ void showErrorSnackBar({
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
content: Text(message, style: const TextStyle(color: Colors.white)),
|
||||
backgroundColor: isDark ? AppColors.darkError : AppColors.lightError,
|
||||
duration: duration,
|
||||
action: action,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
margin: const EdgeInsets.all(8),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import 'package:flutter/material.dart';
|
||||
import '../constants/app_colors.dart';
|
||||
|
||||
/// 로딩 인디케이터 위젯
|
||||
///
|
||||
///
|
||||
/// 앱 전체에서 일관된 로딩 표시를 위한 공통 위젯
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
|
||||
/// 인디케이터 크기
|
||||
final double size;
|
||||
|
||||
|
||||
/// 스트로크 너비
|
||||
final double strokeWidth;
|
||||
|
||||
@@ -24,7 +24,7 @@ class LoadingIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -46,8 +46,8 @@ class LoadingIndicator extends StatelessWidget {
|
||||
message!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -60,12 +60,12 @@ class LoadingIndicator extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 전체 화면 로딩 인디케이터
|
||||
///
|
||||
///
|
||||
/// 화면 전체를 덮는 로딩 표시를 위한 위젯
|
||||
class FullScreenLoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
|
||||
/// 배경 투명도
|
||||
final double backgroundOpacity;
|
||||
|
||||
@@ -78,11 +78,12 @@ class FullScreenLoadingIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return Container(
|
||||
color: (isDark ? Colors.black : Colors.white)
|
||||
.withValues(alpha: backgroundOpacity),
|
||||
color: (isDark ? Colors.black : Colors.white).withValues(
|
||||
alpha: backgroundOpacity,
|
||||
),
|
||||
child: LoadingIndicator(message: message),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user