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,246 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../domain/entities/restaurant.dart';
import '../providers/restaurant_provider.dart';
/// 식당 추가 화면의 상태 모델
class AddRestaurantState {
final bool isLoading;
final String? errorMessage;
final Restaurant? fetchedRestaurantData;
final RestaurantFormData formData;
const AddRestaurantState({
this.isLoading = false,
this.errorMessage,
this.fetchedRestaurantData,
required this.formData,
});
AddRestaurantState copyWith({
bool? isLoading,
String? errorMessage,
Restaurant? fetchedRestaurantData,
RestaurantFormData? formData,
}) {
return AddRestaurantState(
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
fetchedRestaurantData: fetchedRestaurantData ?? this.fetchedRestaurantData,
formData: formData ?? this.formData,
);
}
}
/// 식당 폼 데이터 모델
class RestaurantFormData {
final String name;
final String category;
final String subCategory;
final String description;
final String phoneNumber;
final String roadAddress;
final String jibunAddress;
final String latitude;
final String longitude;
final String naverUrl;
const RestaurantFormData({
this.name = '',
this.category = '',
this.subCategory = '',
this.description = '',
this.phoneNumber = '',
this.roadAddress = '',
this.jibunAddress = '',
this.latitude = '',
this.longitude = '',
this.naverUrl = '',
});
RestaurantFormData copyWith({
String? name,
String? category,
String? subCategory,
String? description,
String? phoneNumber,
String? roadAddress,
String? jibunAddress,
String? latitude,
String? longitude,
String? naverUrl,
}) {
return RestaurantFormData(
name: name ?? this.name,
category: category ?? this.category,
subCategory: subCategory ?? this.subCategory,
description: description ?? this.description,
phoneNumber: phoneNumber ?? this.phoneNumber,
roadAddress: roadAddress ?? this.roadAddress,
jibunAddress: jibunAddress ?? this.jibunAddress,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
naverUrl: naverUrl ?? this.naverUrl,
);
}
/// TextEditingController로부터 폼 데이터 생성
factory RestaurantFormData.fromControllers({
required TextEditingController nameController,
required TextEditingController categoryController,
required TextEditingController subCategoryController,
required TextEditingController descriptionController,
required TextEditingController phoneController,
required TextEditingController roadAddressController,
required TextEditingController jibunAddressController,
required TextEditingController latitudeController,
required TextEditingController longitudeController,
required TextEditingController naverUrlController,
}) {
return RestaurantFormData(
name: nameController.text.trim(),
category: categoryController.text.trim(),
subCategory: subCategoryController.text.trim(),
description: descriptionController.text.trim(),
phoneNumber: phoneController.text.trim(),
roadAddress: roadAddressController.text.trim(),
jibunAddress: jibunAddressController.text.trim(),
latitude: latitudeController.text.trim(),
longitude: longitudeController.text.trim(),
naverUrl: naverUrlController.text.trim(),
);
}
/// Restaurant 엔티티로부터 폼 데이터 생성
factory RestaurantFormData.fromRestaurant(Restaurant restaurant) {
return RestaurantFormData(
name: restaurant.name,
category: restaurant.category,
subCategory: restaurant.subCategory,
description: restaurant.description ?? '',
phoneNumber: restaurant.phoneNumber ?? '',
roadAddress: restaurant.roadAddress,
jibunAddress: restaurant.jibunAddress,
latitude: restaurant.latitude.toString(),
longitude: restaurant.longitude.toString(),
naverUrl: restaurant.naverUrl ?? '',
);
}
/// Restaurant 엔티티로 변환
Restaurant toRestaurant() {
final uuid = const Uuid();
return Restaurant(
id: uuid.v4(),
name: name,
category: category,
subCategory: subCategory.isEmpty ? category : subCategory,
description: description.isEmpty ? null : description,
phoneNumber: phoneNumber.isEmpty ? null : phoneNumber,
roadAddress: roadAddress,
jibunAddress: jibunAddress.isEmpty ? roadAddress : jibunAddress,
latitude: double.tryParse(latitude) ?? 37.5665,
longitude: double.tryParse(longitude) ?? 126.9780,
naverUrl: naverUrl.isEmpty ? null : naverUrl,
source: naverUrl.isNotEmpty ? DataSource.NAVER : DataSource.USER_INPUT,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
}
/// 식당 추가 화면의 ViewModel
class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
final Ref _ref;
AddRestaurantViewModel(this._ref)
: super(const AddRestaurantState(formData: RestaurantFormData()));
/// 네이버 URL로부터 식당 정보 가져오기
Future<void> fetchFromNaverUrl(String url) async {
if (url.trim().isEmpty) {
state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
return;
}
state = state.copyWith(isLoading: true, errorMessage: null);
try {
final notifier = _ref.read(restaurantNotifierProvider.notifier);
final restaurant = await notifier.addRestaurantFromUrl(url);
state = state.copyWith(
isLoading: false,
fetchedRestaurantData: restaurant,
formData: RestaurantFormData.fromRestaurant(restaurant),
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
);
}
}
/// 식당 정보 저장
Future<bool> saveRestaurant() async {
final notifier = _ref.read(restaurantNotifierProvider.notifier);
try {
Restaurant restaurantToSave;
// 네이버에서 가져온 데이터가 있으면 업데이트
final fetchedData = state.fetchedRestaurantData;
if (fetchedData != null) {
restaurantToSave = fetchedData.copyWith(
name: state.formData.name,
category: state.formData.category,
subCategory: state.formData.subCategory.isEmpty
? state.formData.category
: state.formData.subCategory,
description: state.formData.description.isEmpty
? null
: state.formData.description,
phoneNumber: state.formData.phoneNumber.isEmpty
? null
: state.formData.phoneNumber,
roadAddress: state.formData.roadAddress,
jibunAddress: state.formData.jibunAddress.isEmpty
? state.formData.roadAddress
: state.formData.jibunAddress,
latitude: double.tryParse(state.formData.latitude) ?? fetchedData.latitude,
longitude: double.tryParse(state.formData.longitude) ?? fetchedData.longitude,
naverUrl: state.formData.naverUrl.isEmpty ? null : state.formData.naverUrl,
updatedAt: DateTime.now(),
);
} else {
// 직접 입력한 경우
restaurantToSave = state.formData.toRestaurant();
}
await notifier.addRestaurantDirect(restaurantToSave);
return true;
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
return false;
}
}
/// 폼 데이터 업데이트
void updateFormData(RestaurantFormData formData) {
state = state.copyWith(formData: formData);
}
/// 에러 메시지 초기화
void clearError() {
state = state.copyWith(errorMessage: null);
}
}
/// AddRestaurantViewModel Provider
final addRestaurantViewModelProvider =
StateNotifierProvider.autoDispose<AddRestaurantViewModel, AddRestaurantState>(
(ref) => AddRestaurantViewModel(ref),
);