feat: 초기 프로젝트 설정 및 LunchPick 앱 구현
LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
27
lib/core/constants/app_colors.dart
Normal file
27
lib/core/constants/app_colors.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
// Light Theme Colors
|
||||
static const lightPrimary = Color(0xFF03C75A); // 네이버 그린
|
||||
static const lightSecondary = Color(0xFF00BF63);
|
||||
static const lightBackground = Color(0xFFF5F5F5);
|
||||
static const lightSurface = Colors.white;
|
||||
static const lightTextPrimary = Color(0xFF222222);
|
||||
static const lightTextSecondary = Color(0xFF767676);
|
||||
static const lightDivider = Color(0xFFE5E5E5);
|
||||
static const lightError = Color(0xFFFF5252);
|
||||
static const lightText = Color(0xFF222222); // 추가
|
||||
static const lightCard = Colors.white; // 추가
|
||||
|
||||
// Dark Theme Colors
|
||||
static const darkPrimary = Color(0xFF03C75A);
|
||||
static const darkSecondary = Color(0xFF00BF63);
|
||||
static const darkBackground = Color(0xFF121212);
|
||||
static const darkSurface = Color(0xFF1E1E1E);
|
||||
static const darkTextPrimary = Color(0xFFFFFFFF);
|
||||
static const darkTextSecondary = Color(0xFFB3B3B3);
|
||||
static const darkDivider = Color(0xFF2C2C2C);
|
||||
static const darkError = Color(0xFFFF5252);
|
||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||
}
|
||||
44
lib/core/constants/app_constants.dart
Normal file
44
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
class AppConstants {
|
||||
// App Info
|
||||
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.';
|
||||
|
||||
// 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';
|
||||
|
||||
// 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 = [
|
||||
'한식',
|
||||
'중식',
|
||||
'일식',
|
||||
'양식',
|
||||
'분식',
|
||||
'카페',
|
||||
'패스트푸드',
|
||||
'기타',
|
||||
];
|
||||
}
|
||||
34
lib/core/constants/app_typography.dart
Normal file
34
lib/core/constants/app_typography.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTypography {
|
||||
static TextStyle heading1(bool isDark) => TextStyle(
|
||||
fontSize: 24,
|
||||
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,
|
||||
);
|
||||
}
|
||||
178
lib/core/errors/app_exceptions.dart
Normal file
178
lib/core/errors/app_exceptions.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
/// 애플리케이션 전체 예외 클래스들
|
||||
///
|
||||
/// 각 레이어별로 명확한 예외 계층 구조를 제공합니다.
|
||||
|
||||
/// 앱 예외 기본 클래스
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const AppException({
|
||||
required this.message,
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// 비즈니스 로직 예외
|
||||
class BusinessException extends AppException {
|
||||
const BusinessException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic 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();
|
||||
if (fieldErrors != null && fieldErrors!.isNotEmpty) {
|
||||
final errors = fieldErrors!.entries
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.join(', ');
|
||||
return '$base [필드 오류: $errors]';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 예외
|
||||
class DataException extends AppException {
|
||||
const DataException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 저장소 예외
|
||||
class StorageException extends DataException {
|
||||
const StorageException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic 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)';
|
||||
}
|
||||
|
||||
/// 위치 서비스 예외
|
||||
class LocationException extends AppException {
|
||||
const LocationException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 설정 예외
|
||||
class ConfigurationException extends AppException {
|
||||
const ConfigurationException({
|
||||
required String message,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
}
|
||||
|
||||
/// UI 예외
|
||||
class UIException extends AppException {
|
||||
const UIException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic 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',
|
||||
);
|
||||
}
|
||||
|
||||
/// 중복 리소스 예외
|
||||
class DuplicateException extends AppException {
|
||||
final String resourceType;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// 알림 예외
|
||||
class NotificationException extends AppException {
|
||||
const NotificationException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
149
lib/core/errors/data_exceptions.dart
Normal file
149
lib/core/errors/data_exceptions.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
/// 데이터 레이어 예외 클래스들
|
||||
///
|
||||
/// API, 데이터베이스, 파싱 관련 예외를 정의합니다.
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// 네이버 API 예외
|
||||
class NaverApiException extends ApiException {
|
||||
const NaverApiException({
|
||||
required String message,
|
||||
int? statusCode,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
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,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
return url != null ? '$base (URL: $url)' : base;
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 변환 예외
|
||||
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,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message ($fromType → $toType)';
|
||||
}
|
||||
|
||||
/// 캐시 예외
|
||||
class CacheException extends StorageException {
|
||||
const CacheException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
code: code ?? 'CACHE_ERROR',
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hive 예외
|
||||
class HiveException extends StorageException {
|
||||
const HiveException({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
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,
|
||||
);
|
||||
|
||||
@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',
|
||||
);
|
||||
}
|
||||
|
||||
/// 지원하지 않는 URL 예외
|
||||
class UnsupportedUrlException extends UrlProcessingException {
|
||||
const UnsupportedUrlException({
|
||||
required String url,
|
||||
String? message,
|
||||
}) : super(
|
||||
message: message ?? '지원하지 않는 URL입니다',
|
||||
url: url,
|
||||
code: 'UNSUPPORTED_URL',
|
||||
);
|
||||
}
|
||||
108
lib/core/errors/network_exceptions.dart
Normal file
108
lib/core/errors/network_exceptions.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
/// 네트워크 관련 예외 클래스들
|
||||
///
|
||||
/// 모든 네트워크 오류를 명확하게 분류하고 처리합니다.
|
||||
|
||||
/// 네트워크 예외 기본 클래스
|
||||
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)' : ''}';
|
||||
}
|
||||
|
||||
/// 연결 타임아웃 예외
|
||||
class ConnectionTimeoutException extends NetworkException {
|
||||
const ConnectionTimeoutException({
|
||||
String message = '서버 연결 시간이 초과되었습니다',
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 네트워크 연결 없음 예외
|
||||
class NoInternetException extends NetworkException {
|
||||
const NoInternetException({
|
||||
String message = '인터넷 연결을 확인해주세요',
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 서버 오류 예외 (5xx)
|
||||
class ServerException extends NetworkException {
|
||||
const ServerException({
|
||||
required String message,
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 클라이언트 오류 예외 (4xx)
|
||||
class ClientException extends NetworkException {
|
||||
const ClientException({
|
||||
required String message,
|
||||
required int statusCode,
|
||||
dynamic originalError,
|
||||
}) : super(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 파싱 오류 예외
|
||||
class ParseException extends NetworkException {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 재시도 횟수 초과 예외
|
||||
class MaxRetriesExceededException extends NetworkException {
|
||||
const MaxRetriesExceededException({
|
||||
String message = '최대 재시도 횟수를 초과했습니다',
|
||||
dynamic originalError,
|
||||
}) : super(message: message, originalError: originalError);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final base = super.toString();
|
||||
if (retryAfter != null) {
|
||||
return '$base (재시도 가능: $retryAfter초 후)';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
172
lib/core/network/README.md
Normal file
172
lib/core/network/README.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 네트워크 모듈 사용 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
이 네트워크 모듈은 네이버 단축 URL 처리와 로컬 API 검색을 위한 통합 솔루션을 제공합니다. Dio 기반으로 구축되어 재시도, 캐싱, 로깅 등의 기능을 제공합니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
1. **네이버 단축 URL 리다이렉션 처리**
|
||||
2. **HTML 스크래핑으로 식당 정보 추출**
|
||||
3. **네이버 로컬 검색 API 통합**
|
||||
4. **자동 재시도 및 에러 처리**
|
||||
5. **응답 캐싱으로 성능 최적화**
|
||||
6. **네트워크 불안정 상황 대응**
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. 네이버 지도 URL에서 식당 정보 추출
|
||||
|
||||
```dart
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
|
||||
final searchService = NaverSearchService();
|
||||
|
||||
try {
|
||||
// 일반 네이버 지도 URL
|
||||
final restaurant = await searchService.getRestaurantFromUrl(
|
||||
'https://map.naver.com/p/restaurant/1234567890',
|
||||
);
|
||||
|
||||
// 단축 URL도 자동 처리
|
||||
final restaurant2 = await searchService.getRestaurantFromUrl(
|
||||
'https://naver.me/abc123',
|
||||
);
|
||||
|
||||
print('식당명: ${restaurant.name}');
|
||||
print('카테고리: ${restaurant.category}');
|
||||
print('주소: ${restaurant.roadAddress}');
|
||||
} catch (e) {
|
||||
print('오류 발생: $e');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 키워드로 주변 식당 검색
|
||||
|
||||
```dart
|
||||
// 현재 위치 기반 검색
|
||||
final restaurants = await searchService.searchNearbyRestaurants(
|
||||
query: '파스타',
|
||||
latitude: 37.5666805,
|
||||
longitude: 126.9784147,
|
||||
maxResults: 20,
|
||||
sort: 'random', // 정확도순 정렬 (기본값)
|
||||
);
|
||||
|
||||
for (final restaurant in restaurants) {
|
||||
print('${restaurant.name} - ${restaurant.roadAddress}');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 식당 상세 정보 검색
|
||||
|
||||
```dart
|
||||
// 식당 이름과 주소로 상세 정보 검색
|
||||
final details = await searchService.searchRestaurantDetails(
|
||||
name: '맛있는 한식당',
|
||||
address: '서울 중구 세종대로',
|
||||
latitude: 37.5666805,
|
||||
longitude: 126.9784147,
|
||||
);
|
||||
|
||||
if (details != null) {
|
||||
print('영업시간: ${details.businessHours}');
|
||||
print('전화번호: ${details.phoneNumber}');
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 네트워크 에러 처리
|
||||
|
||||
```dart
|
||||
import 'package:lunchpick/core/errors/network_exceptions.dart';
|
||||
|
||||
try {
|
||||
final restaurant = await searchService.getRestaurantFromUrl(url);
|
||||
} on ConnectionTimeoutException {
|
||||
// 연결 타임아웃
|
||||
showSnackBar('네트워크 연결이 느립니다. 다시 시도해주세요.');
|
||||
} on NoInternetException {
|
||||
// 인터넷 연결 없음
|
||||
showSnackBar('인터넷 연결을 확인해주세요.');
|
||||
} on ApiKeyException {
|
||||
// API 키 설정 필요
|
||||
showSnackBar('네이버 API 키를 설정해주세요.');
|
||||
} on NaverMapParseException catch (e) {
|
||||
// 파싱 오류
|
||||
showSnackBar('식당 정보를 가져올 수 없습니다: ${e.message}');
|
||||
} catch (e) {
|
||||
// 기타 오류
|
||||
showSnackBar('알 수 없는 오류가 발생했습니다.');
|
||||
}
|
||||
```
|
||||
|
||||
## 설정
|
||||
|
||||
### API 키 설정
|
||||
|
||||
네이버 로컬 검색 API를 사용하려면 API 키가 필요합니다:
|
||||
|
||||
1. [네이버 개발자 센터](https://developers.naver.com)에서 애플리케이션 등록
|
||||
2. Client ID와 Client Secret 발급
|
||||
3. `lib/core/constants/api_keys.dart` 파일에 키 입력:
|
||||
|
||||
```dart
|
||||
class ApiKeys {
|
||||
static const String naverClientId = 'YOUR_CLIENT_ID';
|
||||
static const String naverClientSecret = 'YOUR_CLIENT_SECRET';
|
||||
}
|
||||
```
|
||||
|
||||
### 네트워크 설정 커스터마이징
|
||||
|
||||
`lib/core/network/network_config.dart`에서 타임아웃, 재시도 횟수 등을 조정할 수 있습니다:
|
||||
|
||||
```dart
|
||||
class NetworkConfig {
|
||||
static const int connectTimeout = 15000; // 15초
|
||||
static const int maxRetries = 3; // 최대 3회 재시도
|
||||
static const Duration cacheMaxAge = Duration(minutes: 15); // 15분 캐싱
|
||||
}
|
||||
```
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── errors/
|
||||
│ │ ├── app_exceptions.dart # 앱 전체 예외 클래스들
|
||||
│ │ ├── data_exceptions.dart # 데이터 레이어 예외
|
||||
│ │ └── network_exceptions.dart # 네트워크 예외
|
||||
│ └── network/
|
||||
│ ├── network_client.dart # Dio 기반 HTTP 클라이언트
|
||||
│ ├── network_config.dart # 네트워크 설정
|
||||
│ └── interceptors/
|
||||
│ ├── retry_interceptor.dart # 재시도 로직
|
||||
│ └── logging_interceptor.dart # 로깅
|
||||
├── data/
|
||||
│ ├── api/
|
||||
│ │ └── naver_api_client.dart # 네이버 API 클라이언트
|
||||
│ └── datasources/
|
||||
│ └── remote/
|
||||
│ ├── naver_map_parser.dart # HTML 파싱
|
||||
│ └── naver_search_service.dart # 통합 검색 서비스
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **API 키 보안**: API 키는 절대 Git에 커밋하지 마세요. `.gitignore`에 추가하세요.
|
||||
2. **요청 제한**: 네이버 API는 일일 요청 제한이 있습니다. 과도한 요청을 피하세요.
|
||||
3. **캐싱**: 동일한 요청은 15분간 캐싱됩니다. 실시간 정보가 필요한 경우 `useCache: false` 옵션을 사용하세요.
|
||||
4. **웹 환경**: 웹에서는 CORS 제한으로 인해 프록시 서버를 통해 요청합니다.
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### CORS 에러 (웹)
|
||||
웹 환경에서 CORS 에러가 발생하면 프록시 서버가 일시적으로 사용 불가능한 상태일 수 있습니다. 잠시 후 다시 시도하거나 직접 입력 기능을 사용하세요.
|
||||
|
||||
### 타임아웃 에러
|
||||
네트워크가 느린 환경에서는 `NetworkConfig`의 타임아웃 값을 늘려보세요.
|
||||
|
||||
### API 키 에러
|
||||
API 키가 올바르게 설정되었는지 확인하고, 네이버 개발자 센터에서 API 사용 권한이 활성화되어 있는지 확인하세요.
|
||||
79
lib/core/network/interceptors/logging_interceptor.dart
Normal file
79
lib/core/network/interceptors/logging_interceptor.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
///
|
||||
/// 네트워크 요청과 응답을 로그로 기록합니다.
|
||||
/// 디버그 모드에서만 활성화됩니다.
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
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) {
|
||||
print('<<< Body: ${responseData.substring(0, 500)}...(truncated)');
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
97
lib/core/network/interceptors/retry_interceptor.dart
Normal file
97
lib/core/network/interceptors/retry_interceptor.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
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 대기');
|
||||
|
||||
// 대기
|
||||
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) {
|
||||
// 재시도도 실패한 경우
|
||||
if (retryCount + 1 >= NetworkConfig.maxRetries) {
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: MaxRetriesExceededException(originalError: e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
/// 재시도 가능한 오류인지 판단
|
||||
bool _shouldRetry(DioException err) {
|
||||
// 네이버 관련 요청은 재시도하지 않음
|
||||
final url = err.requestOptions.uri.toString();
|
||||
if (url.contains('naver.com') || url.contains('naver.me')) {
|
||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 네트워크 연결 오류
|
||||
if (err.type == DioExceptionType.connectionTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout ||
|
||||
err.type == DioExceptionType.receiveTimeout ||
|
||||
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;
|
||||
}
|
||||
}
|
||||
260
lib/core/network/network_client.dart
Normal file
260
lib/core/network/network_client.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'network_config.dart';
|
||||
import '../errors/network_exceptions.dart';
|
||||
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(
|
||||
connectTimeout: Duration(milliseconds: NetworkConfig.connectTimeout),
|
||||
receiveTimeout: Duration(milliseconds: NetworkConfig.receiveTimeout),
|
||||
sendTimeout: Duration(milliseconds: NetworkConfig.sendTimeout),
|
||||
headers: {
|
||||
'User-Agent': NetworkConfig.userAgent,
|
||||
'Accept': 'application/json, text/html, */*',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||
},
|
||||
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(
|
||||
onError: (error, handler) {
|
||||
handler.next(_transformError(error));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 캐시 인터셉터 설정
|
||||
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,
|
||||
maxStale: NetworkConfig.cacheMaxAge,
|
||||
priority: CachePriority.normal,
|
||||
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,
|
||||
statusCode: statusCode,
|
||||
originalError: error,
|
||||
);
|
||||
} else if (statusCode >= 400) {
|
||||
networkException = ClientException(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: error,
|
||||
);
|
||||
} else {
|
||||
networkException = ClientException(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
networkException = NoInternetException(
|
||||
message: error.message ?? '알 수 없는 네트워크 오류가 발생했습니다',
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
type: error.type,
|
||||
error: networkException,
|
||||
);
|
||||
}
|
||||
|
||||
/// 에러 메시지 추출
|
||||
String _getErrorMessage(Response? response) {
|
||||
if (response == null) {
|
||||
return '서버 응답을 받을 수 없습니다';
|
||||
}
|
||||
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
|
||||
// 상태 코드별 기본 메시지
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return '잘못된 요청입니다';
|
||||
case 401:
|
||||
return '인증이 필요합니다';
|
||||
case 403:
|
||||
return '접근 권한이 없습니다';
|
||||
case 404:
|
||||
return '요청한 리소스를 찾을 수 없습니다';
|
||||
case 429:
|
||||
return '너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요';
|
||||
case 500:
|
||||
return '서버 내부 오류가 발생했습니다';
|
||||
case 502:
|
||||
return '게이트웨이 오류가 발생했습니다';
|
||||
case 503:
|
||||
return '서비스를 일시적으로 사용할 수 없습니다';
|
||||
default:
|
||||
return '서버 오류가 발생했습니다 (HTTP $statusCode)';
|
||||
}
|
||||
}
|
||||
|
||||
/// GET 요청
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
bool useCache = true,
|
||||
}) {
|
||||
final requestOptions = options ?? Options();
|
||||
|
||||
// 캐시 사용 설정
|
||||
if (!useCache) {
|
||||
requestOptions.extra = {
|
||||
...?requestOptions.extra,
|
||||
'disableCache': true,
|
||||
};
|
||||
}
|
||||
|
||||
return _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: requestOptions,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// POST 요청
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) {
|
||||
return _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// HEAD 요청 (리다이렉션 확인용)
|
||||
Future<Response<T>> head<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.head<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 캐시 삭제
|
||||
Future<void> clearCache() async {
|
||||
await _cacheStore?.clean();
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
void dispose() {
|
||||
_dio.close();
|
||||
_cacheStore?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 기본 네트워크 클라이언트 인스턴스
|
||||
final networkClient = NetworkClient();
|
||||
34
lib/core/network/network_config.dart
Normal file
34
lib/core/network/network_config.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
/// 네트워크 설정 상수
|
||||
///
|
||||
/// 모든 네트워크 관련 설정을 중앙 관리합니다.
|
||||
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';
|
||||
|
||||
/// CORS 프록시 URL 생성
|
||||
static String getCorsProxyUrl(String originalUrl) {
|
||||
return '$corsProxyUrl?url=${Uri.encodeComponent(originalUrl)}';
|
||||
}
|
||||
}
|
||||
284
lib/core/services/notification_service.dart
Normal file
284
lib/core/services/notification_service.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
|
||||
/// 알림 서비스 싱글톤 클래스
|
||||
class NotificationService {
|
||||
// 싱글톤 인스턴스
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
// Flutter Local Notifications 플러그인
|
||||
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;
|
||||
|
||||
/// 알림 서비스 초기화
|
||||
Future<bool> initialize() async {
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// iOS 초기화 설정
|
||||
final iosInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
onDidReceiveLocalNotification: (id, title, body, payload) async {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// Android 알림 채널 생성
|
||||
Future<void> _createNotificationChannel() async {
|
||||
const androidChannel = AndroidNotificationChannel(
|
||||
_channelId,
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
}
|
||||
|
||||
/// 알림 권한 요청
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
} else if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final granted = await macosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 권한 상태 확인
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
return true;
|
||||
}
|
||||
|
||||
// 알림 탭 콜백
|
||||
static void Function(NotificationResponse)? onNotificationTap;
|
||||
|
||||
/// 방문 확인 알림 예약
|
||||
Future<void> scheduleVisitReminder({
|
||||
required String restaurantId,
|
||||
required String restaurantName,
|
||||
required DateTime recommendationTime,
|
||||
}) 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 androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: '방문 확인',
|
||||
icon: '@mipmap/ic_launcher',
|
||||
autoCancel: true,
|
||||
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,
|
||||
'다녀왔음? 🍴',
|
||||
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
||||
scheduledTime,
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($randomMinutes분 후)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('알림 예약 실패: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 예약된 방문 확인 알림 취소
|
||||
Future<void> cancelVisitReminder() async {
|
||||
await _notifications.cancel(_visitReminderNotificationId);
|
||||
}
|
||||
|
||||
/// 모든 알림 취소
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
/// 예약된 알림 목록 조회
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
return await _notifications.pendingNotificationRequests();
|
||||
}
|
||||
|
||||
/// 알림 탭 이벤트 처리
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 백그라운드 알림 탭 이벤트 처리
|
||||
@pragma('vm:entry-point')
|
||||
static void _onBackgroundNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 즉시 알림 표시 (테스트용)
|
||||
Future<void> showImmediateNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/core/utils/category_mapper.dart
Normal file
142
lib/core/utils/category_mapper.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 동적 카테고리 매핑을 위한 유틸리티 클래스
|
||||
class CategoryMapper {
|
||||
static const Map<String, IconData> _iconMap = {
|
||||
// 주요 카테고리
|
||||
'한식': Icons.rice_bowl,
|
||||
'중식': Icons.ramen_dining,
|
||||
'중국요리': Icons.ramen_dining,
|
||||
'일식': Icons.set_meal,
|
||||
'일본요리': Icons.set_meal,
|
||||
'양식': Icons.restaurant,
|
||||
'아시안': Icons.soup_kitchen,
|
||||
'아시아음식': Icons.soup_kitchen,
|
||||
'패스트푸드': Icons.fastfood,
|
||||
'카페': Icons.local_cafe,
|
||||
'디저트': Icons.cake,
|
||||
'카페/디저트': Icons.local_cafe,
|
||||
'술집': Icons.local_bar,
|
||||
'주점': Icons.local_bar,
|
||||
'분식': Icons.fastfood,
|
||||
'치킨': Icons.egg,
|
||||
'피자': Icons.local_pizza,
|
||||
'베이커리': Icons.bakery_dining,
|
||||
'해물': Icons.set_meal,
|
||||
'해산물': Icons.set_meal,
|
||||
'고기': Icons.kebab_dining,
|
||||
'육류': Icons.kebab_dining,
|
||||
'채식': Icons.eco,
|
||||
'비건': Icons.eco,
|
||||
'브런치': Icons.brunch_dining,
|
||||
'뷔페': Icons.dining,
|
||||
// 기본값
|
||||
'기타': Icons.restaurant_menu,
|
||||
'음식점': Icons.restaurant_menu,
|
||||
};
|
||||
|
||||
static const Map<String, Color> _colorMap = {
|
||||
// 주요 카테고리
|
||||
'한식': Color(0xFFE53935),
|
||||
'중식': Color(0xFFFF6F00),
|
||||
'중국요리': Color(0xFFFF6F00),
|
||||
'일식': Color(0xFF43A047),
|
||||
'일본요리': Color(0xFF43A047),
|
||||
'양식': Color(0xFF1E88E5),
|
||||
'아시안': Color(0xFF8E24AA),
|
||||
'아시아음식': Color(0xFF8E24AA),
|
||||
'패스트푸드': Color(0xFFFDD835),
|
||||
'카페': Color(0xFF6D4C41),
|
||||
'디저트': Color(0xFFEC407A),
|
||||
'카페/디저트': Color(0xFF6D4C41),
|
||||
'술집': Color(0xFF546E7A),
|
||||
'주점': Color(0xFF546E7A),
|
||||
'분식': Color(0xFFFF7043),
|
||||
'치킨': Color(0xFFFFB300),
|
||||
'피자': Color(0xFFE91E63),
|
||||
'베이커리': Color(0xFF8D6E63),
|
||||
'해물': Color(0xFF00ACC1),
|
||||
'해산물': Color(0xFF00ACC1),
|
||||
'고기': Color(0xFFD32F2F),
|
||||
'육류': Color(0xFFD32F2F),
|
||||
'채식': Color(0xFF689F38),
|
||||
'비건': Color(0xFF388E3C),
|
||||
'브런치': Color(0xFFFFA726),
|
||||
'뷔페': Color(0xFF7B1FA2),
|
||||
// 기본값
|
||||
'기타': Color(0xFF757575),
|
||||
'음식점': Color(0xFF757575),
|
||||
};
|
||||
|
||||
/// 카테고리에 해당하는 아이콘 반환
|
||||
static IconData getIcon(String category) {
|
||||
// 완전 일치 검색
|
||||
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;
|
||||
}
|
||||
|
||||
/// 카테고리에 해당하는 색상 반환
|
||||
static Color getColor(String category) {
|
||||
// 완전 일치 검색
|
||||
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();
|
||||
return HSVColor.fromAHSV(1.0, hue, 0.6, 0.8).toColor();
|
||||
}
|
||||
|
||||
/// 카테고리 표시명 정규화
|
||||
static String getDisplayName(String category) {
|
||||
// 긴 카테고리명 축약
|
||||
if (category.length > 10) {
|
||||
// ">"로 구분된 경우 마지막 부분만 사용
|
||||
if (category.contains('>')) {
|
||||
final parts = category.split('>');
|
||||
return parts.last.trim();
|
||||
}
|
||||
// 공백으로 구분된 경우 첫 단어만 사용
|
||||
if (category.contains(' ')) {
|
||||
return category.split(' ').first;
|
||||
}
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
/// 네이버 카테고리 파싱 및 정규화
|
||||
static String normalizeNaverCategory(String category, String? subCategory) {
|
||||
// 카테고리가 "음식점"인 경우 subCategory 사용
|
||||
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;
|
||||
}
|
||||
}
|
||||
110
lib/core/utils/distance_calculator.dart
Normal file
110
lib/core/utils/distance_calculator.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
class DistanceCalculator {
|
||||
static const double earthRadiusKm = 6371.0;
|
||||
|
||||
static double calculateDistance({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
}) {
|
||||
final double dLat = _toRadians(lat2 - lat1);
|
||||
final double dLon = _toRadians(lon2 - lon1);
|
||||
|
||||
final double a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_toRadians(lat1)) *
|
||||
math.cos(_toRadians(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
math.sin(dLon / 2);
|
||||
|
||||
final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
|
||||
return earthRadiusKm * c;
|
||||
}
|
||||
|
||||
static double _toRadians(double degree) {
|
||||
return degree * (math.pi / 180);
|
||||
}
|
||||
|
||||
static String formatDistance(double distanceInKm) {
|
||||
if (distanceInKm < 1) {
|
||||
return '${(distanceInKm * 1000).round()}m';
|
||||
} else if (distanceInKm < 10) {
|
||||
return '${distanceInKm.toStringAsFixed(1)}km';
|
||||
} else {
|
||||
return '${distanceInKm.round()}km';
|
||||
}
|
||||
}
|
||||
|
||||
static bool isWithinDistance({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
required double maxDistanceKm,
|
||||
}) {
|
||||
final distance = calculateDistance(
|
||||
lat1: lat1,
|
||||
lon1: lon1,
|
||||
lat2: lat2,
|
||||
lon2: lon2,
|
||||
);
|
||||
return distance <= maxDistanceKm;
|
||||
}
|
||||
|
||||
static double? calculateDistanceFromCurrentLocation({
|
||||
required double targetLat,
|
||||
required double targetLon,
|
||||
double? currentLat,
|
||||
double? currentLon,
|
||||
}) {
|
||||
if (currentLat == null || currentLon == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateDistance(
|
||||
lat1: currentLat,
|
||||
lon1: currentLon,
|
||||
lat2: targetLat,
|
||||
lon2: targetLon,
|
||||
);
|
||||
}
|
||||
|
||||
static List<T> sortByDistance<T>({
|
||||
required List<T> items,
|
||||
required double Function(T) getLat,
|
||||
required double Function(T) getLon,
|
||||
required double currentLat,
|
||||
required double currentLon,
|
||||
}) {
|
||||
final List<T> sortedItems = List<T>.from(items);
|
||||
|
||||
sortedItems.sort((a, b) {
|
||||
final distanceA = calculateDistance(
|
||||
lat1: currentLat,
|
||||
lon1: currentLon,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
lib/core/utils/validators.dart
Normal file
92
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
class Validators {
|
||||
static String? validateRestaurantName(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '맛집 이름을 입력해주세요';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return '맛집 이름은 2자 이상이어야 합니다';
|
||||
}
|
||||
if (value.trim().length > 50) {
|
||||
return '맛집 이름은 50자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateMemo(String? value) {
|
||||
if (value != null && value.length > 200) {
|
||||
return '메모는 200자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateLatitude(String? value) {
|
||||
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;
|
||||
}
|
||||
|
||||
static String? validateLongitude(String? value) {
|
||||
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;
|
||||
}
|
||||
|
||||
static String? validateAddress(String? value) {
|
||||
if (value != null && value.length > 100) {
|
||||
return '주소는 100자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateCategory(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '카테고리를 선택해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateRating(double? value) {
|
||||
if (value != null && (value < 0 || value > 5)) {
|
||||
return '평점은 0에서 5 사이여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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,}$',
|
||||
);
|
||||
return emailRegex.hasMatch(email);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
154
lib/core/widgets/empty_state_widget.dart
Normal file
154
lib/core/widgets/empty_state_widget.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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;
|
||||
|
||||
const EmptyStateWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.icon,
|
||||
this.iconSize = 80.0,
|
||||
this.actionText,
|
||||
this.onAction,
|
||||
this.customWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 아이콘 또는 커스텀 위젯
|
||||
if (customWidget != null)
|
||||
customWidget!
|
||||
else if (icon != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary
|
||||
).withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
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),
|
||||
Text(
|
||||
message!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// 액션 버튼 (있을 경우)
|
||||
if (actionText != null && onAction != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: onAction,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
actionText!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 리스트 빈 상태 위젯
|
||||
///
|
||||
/// 리스트나 그리드가 비어있을 때 사용하는 특화된 위젯
|
||||
class ListEmptyStateWidget extends StatelessWidget {
|
||||
/// 아이템 유형 (예: "식당", "기록" 등)
|
||||
final String itemType;
|
||||
|
||||
/// 추가 액션 콜백 (선택사항)
|
||||
final VoidCallback? onAdd;
|
||||
|
||||
const ListEmptyStateWidget({
|
||||
super.key,
|
||||
required this.itemType,
|
||||
this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return EmptyStateWidget(
|
||||
icon: Icons.inbox_outlined,
|
||||
title: '$itemType이(가) 없습니다',
|
||||
message: '새로운 $itemType을(를) 추가해보세요',
|
||||
actionText: onAdd != null ? '$itemType 추가' : null,
|
||||
onAction: onAdd,
|
||||
);
|
||||
}
|
||||
}
|
||||
116
lib/core/widgets/error_widget.dart
Normal file
116
lib/core/widgets/error_widget.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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;
|
||||
|
||||
const CustomErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon,
|
||||
this.onRetry,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 에러 아이콘
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: 64,
|
||||
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),
|
||||
Text(
|
||||
details!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// 재시도 버튼 (있을 경우)
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('다시 시도'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 간단한 에러 스낵바를 표시하는 유틸리티 함수
|
||||
void showErrorSnackBar({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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),
|
||||
),
|
||||
margin: const EdgeInsets.all(8),
|
||||
),
|
||||
);
|
||||
}
|
||||
88
lib/core/widgets/loading_indicator.dart
Normal file
88
lib/core/widgets/loading_indicator.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/app_colors.dart';
|
||||
|
||||
/// 로딩 인디케이터 위젯
|
||||
///
|
||||
/// 앱 전체에서 일관된 로딩 표시를 위한 공통 위젯
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
/// 인디케이터 크기
|
||||
final double size;
|
||||
|
||||
/// 스트로크 너비
|
||||
final double strokeWidth;
|
||||
|
||||
const LoadingIndicator({
|
||||
super.key,
|
||||
this.message,
|
||||
this.size = 40.0,
|
||||
this.strokeWidth = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: strokeWidth,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 전체 화면 로딩 인디케이터
|
||||
///
|
||||
/// 화면 전체를 덮는 로딩 표시를 위한 위젯
|
||||
class FullScreenLoadingIndicator extends StatelessWidget {
|
||||
/// 로딩 메시지 (선택사항)
|
||||
final String? message;
|
||||
|
||||
/// 배경 투명도
|
||||
final double backgroundOpacity;
|
||||
|
||||
const FullScreenLoadingIndicator({
|
||||
super.key,
|
||||
this.message,
|
||||
this.backgroundOpacity = 0.5,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
color: (isDark ? Colors.black : Colors.white)
|
||||
.withValues(alpha: backgroundOpacity),
|
||||
child: LoadingIndicator(message: message),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user