LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
246 lines
8.0 KiB
Dart
246 lines
8.0 KiB
Dart
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),
|
|
); |