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:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View 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;
}
}

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

View 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;
}
}