From 845db066bb2a57d872745763832e8a885823a148 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Tue, 24 Feb 2026 21:47:08 +0900 Subject: [PATCH] =?UTF-8?q?[NDGL-112]=20feature:=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EA=B4=80=EB=A0=A8=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yapp/ndgl/data/travel/api/PlaceApi.kt | 18 ++ .../model/GetBookmarkedPlacesResponse.kt | 25 ++ .../data/travel/repository/PlaceRepository.kt | 13 + .../additinerary/AddItineraryContract.kt | 2 +- .../travel/additinerary/AddItineraryScreen.kt | 73 +++-- .../additinerary/AddItineraryViewModel.kt | 252 +++++++++++------- .../component/AddItineraryBottomSheet.kt | 2 +- 7 files changed, 249 insertions(+), 136 deletions(-) create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GetBookmarkedPlacesResponse.kt diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/PlaceApi.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/PlaceApi.kt index a181ceaa..4319ac3b 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/PlaceApi.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/PlaceApi.kt @@ -1,10 +1,12 @@ package com.yapp.ndgl.data.travel.api import com.yapp.ndgl.data.core.model.BaseResponse +import com.yapp.ndgl.data.travel.model.GetBookmarkedPlacesResponse import com.yapp.ndgl.data.travel.model.GetPlacePhotosResponse import com.yapp.ndgl.data.travel.model.PlaceDetailResponse import com.yapp.ndgl.data.travel.model.SavePlaceRequest import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Query @@ -24,4 +26,20 @@ interface PlaceApi { suspend fun getPlacePhotos( @Query("googlePlaceId") googlePlaceId: String, ): BaseResponse + + @GET("/api/v1/places/favorite") + suspend fun getBookmarkedPlaces( + @Query("page") page: Int? = null, + @Query("size")size: Int? = null, + ): BaseResponse + + @POST("/api/v1/places/favorite") + suspend fun bookmarkPlace( + @Query("googlePlaceId") googlePlaceId: String, + ): BaseResponse + + @DELETE("/api/v1/places/favorite") + suspend fun unBookmarkPlace( + @Query("googlePlaceId") googlePlaceId: String, + ): BaseResponse } diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GetBookmarkedPlacesResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GetBookmarkedPlacesResponse.kt new file mode 100644 index 00000000..2468420c --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GetBookmarkedPlacesResponse.kt @@ -0,0 +1,25 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetBookmarkedPlacesResponse( + @SerialName("content") + val places: List, + val hasNext: Boolean, +) + +@Serializable +data class PlaceInfo( + val id: Long, + val googlePlaceId: String, + val name: String, + val formattedAddress: String?, + val latitude: Double, + val longitude: Double, + val thumbnail: String?, + val rating: Double?, + val userRatingCount: Int?, + val category: PlaceCategory, +) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt index 245f4a08..1c903add 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt @@ -6,6 +6,7 @@ import com.google.android.libraries.places.api.net.PlacesClient import com.yapp.ndgl.data.core.model.error.HttpResponseException import com.yapp.ndgl.data.core.model.getData import com.yapp.ndgl.data.travel.api.PlaceApi +import com.yapp.ndgl.data.travel.model.GetBookmarkedPlacesResponse import com.yapp.ndgl.data.travel.model.GetPlacePhotosResponse import com.yapp.ndgl.data.travel.model.PlaceDetailResponse import com.yapp.ndgl.data.travel.model.SavePlaceRequest @@ -63,4 +64,16 @@ class PlaceRepository @Inject constructor( suspend fun getPlacePhotos(googlePlaceId: String): GetPlacePhotosResponse { return placeApi.getPlacePhotos(googlePlaceId).getData() } + + suspend fun getBookmarkedPlaces(page: Int? = null, size: Int? = null): GetBookmarkedPlacesResponse { + return placeApi.getBookmarkedPlaces(page = page, size = size).getData() + } + + suspend fun bookmarkPlace(googlePlaceId: String) { + placeApi.bookmarkPlace(googlePlaceId).getData() + } + + suspend fun unBookmarkPlace(googlePlaceId: String) { + placeApi.unBookmarkPlace(googlePlaceId).getData() + } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryContract.kt index c808275a..5cf6389f 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryContract.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryContract.kt @@ -58,7 +58,7 @@ data class SelectablePlace( val googlePlaceId: String, val name: String, val placeType: PlaceType = PlaceType.ATTRACTION, - val thumbnail: String, + val thumbnail: String?, ) data class SelectedPlaceDetail( diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryScreen.kt index d77382f7..b0b68301 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryScreen.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryScreen.kt @@ -178,47 +178,34 @@ private fun AddItineraryScreen( } } -private val previewRecommendedPlaces = listOf( - SelectablePlace( - googlePlaceId = "place_1", - name = "카피톨리니 박물관 (Musei Capitolini)", - placeType = PlaceType.ATTRACTION, - thumbnail = "https://picsum.photos/seed/capitolini/200", - ), - SelectablePlace( - googlePlaceId = "place_2", - name = "콜로세움 (Colosseo)", - placeType = PlaceType.ATTRACTION, - thumbnail = "https://picsum.photos/seed/colosseo/200", - ), - SelectablePlace( - googlePlaceId = "place_3", - name = "젤라테리아 파씨 (Gelateria Fassi)", - placeType = PlaceType.CAFE, - thumbnail = "https://picsum.photos/seed/gelato/200", - ), -) - -private val previewSelectedPlaceDetail = SelectedPlaceDetail( - placeInfo = PlaceInfo( - googlePlaceId = "1", - name = "콜로세움", - placeType = PlaceType.ATTRACTION, - rating = 4.8, - userRatingCount = 12450, - address = "Piazza del Colosseo, 1, 00184 Roma RM, Italy", - phoneNumber = "+39 06 3996 7700", - websiteUrl = "https://www.colosseo.it", - estimatedDuration = 2.hours, - ), -) - @Preview(showBackground = true) @Composable private fun AddItineraryScreenWithAddItineraryBottomSheetPreview() { NDGLTheme { AddItineraryScreen( - state = AddItineraryState(day = 1, recommendedPlaces = previewRecommendedPlaces), + state = AddItineraryState( + day = 1, + recommendedPlaces = listOf( + SelectablePlace( + googlePlaceId = "place_1", + name = "카피톨리니 박물관 (Musei Capitolini)", + placeType = PlaceType.ATTRACTION, + thumbnail = "https://picsum.photos/seed/capitolini/200", + ), + SelectablePlace( + googlePlaceId = "place_2", + name = "콜로세움 (Colosseo)", + placeType = PlaceType.ATTRACTION, + thumbnail = "https://picsum.photos/seed/colosseo/200", + ), + SelectablePlace( + googlePlaceId = "place_3", + name = "젤라테리아 파씨 (Gelateria Fassi)", + placeType = PlaceType.CAFE, + thumbnail = "https://picsum.photos/seed/gelato/200", + ), + ), + ), clickBack = {}, updateKeyword = {}, searchKeyword = {}, @@ -243,7 +230,19 @@ private fun AddItineraryWithSearchedPlaceBottomSheetPreview() { state = AddItineraryState( day = 1, isSearched = true, - selectedPlaceDetail = previewSelectedPlaceDetail, + selectedPlaceDetail = SelectedPlaceDetail( + placeInfo = PlaceInfo( + googlePlaceId = "1", + name = "콜로세움", + placeType = PlaceType.ATTRACTION, + rating = 4.8, + userRatingCount = 12450, + address = "Piazza del Colosseo, 1, 00184 Roma RM, Italy", + phoneNumber = "+39 06 3996 7700", + websiteUrl = "https://www.colosseo.it", + estimatedDuration = 2.hours, + ), + ), ), clickBack = {}, updateKeyword = {}, diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryViewModel.kt index b5fed3fb..ccc83187 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryViewModel.kt @@ -8,19 +8,19 @@ import com.yapp.ndgl.data.travel.model.AddPlaceEvent import com.yapp.ndgl.data.travel.repository.PlaceRepository import com.yapp.ndgl.data.travel.repository.UserTravelRepository import com.yapp.ndgl.feature.travel.model.PlacePhoto -import com.yapp.ndgl.feature.travel.model.PlaceType import com.yapp.ndgl.feature.travel.model.toPlaceCategory import com.yapp.ndgl.feature.travel.model.toPlaceInfo +import com.yapp.ndgl.feature.travel.model.toPlaceType import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch - -// TODO("테스트용으로 지워야함") -private const val TEST_THUMBNAIL_URL = "https://picsum.photos/200" +import timber.log.Timber @HiltViewModel(assistedFactory = AddItineraryViewModel.Factory::class) class AddItineraryViewModel @AssistedInject constructor( @@ -36,74 +36,74 @@ class AddItineraryViewModel @AssistedInject constructor( private var searchJob: Job? = null init { - loadInitialData() + loadRecommendedPlaces() + loadBookmarkedPlaces() } - private fun loadInitialData() { - // FIXME: Repository에서 현재 일차 장소 + 추천 장소 로드 - val stubRecommendedPlaces = listOf( - SelectablePlace( - googlePlaceId = "ChIJKWGrTn8hQTUR7zeTLtzYJL4", - name = "카피톨리니 박물관 (Musei Capitolini)", - placeType = PlaceType.ATTRACTION, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "1", - name = "콜로세움 (Colosseo)", - placeType = PlaceType.ATTRACTION, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "2", - name = "트레비 분수 (Fontana di Trevi)", - placeType = PlaceType.ATTRACTION, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "3", - name = "젤라테리아 파씨 (Gelateria Fassi)", - placeType = PlaceType.CAFE, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "4", - name = "리스토란테 일 팔라초 (Ristorante Il Palazzo)", - placeType = PlaceType.RESTAURANT, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "5", - name = "카피톨리니 박물관 (Musei Capitolini)", - placeType = PlaceType.ATTRACTION, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "6", - name = "리스토란테 일 팔라초 (Ristorante Il Palazzo)", - placeType = PlaceType.RESTAURANT, - thumbnail = TEST_THUMBNAIL_URL, - ), - SelectablePlace( - googlePlaceId = "7", - name = "카피톨리니 박물관 (Musei Capitolini)", - placeType = PlaceType.ATTRACTION, - thumbnail = TEST_THUMBNAIL_URL, - ), - ) - val stubBookmarkedPlaces = listOf( - SelectablePlace( - googlePlaceId = "8", - name = "카페 산트에우스타키오 (Caffè Sant'Eustachio)", - placeType = PlaceType.CAFE, - thumbnail = TEST_THUMBNAIL_URL, - ), - ) - reduce { - copy( - recommendedPlaces = stubRecommendedPlaces, - bookmarkedPlaces = stubBookmarkedPlaces, - ) + private fun loadRecommendedPlaces() = viewModelScope.launch { + val travelInfo = suspendRunCatching { + userTravelRepository.getUserTravelTemplateContentInfo(travelId) + }.getOrNull() + + // FIXME: 임시 추천 장소 + val recommendedPlaces = if (travelInfo != null) { + val firstChar = travelInfo.city.firstOrNull()?.toString() ?: travelInfo.countryName?.firstOrNull()?.toString() ?: "" + if (firstChar.isNotBlank()) { + // 1. 검색으로 5개 장소 찾기 + val searchResults = suspendRunCatching { + placeRepository.searchKeyword( + keyword = firstChar, + countryCode = countryCode, + ) + }.getOrNull()?.results?.take(5) ?: emptyList() + + // 2. 5개 장소를 동시에 조회 (병렬 처리) + val placeDetails = searchResults.map { result -> + async { + suspendRunCatching { + placeRepository.getPlace(result.googlePlaceId) + }.getOrNull() + } + }.awaitAll() + + // 3. SelectablePlace로 변환 + placeDetails.mapNotNull { response -> + response?.let { + SelectablePlace( + googlePlaceId = it.place.id, + name = it.place.name, + placeType = it.place.category.toPlaceType(), + thumbnail = it.place.thumbnail, + ) + } + } + } else { + emptyList() + } + } else { + emptyList() + } + + reduce { copy(recommendedPlaces = recommendedPlaces) } + } + + private fun loadBookmarkedPlaces() = viewModelScope.launch { + suspendRunCatching { + placeRepository.getBookmarkedPlaces() + }.onSuccess { response -> + val bookmarkedPlaces = response.places.map { place -> + SelectablePlace( + googlePlaceId = place.googlePlaceId, + name = place.name, + placeType = place.category.toPlaceType(), + thumbnail = place.thumbnail, + ) + } + + reduce { copy(bookmarkedPlaces = bookmarkedPlaces) } + }.onFailure { + // FIXME: Handle Error + Timber.d("${it.message} $it") } } @@ -163,8 +163,8 @@ class AddItineraryViewModel @AssistedInject constructor( ) } reduce { copy(isSearched = true, searchResults = results) } - }.onFailure { error -> - // FIXME: 임시 조치 + }.onFailure { + // FIXME: Handle Error reduce { copy(isSearched = true, searchResults = emptyList()) } } } @@ -190,7 +190,7 @@ class AddItineraryViewModel @AssistedInject constructor( ) } reduce { copy(isSearched = true, searchResults = results) } - }.onFailure { error -> + }.onFailure { reduce { copy(isSearched = true, searchResults = emptyList()) } } } @@ -261,7 +261,6 @@ class AddItineraryViewModel @AssistedInject constructor( private fun selectChip(chip: AddItineraryChip) { reduce { copy(selectedChip = chip) } - // FIXME: 칩에 따라 추천 장소 목록 로드 } private fun checkSelectablePlace(googlePlaceId: String) { @@ -278,33 +277,68 @@ class AddItineraryViewModel @AssistedInject constructor( private fun clickAddItinerary() = viewModelScope.launch { val selectedDetail = state.value.selectedPlaceDetail + val checkedPlaceId = state.value.checkedPlaceId - if (selectedDetail == null) { + // 검색해서 선택한 장소가 있는 경우 + if (selectedDetail != null) { + val placeInfo = selectedDetail.placeInfo + userTravelRepository.emitAddPlaceEvent( + AddPlaceEvent( + travelId = travelId, + day = day, + googlePlaceId = placeInfo.googlePlaceId, + name = placeInfo.name, + latitude = placeInfo.latitude, + longitude = placeInfo.longitude, + thumbnail = placeInfo.thumbnail, + placeType = placeInfo.placeType.toPlaceCategory(), + address = placeInfo.address, + phoneNumber = placeInfo.phoneNumber, + googleMapsUri = placeInfo.googleMapsUri, + websiteUrl = placeInfo.websiteUrl, + rating = placeInfo.rating, + userRatingCount = placeInfo.userRatingCount, + estimatedDuration = placeInfo.estimatedDuration.inWholeMinutes.toInt(), + ), + ) postSideEffect(AddItinerarySideEffect.NavigateBack) return@launch } - val placeInfo = selectedDetail.placeInfo - userTravelRepository.emitAddPlaceEvent( - AddPlaceEvent( - travelId = travelId, - day = day, - googlePlaceId = placeInfo.googlePlaceId, - name = placeInfo.name, - latitude = placeInfo.latitude, - longitude = placeInfo.longitude, - thumbnail = placeInfo.thumbnail, - placeType = placeInfo.placeType.toPlaceCategory(), - address = placeInfo.address, - phoneNumber = placeInfo.phoneNumber, - googleMapsUri = placeInfo.googleMapsUri, - websiteUrl = placeInfo.websiteUrl, - rating = placeInfo.rating, - userRatingCount = placeInfo.userRatingCount, - estimatedDuration = placeInfo.estimatedDuration.inWholeMinutes.toInt(), - ), - ) + // AddItineraryBottomSheet에서 체크박스로 선택한 장소가 있는 경우 + if (checkedPlaceId != null) { + // 장소 상세 정보 가져오기 + val placeDetail = suspendRunCatching { + placeRepository.getPlace(checkedPlaceId) + }.getOrNull() + + if (placeDetail != null) { + userTravelRepository.emitAddPlaceEvent( + AddPlaceEvent( + travelId = travelId, + day = day, + googlePlaceId = placeDetail.place.id, + name = placeDetail.place.name, + latitude = placeDetail.place.location.latitude, + longitude = placeDetail.place.location.longitude, + thumbnail = placeDetail.place.thumbnail, + placeType = placeDetail.place.category, + address = placeDetail.place.formattedAddress, + phoneNumber = placeDetail.place.nationalPhoneNumber + ?: placeDetail.place.internationalPhoneNumber, + googleMapsUri = placeDetail.place.googleMapsUri, + websiteUrl = placeDetail.place.websiteUri, + rating = placeDetail.place.rating, + userRatingCount = placeDetail.place.userRatingCount, + estimatedDuration = 60, // 기본값 60분 + ), + ) + postSideEffect(AddItinerarySideEffect.NavigateBack) + return@launch + } + } + // 선택한 장소가 없으면 그냥 뒤로가기 postSideEffect(AddItinerarySideEffect.NavigateBack) } @@ -320,8 +354,32 @@ class AddItineraryViewModel @AssistedInject constructor( postSideEffect(AddItinerarySideEffect.NavigateToBrowser(url)) } - private fun bookmarkPlace(placeId: String) { - // FIXME: 북마크 저장 기능 연동 + private fun bookmarkPlace(placeId: String) = viewModelScope.launch { + val currentPlaceDetail = state.value.selectedPlaceDetail + val isBookmarked = currentPlaceDetail?.placeInfo?.isBookMarked ?: false + + suspendRunCatching { + if (isBookmarked) { + placeRepository.unBookmarkPlace(placeId) + } else { + placeRepository.bookmarkPlace(placeId) + } + }.onSuccess { + currentPlaceDetail?.let { detail -> + reduce { + copy( + selectedPlaceDetail = detail.copy( + placeInfo = detail.placeInfo.copy(isBookMarked = !isBookmarked), + ), + ) + } + } + + // 북마크 리스트 새로고침 + loadBookmarkedPlaces() + }.onFailure { + // TODO: 에러 처리 + } } @AssistedFactory diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryBottomSheet.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryBottomSheet.kt index 63c60913..61be23a1 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryBottomSheet.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryBottomSheet.kt @@ -57,9 +57,9 @@ enum class AddItineraryBottomSheetValue { internal fun AddItineraryBottomSheet( modifier: Modifier = Modifier, initialValue: AddItineraryBottomSheetValue = AddItineraryBottomSheetValue.PartiallyExpanded, + places: List, day: Int, selectedChip: AddItineraryChip, - places: List, checkedPlaceId: String? = null, selectChip: (AddItineraryChip) -> Unit, checkSelectablePlace: (String) -> Unit,