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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user