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